feat: gate decision-type validation, force-advance, and frontend fixes

Gate (tht-gate.js):
- Pre-validate decision types against workflow.yaml before showing reviewer widget
- Reject decisions emitted by later phases (min-phase check)
- Copy top-level `kind` into artifact when model forgets it (prevents loop)
- Force-advance on reviewer_decide/schema_linking when advance:true — skip
  redundant reviewer_confirm gate

Backend:
- Emit agent_end on clean Pi exit (code 0 + bridge idle) instead of marking failed

Frontend:
- Strip <think> tags from transcript and activity panel
- Fix mermaid render with offscreen container + cleanup
- Graceful mermaid error: show source code instead of red error, fall back to table

Workflow:
- F2 now emits table_promoted and table_excluded (early schema linking decisions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 14:14:44 +02:00
co-authored by Claude Opus 4.6
parent 9fbca06f7c
commit 6ee5bda7f0
9 changed files with 114 additions and 16 deletions
+85 -8
View File
@@ -129,6 +129,11 @@ export function prepareReviewerArguments(input) {
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) };
}
// Models frequently forget artifact.kind while providing it at top level —
// copy it over so validation doesn't loop.
if (args.artifact && typeof args.artifact === "object" && !args.artifact.kind && args.kind) {
args.artifact = { ...args.artifact, kind: args.kind };
}
return args;
}
@@ -275,6 +280,52 @@ function phaseId(ctx, num) {
const p = meta.phases.find((x) => x.num === num);
return p ? p.id : "?";
}
// Known decision types from workflow.yaml emits + meta types. Pre-validates decision
// payloads BEFORE showing the reviewer widget so a typo in the type name doesn't waste
// a human interaction (the CLI would reject it only after the reviewer has already picked).
let _knownTypes = null;
function knownDecisionTypes(ctx) {
if (_knownTypes) return _knownTypes;
const meta = phaseMeta(ctx);
const types = new Set([
"phase_approved", "phase_auto_approved", "phase_reopened",
"phase_skipped", "decision_retracted",
]);
for (const p of meta.phases) {
for (const t of (p.emits || [])) types.add(t);
}
_knownTypes = types;
return types;
}
function decisionMinPhaseMap(ctx) {
const meta = phaseMeta(ctx);
const mins = {};
for (const p of meta.phases) {
for (const t of (p.emits || [])) {
if (!(t in mins) || p.num < mins[t]) mins[t] = p.num;
}
}
return mins;
}
function validateDecisionTypes(ctx, options, session) {
const known = knownDecisionTypes(ctx);
for (const o of options) {
if (o.decision && !known.has(o.decision.type)) {
return `Tipo di decisione '${o.decision.type}' non valido. Tipi ammessi: ${[...known].join(", ")}. Correggi e riprova.`;
}
}
if (session) {
const cur = currentPhase(ctx, session);
const mins = decisionMinPhaseMap(ctx);
for (const o of options) {
if (o.decision && mins[o.decision.type] && mins[o.decision.type] > cur) {
return `Tipo '${o.decision.type}' ammesso dalla Fase ${mins[o.decision.type]}, sessione alla Fase ${cur}. Chiudi prima la fase corrente.`;
}
}
}
return null;
}
// Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`.
function phaseMetaForNum(ctx, num) {
const meta = phaseMeta(ctx);
@@ -316,6 +367,21 @@ function advanceIfReady(ctx, session) {
}
}
// Unconditional phase advance — the reviewer already approved via the widget interaction
// (reviewer_decide selection IS the human confirmation). Used when reviewer_decide persists
// substantive decisions with advance:true, so no separate reviewer_confirm gate is needed.
function forceAdvance(ctx, session) {
try {
tht(ctx, ["phase", "advance", "--session", session]);
return { advanced: true };
} catch (e) {
return {
advanced: false,
error: (e.stderr || e.message || String(e)).toString().trim(),
};
}
}
// --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) --------
//
// emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title`
@@ -662,6 +728,8 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, title, options: opts, intro, advance } = params;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session));
const recommended = opts.find((o) => o.recommended)?.id ?? null;
@@ -734,6 +802,8 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, title, options: opts, advance } = params;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session));
const toAdd = [];
const meritOptions = opts
@@ -813,12 +883,17 @@ export default function (pi) {
toAdd.push(d);
}
}
if (advance) advanceIfReady(ctx, session);
return textResult(
toAdd.length
? `Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`
: "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).",
);
if (!toAdd.length) {
const adv = advance ? advanceIfReady(ctx, session) : { advanced: false };
const msg = "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
return textResult(adv.advanced ? msg + " Fase avanzata automaticamente." : msg);
}
// Decisions were recorded AND advance requested: the reviewer's selection IS the
// approval — force-advance without a separate reviewer_confirm gate.
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
const parts = [`Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`];
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
return textResult(parts.join(" "));
},
});
@@ -924,8 +999,10 @@ export default function (pi) {
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
if (eSync) return eSync;
if (advance) advanceIfReady(ctx, session);
return textResult(`Schema linking registrato dal reviewer (${n} tabelle + colonne curate).`);
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`];
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
return textResult(parts.join(" "));
},
});
+1 -1
View File
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
("concept_clarified", 1),
("memory_rejected", 2),
("question_rewritten", 3),
("table_promoted", 4),
("table_promoted", 2),
("column_corrected", 4),
("evidence_accepted", 4),
("value_grounded", 4),
+1 -1
View File
@@ -21,7 +21,7 @@ phases:
advance: auto_if_empty
prerequisites: []
artifacts_out: []
emits: [memory_rejected]
emits: [memory_rejected, table_promoted, table_excluded]
- id: F3
name: riscrittura
advance: kind:phase