feat(gate): deterministic v2 review-gate payloads (cte_plan/cte_result/phase)

The tht-gate.js reviewer_confirm now builds structured v2 artifacts before the
widget so the reviewer approves gate-derived data, not raw model text:

- new pure modules gate/artifact-contracts.js (soft validators, {ok,errors},
  legacy-passthrough) and gate/enrich.js (index/description enrichment,
  buildCteResultV2 fusing thin model data with `tht cte info`, phase enrichment)
- cte_plan v2: validate + enrich + persist via `tht cte plan --name … --doc -`
  (names derived from data.ctes[]); legacy `names` param kept as fallback
- cte_result v2: rebuild from `tht cte next`/`tht cte info` (sql + preview from
  the persisted test record); null/error last_test -> actionable textResult
- phase v2: soft-validate + fill phase from meta + catalog descriptions
- prepareReviewerArguments coerces artifact.data too (GLM double-stringify);
  legacy markdown strings pass through unchanged
- SKILL.md: Phase 6 cte_plan payload A + thin cte_result guidance; Discipline 6
  payload C example; Discipline 7 reworded for gate-rebuilt cte_result

Legacy (non-v2) paths unchanged. TypeBox stays Type.Any() for artifact.data;
validation is soft (textResult) so models self-correct instead of looping.
All 102 gate JS tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:42:48 +02:00
co-authored by Claude Fable 5
parent 9b4f6b9804
commit 3ad93cd02f
8 changed files with 1278 additions and 18 deletions
+136 -8
View File
@@ -31,6 +31,18 @@ import {
buildArtifactGate,
buildSchemaLinkingRequest,
} from "./gate/builders.js";
import {
validateCtePlanV2,
validateCteResultThin,
validatePhaseSummaryV2,
} from "./gate/artifact-contracts.js";
import {
memoizeGetColumns,
enrichCtePlanV2,
buildCteResultV2,
enrichCteResultColumns,
enrichPhaseSummaryV2,
} from "./gate/enrich.js";
import { isReserved } from "./reserved-labels.mjs";
// --- prepareArguments: parse stringified arrays (workaround for models that send
@@ -80,6 +92,13 @@ export function prepareReviewerArguments(input) {
// stringified JSON object, which fails validation BEFORE execute() and makes the
// model loop. Coerce it back to an object so the gate proceeds.
if (args.artifact !== undefined) args.artifact = jsonObjectOrSelf(args.artifact);
// GLM sometimes double-stringifies: artifact.data itself arrives as a JSON
// string (v2 payloads) even once artifact is an object. Coerce it too; a
// legacy markdown string in artifact.data is not valid JSON and jsonObjectOrSelf
// already leaves non-JSON strings unchanged.
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) };
}
return args;
}
@@ -210,6 +229,24 @@ function phaseId(ctx, num) {
const p = meta.phases.find((x) => x.num === num);
return p ? p.id : "?";
}
// Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`.
function phaseMetaForNum(ctx, num) {
const meta = phaseMeta(ctx);
const p = meta.phases.find((x) => x.num === num);
return p ? { id: p.id, num: p.num, name: p.name } : { id: "?", num, name: "" };
}
// Memoized, never-throwing catalog lookup for enrich: `tht schema columns <t> --json`
// -> {description, columns:[...]} or null on any miss (same hardened pattern as
// reviewer_schema_linking, but a miss is a soft null here, not a hard fail).
function makeGetColumns(ctx) {
return memoizeGetColumns((tableName) => {
try {
return JSON.parse(tht(ctx, ["schema", "columns", tableName, "--json"]));
} catch {
return null;
}
});
}
function currentPhase(ctx, session) {
const out = tht(ctx, ["phase", "show", "--session", session]);
const m = out.match(/Fase corrente:\s*(\d+)/);
@@ -753,8 +790,86 @@ export default function (pi) {
prepareArguments: prepareReviewerArguments,
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, kind, title, artifact } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
const { session, kind, title } = params;
let artifact = params.artifact;
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
// --- v2 payload construction (WS2) -------------------------------------
// The gate builds/validates the structured payload BEFORE showing the
// widget, so the reviewer approves a deterministic artifact (not the raw
// model data). Legacy (non-v2) payloads fall through unchanged.
const data = artifact && typeof artifact === "object" ? artifact.data : undefined;
const isV2 = data && typeof data === "object" && data.schema_version === 2;
// derived from the (possibly rebuilt) cte_plan doc; the names list to persist.
let ctePlanNames = null;
if (kind === "cte_plan" && isV2) {
const v = validateCtePlanV2(data);
if (!v.legacy && !v.ok) {
return textResult(
"Piano CTE v2 non valido:\n- " + v.errors.join("\n- ") +
"\nCorreggi il payload e ripresenta il gate.",
);
}
const getColumns = makeGetColumns(ctx);
const enriched = enrichCtePlanV2(data, getColumns);
ctePlanNames = enriched.ctes.map((c) => c.name);
artifact = { ...artifact, data: enriched };
} else if (kind === "cte_result" && isV2) {
const thinCheck = validateCteResultThin(data);
if (!thinCheck.legacy && !thinCheck.ok) {
return textResult(
"Dati cte_result non validi:\n- " + thinCheck.errors.join("\n- "),
);
}
const cteName = tht(ctx, ["cte", "next", "--session", session]).trim();
if (!cteName) {
return textResult(
`Nessun CTE in attesa di approvazione (sessione ${session}).`,
);
}
let cteInfo;
try {
cteInfo = JSON.parse(
tht(ctx, ["cte", "info", cteName, "--session", session, "--json"]),
);
} catch (e) {
const msg = (e.stderr || e.message || String(e)).toString().trim();
return textResult(
`Impossibile leggere le info del CTE '${cteName}': ${msg}`,
);
}
if (!cteInfo.last_test || cteInfo.last_test.status === "error") {
return textResult(
`Il CTE '${cteName}' non ha un test con esito ok: devi eseguire ` +
"`tht cte test` con esito ok prima di presentare il gate cte_result.",
);
}
let built = buildCteResultV2(data, cteInfo);
// Enrich column descriptions against the plan's tables (best-effort).
const planTables = [];
if (cteInfo.doc && Array.isArray(cteInfo.doc.tables)) {
for (const t of cteInfo.doc.tables) if (t && t.name) planTables.push(t.name);
}
built = enrichCteResultColumns(built, planTables, makeGetColumns(ctx));
artifact = { ...artifact, data: built };
} else if (kind === "phase" && isV2) {
const v = validatePhaseSummaryV2(data);
if (!v.legacy && !v.ok) {
return textResult(
"Riepilogo di fase v2 non valido:\n- " + v.errors.join("\n- "),
);
}
const enriched = enrichPhaseSummaryV2(
data,
phaseMetaForNum(ctx, curNum),
makeGetColumns(ctx),
);
artifact = { ...artifact, data: enriched };
}
const widget = buildArtifactGate({
id: `u${Date.now()}`,
phase,
@@ -793,19 +908,32 @@ export default function (pi) {
return textResult(`Fase approvata (sessione ${session}).`);
}
if (kind === "cte_plan") {
// The CTE plan is the ordered list of CTE names; the model passes them in
// params.names (tht cte plan requires at least one --name).
const names = Array.isArray(params.names) ? params.names : [];
// v2: names derive from data.ctes[]; persist the plan AND the chain doc
// (payload A, enriched) via `tht cte plan --name ... --doc -`.
// Legacy: the ordered names come from params.names.
const names = ctePlanNames ?? (Array.isArray(params.names) ? params.names : []);
if (names.length === 0) {
return textResult(
"Nessun nome CTE fornito: il piano CTE richiede l'elenco ordinato dei CTE " +
"(parametro names di reviewer_confirm).",
"(parametro names di reviewer_confirm, o data.ctes[].name nel payload v2).",
);
}
const planArgs = ["cte", "plan", "--session", session];
for (const n of names) planArgs.push("--name", n);
const err = relayIfThtFails(ctx, planArgs, "");
if (err) return err;
if (ctePlanNames) {
// v2: pass the enriched doc on stdin; the CLI validates the name list
// matches (same order) and writes cte_plan_doc.json.
planArgs.push("--doc", "-");
try {
tht(ctx, planArgs, JSON.stringify(artifact.data));
} catch (e) {
const cliMsg = (e.stderr || e.message || String(e)).toString().trim();
return textResult(`${cliMsg} Correggi il piano CTE e riprova.`);
}
} else {
const err = relayIfThtFails(ctx, planArgs, "");
if (err) return err;
}
return textResult(
`CTE plan approvato (${names.length} CTE, sessione ${session}).`,
);