refactor(workflow): contract shared core (#33)

This commit is contained in:
2026-08-24 03:12:00 +02:00
parent f375515dc0
commit 36a7a0ab33
19 changed files with 173 additions and 29 deletions
@@ -7,7 +7,7 @@ const {
validateCtePlanV2,
validateCteResultThin,
validatePhaseSummaryV2,
} = require("../artifact-contracts.js");
} = require("../core/artifact-contracts.js");
// --- validateCtePlanV2 (payload A) ------------------------------------------------
@@ -18,7 +18,7 @@ const {
buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage,
} = require("../builders.js");
} = require("../core/builders.js");
const GOLDEN = path.join(__dirname, "golden");
const golden = (name) => JSON.parse(fs.readFileSync(path.join(GOLDEN, name)));
@@ -6,7 +6,7 @@ const {
enrichCtePlanV2,
buildCteResultV2,
enrichPhaseSummaryV2,
} = require("../enrich.js");
} = require("../core/enrich.js");
// --- enrichCtePlanV2 --------------------------------------------------------------
@@ -202,7 +202,7 @@ test("enrichPhaseSummaryV2 preserves open_questions/checks untouched", () => {
// --- appendLedgerSection -----------------------------------------------------------
const { appendLedgerSection } = require("../enrich.js");
const { appendLedgerSection } = require("../core/enrich.js");
test("appendLedgerSection appends only the phase's substantive decisions", () => {
const data = { schema_version: 2, summary: "s", sections: [{ title: "Criteri", items: [] }] };
@@ -1,6 +1,8 @@
const test = require("node:test");
const assert = require("node:assert");
const { createMemoryGate } = require("../memory/index.js");
const { buildMultiselectRequest } = require("../core/builders.js");
const { isReserved } = require("../core/reserved-labels.mjs");
const { createFakePi } = require("./fake_pi_runtime.js");
@@ -74,6 +76,10 @@ test("the public Memory facade owns F8 policy and mutation ordering", async () =
return null;
},
},
reviewer: {
buildMultiselect: buildMultiselectRequest,
isReserved,
},
waitForReviewer: async (runtimeContext, widget) => {
const response = await runtimeContext.ui.input(JSON.stringify(widget), "");
return JSON.parse(response);
@@ -1,6 +1,8 @@
const test = require("node:test");
const assert = require("node:assert");
const { createMemoryGate } = require("../memory/index.js");
const { buildMultiselectRequest } = require("../core/builders.js");
const { isReserved } = require("../core/reserved-labels.mjs");
const { createFakePi } = require("./fake_pi_runtime.js");
@@ -71,6 +73,10 @@ function setupRecall(choices) {
return null;
},
},
reviewer: {
buildMultiselect: buildMultiselectRequest,
isReserved,
},
waitForReviewer: async (runtimeContext, widget) => {
const response = await runtimeContext.ui.input(JSON.stringify(widget), "");
return JSON.parse(response);
@@ -75,7 +75,7 @@ test("prepareReviewerArguments leaves a legacy markdown string artifact.data UNC
test("GATE_CODE_FILES blocks writes to gate extensions, not session artifacts", () => {
assert.ok(GATE_CODE_FILES.test("harness/.pi/extensions/tht-gate.js"));
assert.ok(GATE_CODE_FILES.test(".pi/extensions/reserved-labels.mjs"));
assert.ok(GATE_CODE_FILES.test(".pi/extensions/gate/core/reserved-labels.mjs"));
assert.equal(GATE_CODE_FILES.test("sessions/s1/schema_linking.json"), false);
assert.equal(GATE_CODE_FILES.test("sessions/s1/sql_final.sql"), false);
});
@@ -0,0 +1,43 @@
const test = require("node:test");
const assert = require("node:assert");
const { readdirSync, readFileSync } = require("node:fs");
const { dirname, extname, join, relative, resolve, sep } = require("node:path");
const gateRoot = resolve(__dirname, "..");
const domainRoots = ["disambiguation", "memory"];
function productionFiles(root) {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
if (entry.isDirectory()) {
return entry.name === "__tests__" ? [] : productionFiles(path);
}
return [".js", ".mjs"].includes(extname(entry.name)) ? [path] : [];
});
}
function relativeImports(path) {
const source = readFileSync(path, "utf8");
return [...source.matchAll(/(?:from\s+|import\s*(?:\(\s*)?|require\s*\(\s*)["'](\.[^"']+)["']/g)]
.map((match) => match[1]);
}
test("gate domain modules receive shared behavior as capabilities", () => {
for (const domain of domainRoots) {
const root = join(gateRoot, domain);
for (const path of productionFiles(root)) {
const imports = relativeImports(path).map((specifier) =>
relative(gateRoot, resolve(dirname(path), specifier)).split(sep).join("/"));
const violations = imports.filter((target) =>
target !== domain && !target.startsWith(`${domain}/`));
assert.deepEqual(
violations,
[],
`${relative(gateRoot, path)} imports gate implementation directly: ${violations.join(", ")}`,
);
}
}
});
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert");
const { isReserved, stripReserved } = require("../../reserved-labels.mjs");
const { isReserved, stripReserved } = require("../core/reserved-labels.mjs");
test("Altro variants (any punctuation/case) are reserved", () => {
assert.equal(isReserved("Altro — specifica…"), true);
@@ -1,6 +1,17 @@
import { Type } from "typebox";
export const NEW_SESSION_CLARIFICATION_KICKOFF =
"2. Nel primo turno identifica la SOLA ambiguità con maggiore impatto sulla query e " +
"chiama subito `reviewer_select` con opzioni concrete. Niente lunga narrazione, elenco " +
"di tutte le ambiguità o ricapitolazione preliminare.\n";
export const EMPTY_SESSION_CLARIFICATION_KICKOFF =
"Se la sessione e' in fase 1 e non ha decisioni, dopo `session show` chiama " +
"`reviewer_select` subito: non produrre testo libero.\n";
function normalizedAssumptions(assumptions) {
let normalized = assumptions;
if (typeof normalized === "string") {
+5 -8
View File
@@ -1,8 +1,5 @@
import { Type } from "typebox";
import { buildMultiselectRequest } from "../builders.js";
import { isReserved } from "../../reserved-labels.mjs";
// Compatibility note: reviewer labels move verbatim from the composition root.
// Tickets #22 and #23 require observable parity; translating existing chrome is a
// separate product behavior change rather than part of these extractions.
@@ -80,14 +77,14 @@ function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
async function reviewRecall(ctx, params, phase, dependencies) {
const { workflow, ledger, waitForReviewer, toTextResult } = dependencies;
const { workflow, ledger, reviewer, waitForReviewer, toTextResult } = dependencies;
try {
const { session, advance } = params;
const options = normalizeMemoryOptions(params.options);
const typeError = ledger.validate(ctx, options, session);
if (typeError) return toTextResult(typeError);
const meritOptions = options
.filter((option) => !isReserved(option.label))
.filter((option) => !reviewer.isReserved(option.label))
.map((option) => ({
id: option.id,
label: option.label,
@@ -110,7 +107,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
"avanzamento automatico alla fase successiva.",
);
}
const widget = buildMultiselectRequest({
const widget = reviewer.buildMultiselect({
id: `u${Date.now()}`,
phase,
allowEmpty: params.allow_empty ?? false,
@@ -225,7 +222,7 @@ function splitPromotionChoices(candidates, choices) {
*/
function installMemoryGate(
pi,
{ workflow, memory, ledger, waitForReviewer, toTextResult },
{ workflow, memory, ledger, reviewer, waitForReviewer, toTextResult },
) {
pi.registerTool({
name: "reviewer_memory_promote",
@@ -285,7 +282,7 @@ function installMemoryGate(
);
}
const options = promotionOptions(candidates);
const widget = buildMultiselectRequest({
const widget = reviewer.buildMultiselect({
id: `u${Date.now()}`,
phase: phase.id,
title: "Quali concetti chiariti salvare nella memoria riutilizzabile?",
+17 -10
View File
@@ -6,7 +6,7 @@
// the reference implementation PHASE_NAMES array (truncated to 7) is gone; F8/datamart can no
// longer drift out of sync.
// (2) D2/D4 widget-descriptor: the reviewer interaction is emitted as a
// widget-descriptor JSON (built by ./gate/builders.js) and awaited by id, instead
// widget-descriptor JSON (built by ./gate/core/builders.js) and awaited by id, instead
// of rendered by blocking native TUI primitives (ctx.ui.select/custom).
//
// PRESERVED VERBATIM from the source (load-bearing runtime glue, spec D4):
@@ -30,12 +30,12 @@ import {
buildArtifactGate,
buildSchemaLinkingRequest,
buildJoinReviewRequest,
} from "./gate/builders.js";
} from "./gate/core/builders.js";
import {
validateCtePlanV2,
validateCteResultThin,
validatePhaseSummaryV2,
} from "./gate/artifact-contracts.js";
} from "./gate/core/artifact-contracts.js";
import {
memoizeGetColumns,
enrichCtePlanV2,
@@ -43,10 +43,14 @@ import {
enrichCteResultColumns,
enrichPhaseSummaryV2,
appendLedgerSection,
} from "./gate/enrich.js";
} from "./gate/core/enrich.js";
import { createMemoryGate } from "./gate/memory/index.js";
import { createDisambiguationGate } from "./gate/disambiguation/index.js";
import { isReserved } from "./reserved-labels.mjs";
import {
createDisambiguationGate,
EMPTY_SESSION_CLARIFICATION_KICKOFF,
NEW_SESSION_CLARIFICATION_KICKOFF,
} from "./gate/disambiguation/index.js";
import { isReserved } from "./gate/core/reserved-labels.mjs";
// Load the workflow contract once when Pi loads the extension. Asking the model to
// discover/read the skill as its first action proved unreliable with remote models:
@@ -204,9 +208,7 @@ const NUOVA_DOMANDA_KICKOFF_PROVIDED = (sessionId, hasRetrievalPack = false) =>
: "1. Il retrieval pack non era ancora disponibile: come PRIMA chiamata tool esegui " +
"subito `tht search pack \"<domanda originale nel messaggio utente>\" --session " +
sessionId + "` e usa il risultato.\n") +
"2. Nel primo turno identifica la SOLA ambiguità con maggiore impatto sulla query e " +
"chiama subito `reviewer_select` con opzioni concrete. Niente lunga narrazione, elenco " +
"di tutte le ambiguità o ricapitolazione preliminare.\n" +
NEW_SESSION_CLARIFICATION_KICKOFF +
"Regole non negoziabili: una domanda al reviewer per volta; mai promuovere/escludere/" +
"correggere senza conferma; le interazioni passano dai tool reviewer_*; testo libero col prefisso '!'. " +
"MAI `tht phase advance|reopen` né `tht decision add` da shell.";
@@ -225,7 +227,8 @@ const RIPRENDI_KICKOFF = (hasRetrievalPack = false) =>
"cio' che non e' registrato non e' avvenuto) e riprendi da li'.\n" +
"Esegui il passo 1 ORA, in QUESTO stesso turno, chiamando subito il tool `bash` per " +
"`tht session show`: NON limitarti a dichiarare l'intenzione e NON " +
"terminare il turno prima di aver chiamato i tool. Se la sessione e' in fase 1 e non ha decisioni, dopo `session show` chiama `reviewer_select` subito: non produrre testo libero.\n" +
"terminare il turno prima di aver chiamato i tool. " +
EMPTY_SESSION_CLARIFICATION_KICKOFF +
"Valgono le stesse regole non negoziabili: una domanda per volta, conferma esplicita, tool " +
"reviewer_*, niente phase advance/reopen o decision add da shell, una fase alla volta.";
@@ -617,6 +620,10 @@ export default function (pi) {
ctx, decisionAddArgs(session, decision), recovery,
),
},
reviewer: {
buildMultiselect: buildMultiselectRequest,
isReserved,
},
waitForReviewer: emitAndWait,
toTextResult: textResult,
});