feat: harden workflow gates and expose token usage

This commit is contained in:
2026-07-21 12:14:26 +02:00
parent 8aa1676811
commit 2ff63d371f
20 changed files with 604 additions and 53 deletions
+78 -12
View File
@@ -165,6 +165,14 @@ export function isProtectedBashMutation(cmd) {
return PROTECTED_PATH_TOKEN.test(cmd) && BASH_MUTATION.test(cmd);
}
// Session artifacts are exposed by deterministic `tht session documents`; shell
// discovery is both unnecessary and dangerous (a model once escalated to `find /`
// and wedged the whole RPC turn). Match shell command boundaries so the supported
// `tht search find` subcommand remains available.
export function isFilesystemFind(cmd) {
return /(?:^|[;&|\n])\s*(?:(?:sudo|command)\s+)?find(?:\s|$)/.test(cmd);
}
// --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) -
const NUOVA_DOMANDA_KICKOFF =
"AZIONE IMMEDIATA OBBLIGATORIA: la skill canonica è già inclusa integralmente nel " +
@@ -480,6 +488,35 @@ export function decisionAddArgs(session, d) {
return args;
}
// Keep the model inside the gate workflow immediately after a reviewer selection.
// The agent_end prose safety net is too late for providers that keep streaming a
// single, very long assistant turn: put the continuation contract in the tool result
// that unlocks the model after the reviewer response.
export function decisionRecordedResultText(decision, option) {
return (
`Decisione registrata (${decision.type}): ${option.label}. ` +
"Ora rileggi lo stato persistito con `tht session show` e invoca immediatamente " +
"il prossimo tool reviewer_ richiesto dal workflow. " +
"Non scrivere analisi o spiegazioni visibili."
);
}
export function hasUngatedAssistantProse(messages) {
const last = messages?.[messages.length - 1];
if (!last || last.role !== "assistant" || !Array.isArray(last.content))
return false;
const hasText = last.content.some(
(block) => block.type === "text" && (block.text ?? "").trim().length > 0,
);
const hasReviewerTool = last.content.some(
(block) =>
block.type === "toolCall" &&
typeof block.name === "string" &&
block.name.startsWith("reviewer_"),
);
return hasText && !hasReviewerTool;
}
// Workstream F: classifies a reviewer_select response into the action the gate takes.
// A concrete choice that carries a `decision` payload auto-confirms (persist directly,
// no second gate); a bare choice stays ask-only; back/exit/Other never persist.
@@ -618,6 +655,15 @@ export default function (pi) {
lastSteered = false;
return;
}
if (isFilesystemFind(cmd)) {
return {
block: true,
reason:
"Non cercare file con `find`: gli artefatti persistiti della sessione " +
"si leggono con `tht session documents <id> --json`; per catalogo ed " +
"evidence usa `tht schema render` / `tht search find`.",
};
}
if (FORBIDDEN.some((re) => re.test(cmd))) {
return {
block: true,
@@ -739,17 +785,7 @@ export default function (pi) {
// tool call (and the lock is active), nudge it back to the gate tools.
pi.on("agent_end", async (event) => {
if (!lockActive) return;
const msgs = event.messages ?? [];
const last = msgs[msgs.length - 1];
const isProse =
last &&
last.role === "assistant" &&
Array.isArray(last.content) &&
last.content.some(
(b) => b.type === "text" && (b.text ?? "").trim().length > 0,
) &&
!last.content.some((b) => b.type === "toolCall");
if (isProse && !lastSteered) {
if (hasUngatedAssistantProse(event.messages ?? []) && !lastSteered) {
lastSteered = true;
await pi.sendUserMessage(
"Le risposte del reviewer arrivano solo dai widget del gate. Riproponi la " +
@@ -832,7 +868,7 @@ export default function (pi) {
if (err) return err;
if (advance) advanceIfReady(ctx, session);
return textResult(
`Decisione registrata (${outcome.decision.type}): ${outcome.option.label}.`,
decisionRecordedResultText(outcome.decision, outcome.option),
);
}
return textResult(
@@ -1159,6 +1195,36 @@ export default function (pi) {
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
// F7's review target is the persisted artifact, never the model-authored
// descriptor payload. A model can (and did) call reviewer_confirm with
// `artifact.data: {}` even though write_final_sql had already persisted a
// non-empty sql_final.sql; trusting that payload rendered a blank approval
// dialog. Hydrate from the repository boundary and fail closed before any
// widget is shown if the artifact is unavailable or empty.
if (kind === "sql") {
let docs;
try {
docs = JSON.parse(tht(ctx, ["session", "documents", session, "--json"]));
} catch (e) {
const msg = (e.stderr || e.message || String(e)).toString().trim();
return textResult(
`Impossibile leggere sql_final.sql dalla sessione ${session}: ${msg}. ` +
"Verifica l'artefatto persistito e riprova.",
);
}
const sqlDoc = Array.isArray(docs)
? docs.find((doc) => doc && doc.key === "sql" && doc.format === "sql")
: null;
const sql = typeof sqlDoc?.content === "string" ? sqlDoc.content : "";
if (!sql.trim()) {
return textResult(
`sql_final.sql assente o vuoto per la sessione ${session}: ` +
"scrivi un SQL finale valido prima di ripresentare il gate F7.",
);
}
artifact = { ...artifact, kind: "sql", data: sql };
}
// Sessione gia' finalizzata: non c'e' piu' nulla da approvare — mai
// ripresentare il phase-gate (la form "approve") a workflow chiuso.
if (kind === "phase" && curNum >= phaseMeta(ctx).max_phase) {