feat(gate): reviewer_memory_promote — deterministic F8 memory-promotion gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:30:35 +02:00
co-authored by Claude Fable 5
parent bf7e850e6b
commit e25ba5126b
2 changed files with 161 additions and 0 deletions
+116
View File
@@ -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)",