diff --git a/harness/.pi/extensions/gate/__tests__/gate_confirm_cte_v2.test.js b/harness/.pi/extensions/gate/__tests__/gate_confirm_cte_v2.test.js index 06d1a8e5..53f2604f 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_confirm_cte_v2.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_confirm_cte_v2.test.js @@ -345,9 +345,11 @@ test("reviewer_confirm kind:phase (v2): fills phase from phaseMeta and enriches const origExecFileSync = cp.execFileSync; cp.execFileSync = (file, args) => { if (args[0] === "phase" && args[1] === "meta") - return JSON.stringify({ phases: [{ num: 5, id: "F5", name: "sintesi" }] }); + return JSON.stringify({ max_phase: 8, phases: [{ num: 5, id: "F5", name: "sintesi", emits: [] }] }); if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n"; if (args[0] === "phase" && args[1] === "advance") return ""; + if (args[0] === "session" && args[1] === "show") + return JSON.stringify({ decisions: [] }); if (args[0] === "schema" && args[1] === "columns") return JSON.stringify(CATALOG[args[2]] || {}); return ""; @@ -386,11 +388,83 @@ test("reviewer_confirm kind:phase (v2): fills phase from phaseMeta and enriches } }); +test("reviewer_confirm kind:phase (v2) at max_phase auto-finalizes the session", async () => { + const origExecFileSync = cp.execFileSync; + let finalizeCalled = false; + cp.execFileSync = (file, args) => { + if (args[0] === "phase" && args[1] === "meta") + return JSON.stringify({ max_phase: 8, phases: [{ num: 8, id: "F8", name: "datamart", emits: [] }] }); + if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n"; + if (args[0] === "phase" && args[1] === "advance") return ""; + if (args[0] === "session" && args[1] === "show") + return JSON.stringify({ decisions: [] }); + if (args[0] === "session" && args[1] === "finalize") { finalizeCalled = true; return ""; } + return ""; + }; + try { + const { ctx, tools } = await loadGate(); + ctx.ui.input = async (title) => { + const d = JSON.parse(title); + return JSON.stringify({ id: d.id, choices: ["approve"] }); + }; + const tool = tools.get("reviewer_confirm"); + const result = await tool.def.execute( + "call-1", + { + session: "s1", kind: "phase", title: "Chiusura F8", + artifact: { kind: "phase", data: { schema_version: 2, summary: "done" } }, + }, + null, null, ctx, + ); + assert.ok(finalizeCalled, "gate must auto-finalize after last phase advance"); + assert.match(result.content[0].text, /finalizzata/); + } finally { + cp.execFileSync = origExecFileSync; + } +}); + +test("reviewer_confirm kind:phase at non-max phase does NOT finalize", async () => { + const origExecFileSync = cp.execFileSync; + let finalizeCalled = false; + cp.execFileSync = (file, args) => { + if (args[0] === "phase" && args[1] === "meta") + return JSON.stringify({ max_phase: 8, phases: [{ num: 5, id: "F5", name: "sintesi", emits: [] }] }); + if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n"; + if (args[0] === "phase" && args[1] === "advance") return ""; + if (args[0] === "session" && args[1] === "show") + return JSON.stringify({ decisions: [] }); + if (args[0] === "session" && args[1] === "finalize") { finalizeCalled = true; return ""; } + if (args[0] === "schema" && args[1] === "columns") return "{}"; + return ""; + }; + try { + const { ctx, tools } = await loadGate(); + ctx.ui.input = async (title) => { + const d = JSON.parse(title); + return JSON.stringify({ id: d.id, choices: ["approve"] }); + }; + const tool = tools.get("reviewer_confirm"); + const result = await tool.def.execute( + "call-1", + { + session: "s1", kind: "phase", title: "Chiusura F5", + artifact: { kind: "phase", data: { schema_version: 2, summary: "ok" } }, + }, + null, null, ctx, + ); + assert.ok(!finalizeCalled, "must NOT finalize at non-max phase"); + assert.match(result.content[0].text, /Fase approvata/); + assert.ok(!result.content[0].text.includes("finalizzata")); + } finally { + cp.execFileSync = origExecFileSync; + } +}); + test("reviewer_confirm kind:phase legacy (non-v2 data): unchanged path", async () => { const origExecFileSync = cp.execFileSync; cp.execFileSync = (file, args) => { if (args[0] === "phase" && args[1] === "meta") - return JSON.stringify({ phases: [{ num: 1, id: "F1", name: "chiarimento" }] }); + return JSON.stringify({ max_phase: 8, phases: [{ num: 1, id: "F1", name: "chiarimento" }] }); if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 1\n"; if (args[0] === "phase" && args[1] === "advance") return ""; return ""; diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 9d6798c0..2d6f7bfb 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -957,6 +957,23 @@ export default function (pi) { PHASE_RECOVERY, ); if (err) return err; + // Auto-finalize after advancing the LAST phase: the model may not + // follow through (token budget, turn end) leaving the session open. + const meta = phaseMeta(ctx); + if (curNum >= meta.max_phase) { + const fErr = relayIfThtFails( + ctx, + ["session", "finalize", session], + "Fase approvata ma finalizzazione fallita: esegui manualmente " + + `\`tht session finalize ${session}\`.`, + ); + if (fErr) return fErr; + lockActive = false; + lastSteered = false; + return textResult( + `Fase ${curNum} approvata e sessione finalizzata (${session}).`, + ); + } return textResult(`Fase approvata (sessione ${session}).`); } if (kind === "cte_plan") { diff --git a/harness/.pi/skills/tht-sessione/SKILL.md b/harness/.pi/skills/tht-sessione/SKILL.md index 8d2690d2..2cb3f3db 100644 --- a/harness/.pi/skills/tht-sessione/SKILL.md +++ b/harness/.pi/skills/tht-sessione/SKILL.md @@ -393,12 +393,14 @@ Prerequisite: Phase 7 closed. pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`; deselected → `memory_promotion_declined` (never re-proposed). If the gate reports zero candidates, move on — do not retry. -4. Close with `reviewer_confirm kind:"phase"`. The session is finalizable. +4. Close with `reviewer_confirm kind:"phase"`. The gate auto-finalizes the session + after advancing the last phase — you do NOT need to call `tht session finalize` + yourself. If auto-finalize fails, the error message tells you the recovery command. ## Session end -When the workflow is complete (Phase 8), `tht session finalize` closes the session -and unlocks input. Finalize also indexes the question→SQL pair in the vectordb -(kind `solved_question`, best-effort — on failure recover with `tht memory -solved-index `). The persisted state (ledger `review_decisions.jsonl` + -artifacts) is the truth: what is not recorded did not happen. +When Phase 8 is approved, the gate calls `tht session finalize` automatically. +Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`, +best-effort — on failure recover with `tht memory solved-index `). The persisted +state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not +recorded did not happen.