From e25ba5126bd07815631dc377d8d4b0dddd356222 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 7 Jul 2026 13:30:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(gate):=20reviewer=5Fmemory=5Fpromote=20?= =?UTF-8?q?=E2=80=94=20deterministic=20F8=20memory-promotion=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/gate_memory_promote.test.js | 45 +++++++ harness/.pi/extensions/tht-gate.js | 116 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js diff --git a/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js new file mode 100644 index 00000000..6f40809d --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js @@ -0,0 +1,45 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { + promotionOptions, + promotionContent, + splitPromotionChoices, +} = require("../../tht-gate.js"); + +// F8 memory-promotion gate: the candidates are DETERMINISTIC (computed by +// `tht memory promote --preview --json`), the model only names the session. +// These tests pin the pure candidate->widget mapping and the choice partition. + +const CANDIDATES = [ + { decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione", + detail: "tabella principale ablazioni", rationale: "scelta dal reviewer", + question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] }, + { decision_seq: 5, type: "concept_clarified", subject: "paziente attivo", + detail: "flag_attivo = TRUE", rationale: "", + question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] }, +]; + +test("promotionOptions maps candidates to seq-keyed options", () => { + assert.deepEqual(promotionOptions(CANDIDATES), [ + { id: "seq-3", label: "table_promoted: fact_seeablazione" }, + { id: "seq-5", label: "concept_clarified: paziente attivo" }, + ]); +}); + +test("promotionContent lists every candidate with detail and context", () => { + const c = promotionContent(CANDIDATES); + assert.ok(c.includes("fact_seeablazione")); + assert.ok(c.includes("flag_attivo = TRUE")); + assert.ok(c.includes("quante ablazioni nel 2023")); +}); + +test("splitPromotionChoices partitions by selection", () => { + const { promote, decline } = splitPromotionChoices(CANDIDATES, ["seq-5"]); + assert.deepEqual(promote.map((c) => c.decision_seq), [5]); + assert.deepEqual(decline.map((c) => c.decision_seq), [3]); +}); + +test("empty or missing choices declines everything", () => { + assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2); + assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2); +}); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 8e89418e..06832293 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -107,6 +107,8 @@ const FORBIDDEN = [ /\btht\s+phase\s+(advance|reopen)\b/, /\btht\s+decision\s+add\b/, /\btht\s+cte\s+plan\b/, + // La promozione in memoria passa dal reviewer (reviewer_memory_promote), mai da shell. + /\btht\s+memory\s+(promote|save-one)\b/, ]; const PROTECTED_FILES = /(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/; @@ -341,6 +343,38 @@ export function shouldSkipEmptyDecide({ meritCount, allowEmpty, advance }) { return meritCount === 0 && !!allowEmpty && !!advance; } +// --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------ +// The candidates come from `tht memory promote --preview --json` (deterministic, +// reviewer-approved decisions only); the model never authors them. +export function promotionOptions(candidates) { + return candidates.map((c) => ({ + id: `seq-${c.decision_seq}`, + label: `${c.type}: ${c.subject}`, + })); +} + +export function promotionContent(candidates) { + return candidates + .map( + (c) => + `- **${c.type}: ${c.subject}** (decisione #${c.decision_seq})\n` + + ` ${c.detail || ""}\n` + + ` Motivo: ${c.rationale || "—"}\n` + + ` Domanda di contesto: ${c.question_context || "—"}`, + ) + .join("\n"); +} + +export function splitPromotionChoices(candidates, choices) { + const chosen = new Set(choices ?? []); + const promote = []; + const decline = []; + for (const c of candidates) { + (chosen.has(`seq-${c.decision_seq}`) ? promote : decline).push(c); + } + return { promote, decline }; +} + export async function emitAndWait(ctx, descriptor) { for (;;) { const value = await ctx.ui.input(JSON.stringify(descriptor), ""); @@ -984,6 +1018,88 @@ export default function (pi) { }, }); + pi.registerTool({ + name: "reviewer_memory_promote", + label: "Promozione memorie riusabili (reviewer)", + description: + "F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " + + "calcolati dalla CLI (tht memory promote --preview: tipi riusabili concept_clarified/" + + "table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " + + "vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " + + "le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " + + "alla sessione: i candidati sono deterministici, NON li scrivi tu.", + parameters: Type.Object({ + session: Type.String(), + }), + async execute(_id, params, _signal, _onUpdate, ctx) { + lockActive = true; + const { session } = params; + const phase = phaseId(ctx, currentPhase(ctx, session)); + let candidates; + try { + candidates = JSON.parse( + tht(ctx, ["memory", "promote", "--session", session, "--preview", "--json"]), + ); + } catch (e) { + const msg = (e.stderr || e.message || String(e)).toString().trim(); + return textResult(`Preview di promozione non disponibile: ${msg}`); + } + if (!Array.isArray(candidates) || candidates.length === 0) { + await ctx.ui.notify( + "Nessuna decisione riusabile da promuovere in memoria per questa sessione.", + "info", + ); + return textResult( + "Nessun candidato di promozione: prosegui con la chiusura della sessione.", + ); + } + const options = promotionOptions(candidates); + const widget = buildMultiselectRequest({ + id: `u${Date.now()}`, + phase, + title: "Quali decisioni salvare nella memoria riutilizzabile?", + allowEmpty: true, + options, + selected: options.map((o) => o.id), + content: promotionContent(candidates), + }); + const resp = await emitAndWait(ctx, widget); + if (resp.control === "freetext") + return textResult(`Altro (reviewer): ${resp.text}. Valuta e ripresenta il gate.`); + if (resp.control === "back") + return textResult("Il reviewer vuole tornare indietro."); + if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); + const { promote, decline } = splitPromotionChoices(candidates, resp.choices); + let saved = 0; + for (const c of promote) { + const err = relayIfThtFails( + ctx, + ["memory", "save-one", "--session", session, + "--decision", String(c.decision_seq), "--json"], + "", + ); + if (err) return err; + const e2 = relayIfThtFails(ctx, decisionAddArgs(session, { + type: "memory_promoted", subject: c.subject, + detail: `seq:${c.decision_seq}`, rationale: c.rationale || c.detail || "", + }), ""); + if (e2) return e2; + saved++; + } + for (const c of decline) { + const err = relayIfThtFails(ctx, decisionAddArgs(session, { + type: "memory_promotion_declined", subject: c.subject, + detail: `seq:${c.decision_seq}`, + }), ""); + if (err) return err; + } + return textResult( + `Promozione registrata: ${saved} memorie salvate nel vectordb, ` + + `${decline.length} candidati scartati.`, + ); + }, + }); + pi.registerTool({ name: "rewrite_question", label: "Riscrittura domanda (deterministica)",