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");
});