feat(harness): F6/F7 close on their last approval — no echo phase gate

Where completeness is machine-detectable, the reviewer's last substantive
approval now closes the phase itself (same pattern as F3/F4/F8):

- F6: approving the LAST CTE of the plan (kind:"cte_result" with next_cte
  now empty) advances the phase; a non-final CTE keeps the phase open and
  names the next one.
- F7: kind:"sql" records sql_approved — which IS F7's only advance
  prerequisite — and advances immediately.

Two reviewer interactions per session removed, both pure echoes. The
summary phase gate remains only where completeness is a human judgment
(F1, F2 with recorded memories, F5). SKILL.md states the rule and the
five self-closing gates; L1 tests cover last/non-last CTE and sql close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 02:23:45 +02:00
co-authored by Claude Fable 5
parent c4951e2aa5
commit 83942c0b5c
3 changed files with 161 additions and 19 deletions
@@ -0,0 +1,106 @@
// F6/F7 auto-close: where completeness is machine-detectable the LAST substantive
// approval closes the phase itself — approving the final CTE of the plan (F6) and
// approving the SQL (F7) must run `tht phase advance` without a separate
// reviewer_confirm kind:"phase"; a non-final CTE must NOT advance.
//
// HARNESS NOTE: the gate's ESM `import { execFileSync }` is snapshotted from the CJS
// namespace at first require — later reassignments of cp.execFileSync are NOT seen.
// Install ONE dispatcher before requiring the gate; tests swap its `current` handler.
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
const shell = { current: () => "" };
cp.execFileSync = (file, args, opts) => shell.current(file, args, opts);
const META = JSON.stringify({
max_phase: 8,
phases: [
{ num: 6, id: "F6" },
{ num: 7, id: "F7" },
{ num: 8, id: "F8" },
],
});
// `cte next` walks the queue one entry per `decision add cte_approved`.
function useShell({ phase, cteQueue = [] }) {
const calls = [];
const queue = [...cteQueue];
shell.current = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta") return META;
if (args[0] === "phase" && args[1] === "show") return `Fase corrente: ${phase}\n`;
if (args[0] === "cte" && args[1] === "next") return queue.length ? `${queue[0]}\n` : "";
if (args[0] === "decision" && args[1] === "add" && args.includes("cte_approved")) {
queue.shift();
return "";
}
return "";
};
return calls;
}
async function runConfirm(kind, artifactKind) {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
// Reset the module-level phase-meta cache captured by a previous test's handler.
await pi.emit("session_start", {});
ctx.ui.input = async (title) => {
const d = JSON.parse(title);
return JSON.stringify({ id: d.id, kind: "artifact-gate", choices: ["approve"] });
};
return tools.get("reviewer_confirm").def.execute(
"call-1",
{ session: "s1", kind, title: "t", artifact: { kind: artifactKind, data: "# md" } },
null,
null,
ctx,
);
}
test("approving a NON-final CTE records it and does NOT advance the phase", async () => {
const calls = useShell({ phase: 6, cteQueue: ["cte_a", "cte_b"] });
const result = await runConfirm("cte_result", "cte_result");
assert.match(result.content[0].text, /cte_a.*approvato/i);
assert.match(result.content[0].text, /cte_b/);
assert.ok(!calls.some((c) => c.startsWith("phase advance")), `no advance in: ${calls}`);
});
test("approving the LAST CTE of the plan closes F6 automatically", async () => {
const calls = useShell({ phase: 6, cteQueue: ["cte_finale"] });
const result = await runConfirm("cte_result", "cte_result");
assert.match(result.content[0].text, /cte_finale.*approvato/i);
assert.match(result.content[0].text, /chiusa automaticamente/);
assert.match(result.content[0].text, /NON presentare reviewer_confirm/);
assert.ok(
calls.some((c) => c === "phase advance --session s1"),
`expected phase advance in: ${calls}`,
);
assert.ok(!calls.some((c) => c.startsWith("session finalize")), "F6 must not finalize");
});
test("approving the SQL closes F7 automatically (no separate phase gate)", async () => {
const calls = useShell({ phase: 7 });
const result = await runConfirm("sql", "sql");
assert.match(result.content[0].text, /SQL approvato/);
assert.match(result.content[0].text, /chiusa automaticamente/);
assert.ok(
calls.some((c) => c.includes("decision add --session s1 --type sql_approved")),
`expected sql_approved in: ${calls}`,
);
assert.ok(
calls.some((c) => c === "phase advance --session s1"),
`expected phase advance in: ${calls}`,
);
assert.ok(!calls.some((c) => c.startsWith("session finalize")), "F7 must not finalize");
});
+35 -2
View File
@@ -1299,7 +1299,28 @@ export default function (pi) {
"", "",
); );
if (err) return err; if (err) return err;
return textResult(`CTE '${cteName}' approvato (sessione ${session}).`); // Deterministic completeness: when no CTE remains in the plan the reviewer
// has approved everything F6 can ask — close the phase here instead of
// echoing a reviewer_confirm kind:"phase" that decides nothing new.
const remaining = tht(ctx, ["cte", "next", "--session", session]).trim();
if (remaining) {
return textResult(
`CTE '${cteName}' approvato (sessione ${session}). Prossimo CTE del piano: '${remaining}'.`,
);
}
const closed = advancePhaseAndFinalize(ctx, session, curNum);
if (closed.err) return closed.err;
if (closed.finalized) {
lockActive = false;
lastSteered = false;
return textResult(
`CTE '${cteName}' approvato — era l'ultimo del piano: fase chiusa e sessione finalizzata (${session}).`,
);
}
return textResult(
`CTE '${cteName}' approvato — era l'ultimo del piano: Fase ${curNum} chiusa automaticamente. ` +
'NON presentare reviewer_confirm kind:"phase" per questa fase; prosegui con la successiva.',
);
} }
if (kind === "sql") { if (kind === "sql") {
const err = relayIfThtFails( const err = relayIfThtFails(
@@ -1317,7 +1338,19 @@ export default function (pi) {
"", "",
); );
if (err) return err; if (err) return err;
return textResult(`SQL approvato (sessione ${session}).`); // F7's only prerequisite IS sql_approved (workflow.yaml): the phase gate
// after it decides nothing new — close the phase here.
const closed = advancePhaseAndFinalize(ctx, session, curNum);
if (closed.err) return closed.err;
if (closed.finalized) {
lockActive = false;
lastSteered = false;
return textResult(`SQL approvato — fase chiusa e sessione finalizzata (${session}).`);
}
return textResult(
`SQL approvato — Fase ${curNum} chiusa automaticamente. ` +
'NON presentare reviewer_confirm kind:"phase" per questa fase; prosegui con la successiva (datamart/memorie).',
);
} }
return textResult(`Approvato (kind=${kind}, sessione ${session}).`); return textResult(`Approvato (kind=${kind}, sessione ${session}).`);
} catch (fatal) { } catch (fatal) {
+20 -17
View File
@@ -26,13 +26,16 @@ about a domain term, ask the reviewer.
## Phase map (advance cheat-sheet) ## Phase map (advance cheat-sheet)
A phase advances ONLY when a `phase_approved:phase:N` decision is recorded for the current A phase advances ONLY when a `phase_approved:phase:N` decision is recorded for the current
phase — written by `reviewer_confirm kind:"phase"`. (F7 is two-step: `kind:"sql"` records phase. THE RULE: where completeness is machine-detectable, the LAST substantive approval
`sql_approved`, then `kind:"phase"` advances.) A `reviewer_decide`/ closes the phase itself; a `reviewer_confirm kind:"phase"` summary gate exists only where
completeness is a human judgment (F1, F2 with recorded memories, F5). A `reviewer_decide`/
`reviewer_select` choice records its OWN decision but does NOT advance the phase. `advance:true` `reviewer_select` choice records its OWN decision but does NOT advance the phase. `advance:true`
on `reviewer_decide` auto-advances only F2 (empty memory) and F6 (skipped/empty) — never a on `reviewer_decide` auto-advances only F2 (empty memory) and F6 (skipped/empty) — never a
phase that recorded substantive decisions. Exactly three gates close their phase themselves, phase that recorded substantive decisions. FIVE gates close their phase themselves, because
because there the human interaction IS the phase approval: `rewrite_question` (F3), there the human interaction IS the phase approval: `rewrite_question` (F3),
`reviewer_schema_linking` with `advance:true` (F4), and `reviewer_memory_promote` (F8). `reviewer_schema_linking` with `advance:true` (F4), the LAST `reviewer_confirm
kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and
`reviewer_memory_promote` (F8).
| Phase | Artifact out | Advance / close by | | Phase | Artifact out | Advance / close by |
|-------|--------------|--------------------| |-------|--------------|--------------------|
@@ -41,8 +44,8 @@ because there the human interaction IS the phase approval: `rewrite_question` (F
| F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically | | F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically |
| F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking` with `advance:true` closes the phase itself (the curation IS the approval; `reviewer_confirm kind:"phase"` only as fallback if it reports an error). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. | | F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking` with `advance:true` closes the phase itself (the curation IS the approval; `reviewer_confirm kind:"phase"` only as fallback if it reports an error). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. |
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) | | F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` | | F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE with `kind:"cte_result"`; approving the LAST CTE of the plan closes the phase automatically (`kind:"phase"` only as fallback if the auto-close reports an error) |
| F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved`, then `reviewer_confirm kind:"phase"` | | F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved` AND closes the phase automatically (`kind:"phase"` only as fallback if it reports an error) |
| F8 datamart | — | auto: the `reviewer_memory_promote` gate advances F8 and finalizes the session itself (`reviewer_confirm kind:"phase"` only as fallback if it reports an error) | | F8 datamart | — | auto: the `reviewer_memory_promote` gate advances F8 and finalizes the session itself (`reviewer_confirm kind:"phase"` only as fallback if it reports an error) |
## Disciplines (hold in every phase) ## Disciplines (hold in every phase)
@@ -51,17 +54,17 @@ because there the human interaction IS the phase approval: `rewrite_question` (F
command (you invoke `tht ...` via the shell tool). NEVER run `tht phase advance` command (you invoke `tht ...` via the shell tool). NEVER run `tht phase advance`
or `tht decision add` from the shell — they are blocked by the gate's anti-bypass or `tht decision add` from the shell — they are blocked by the gate's anti-bypass
hook; the gate extension records every decision via the `reviewer_*` tools. hook; the gate extension records every decision via the `reviewer_*` tools.
2. **The choice records; the phase gate advances.** A `reviewer_decide`, or a 2. **The choice records; the closing gate advances.** A `reviewer_decide`, or a
`reviewer_select` whose chosen option carries a `decision`, PERSISTS that decision — it `reviewer_select` whose chosen option carries a `decision`, PERSISTS that decision — it
does NOT by itself advance the phase. To move to the next phase you MUST issue does NOT by itself advance the phase. In F1, F2-with-memories and F5 the phase closes
`reviewer_confirm kind:"phase"` (for F7, first `kind:"sql"` to record `sql_approved`, then with the deliberate `reviewer_confirm kind:"phase"` summary gate. The `advance:true`
`kind:"phase"` to advance), the deliberate "this phase is flag on `reviewer_decide` is a shortcut that auto-advances ONLY F2 when the memory phase
done" gate. The `advance:true` flag on `reviewer_decide` is a shortcut that auto-advances recorded nothing and F6 when it is skipped/empty; everywhere else it is a silent no-op,
ONLY F2 when the memory phase recorded nothing and F6 when it is skipped/empty; everywhere so never rely on it to advance. The self-closing gates are the five listed above
else it is a silent no-op, so never rely on it to advance. The self-closing gates are the (F3 `rewrite_question`, F4 `reviewer_schema_linking` `advance:true`, F6 last
three listed above (F3 `rewrite_question`, F4 `reviewer_schema_linking` `advance:true`, `kind:"cte_result"`, F7 `kind:"sql"`, F8 `reviewer_memory_promote`) — after one of
F8 `reviewer_memory_promote`) — after one of those, do NOT add a `reviewer_confirm` that those, do NOT add a `reviewer_confirm kind:"phase"` that merely echoes it; the phase is
merely echoes it; the phase is already closed. already closed.
3. **Single pick vs multi-answer.** For a single-pick clarification or decision, use 3. **Single pick vs multi-answer.** For a single-pick clarification or decision, use
`reviewer_select` and attach a `decision` payload (`{type, subject, detail?, `reviewer_select` and attach a `decision` payload (`{type, subject, detail?,
rationale?}`) to each concrete option: picking it persists that decision directly — rationale?}`) to each concrete option: picking it persists that decision directly —