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:
@@ -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(" "));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user