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