diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index d5001d0c..1a159930 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -30,7 +30,32 @@ import { buildMultiselectRequest, buildArtifactGate, } from "./gate/builders.js"; -import { isReserved, stripReserved } from "./reserved-labels.mjs"; +import { isReserved } from "./reserved-labels.mjs"; + +// --- prepareArguments: parse stringified arrays (workaround for models that send +// arrays as JSON strings -- same pattern as pi-core's edit tool prepareEditArguments) +function prepareReviewerArguments(input) { + if (!input || typeof input !== "object") return input; + const args = { ...input }; + // Parse stringified JSON arrays (workaround for models that send arrays as strings) + if (typeof args.options === "string") { + try { + const parsed = JSON.parse(args.options); + if (Array.isArray(parsed)) args.options = parsed; + } catch { + /* not JSON */ + } + } + if (typeof args.names === "string") { + try { + const parsed = JSON.parse(args.names); + if (Array.isArray(parsed)) args.names = parsed; + } catch { + /* not JSON */ + } + } + return args; +} // --- anti-bypass block lists (spec D4, verbatim from source L169-177) ----------- const FORBIDDEN = [ @@ -38,7 +63,8 @@ const FORBIDDEN = [ /\btht\s+decision\s+add\b/, /\btht\s+cte\s+plan\b/, ]; -const PROTECTED_FILES = /(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/; +const PROTECTED_FILES = + /(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/; // --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) - const NUOVA_DOMANDA_KICKOFF = @@ -82,7 +108,8 @@ const RIPRENDI_KICKOFF = "reviewer_*, niente phase advance/reopen o decision add da shell, una fase alla volta."; // Recovery hint shown when `tht phase advance` refuses with exit 5 (gate not satisfied). -const PHASE_RECOVERY = "Completa i prerequisiti della fase (decisioni/artefatti) e riprova."; +const PHASE_RECOVERY = + "Completa i prerequisiti della fase (decisioni/artefatti) e riprova."; // --- helpers (verbatim from source) ------------------------------------------- @@ -90,8 +117,32 @@ function textResult(text) { return { content: [{ type: "text", text }], details: {} }; } +// Load THT_* env vars from .env (project root = ctx.cwd). +function loadEnvFromDotenv(ctx) { + const fs = require("node:fs"); + const path = require("node:path"); + const envPath = path.join(ctx.cwd, ".env"); + try { + const raw = fs.readFileSync(envPath, "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf("="); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (key.startsWith("THT_") || key.startsWith("PSD_")) { + process.env[key] = value; + } + } + } catch { + // .env not found or unreadable — proceed with existing env. + } +} + // Single chokepoint for all CLI calls. cwd is the Pi project root (harness/). function tht(ctx, args) { + loadEnvFromDotenv(ctx); return execFileSync("tht", args, { cwd: ctx.cwd, encoding: "utf8" }); } @@ -139,7 +190,10 @@ function advanceIfReady(ctx, session) { return { advanced: true }; } catch (e) { if (e.status === 6) return { advanced: false }; - return { advanced: false, error: (e.stderr || e.message || String(e)).toString().trim() }; + return { + advanced: false, + error: (e.stderr || e.message || String(e)).toString().trim(), + }; } } @@ -156,17 +210,29 @@ function advanceIfReady(ctx, session) { export async function emitAndWait(ctx, descriptor) { for (;;) { const value = await ctx.ui.input(JSON.stringify(descriptor), ""); - if (value === undefined || value === null) { await reLoop(ctx); continue; } + if (value === undefined || value === null) { + await reLoop(ctx); + continue; + } let resp; - try { resp = JSON.parse(value); } catch { await reLoop(ctx); continue; } - if (resp && resp.control !== "cancel" && resp.id === descriptor.id) return resp; + try { + resp = JSON.parse(value); + } catch { + await reLoop(ctx); + continue; + } + if (resp && resp.control !== "cancel" && resp.id === descriptor.id) + return resp; await reLoop(ctx); } } async function reLoop(ctx) { - if (ctx.hasUI) await ctx.ui.notify( - "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", "warning"); + if (ctx.hasUI) + await ctx.ui.notify( + "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", + "warning", + ); } // --- the extension ------------------------------------------------------------ @@ -202,7 +268,8 @@ export default function (pi) { if (PROTECTED_FILES.test(path)) { return { block: true, - reason: "Questo file di stato e' gestito dal gate: non scriverlo direttamente.", + reason: + "Questo file di stato e' gestito dal gate: non scriverlo direttamente.", }; } } @@ -218,9 +285,9 @@ export default function (pi) { lockActive = true; lastSteered = false; pendingKickoff = /^\/nuova-domanda\b/.test(raw) - ? (process.env.THT_SESSION + ? process.env.THT_SESSION ? NUOVA_DOMANDA_KICKOFF_PROVIDED(process.env.THT_SESSION) - : NUOVA_DOMANDA_KICKOFF) + : NUOVA_DOMANDA_KICKOFF : RIPRENDI_KICKOFF; } // free-input block: attivo quando il lock è su, per qualsiasi input utente (non solo interattivo). @@ -229,7 +296,8 @@ export default function (pi) { if (trimmed.length === 0) return { action: "continue" }; if (trimmed.startsWith("/")) return { action: "continue" }; // `!`-prefixed free text -> forward to the model (the one sanctioned steer channel). - if (trimmed.startsWith("!")) return { action: "transform", text: trimmed.slice(1).trimStart() }; + if (trimmed.startsWith("!")) + return { action: "transform", text: trimmed.slice(1).trimStart() }; if (ctx.hasUI) { await ctx.ui.notify( "Durante la sessione rispondi con i widget del gate. " + @@ -268,7 +336,9 @@ export default function (pi) { last && last.role === "assistant" && Array.isArray(last.content) && - last.content.some((b) => b.type === "text" && (b.text ?? "").trim().length > 0) && + last.content.some( + (b) => b.type === "text" && (b.text ?? "").trim().length > 0, + ) && !last.content.some((b) => b.type === "toolCall"); if (isProse && !lastSteered) { lastSteered = true; @@ -290,7 +360,9 @@ export default function (pi) { "controllo (Altro/Torna indietro/Esci) sono sempre presenti. NON persiste: serve a " + "chiedere, non a decidere.", parameters: Type.Object({ - session: Type.String({ description: "Id sessione (per determinare la fase)." }), + session: Type.String({ + description: "Id sessione (per determinare la fase).", + }), title: Type.String(), options: Type.Array( Type.Object({ @@ -301,27 +373,35 @@ export default function (pi) { ), intro: Type.Optional(Type.String()), }), + prepareArguments: prepareReviewerArguments, async execute(_id, params, _signal, _onUpdate, ctx) { lockActive = true; const { session, title, options: opts, intro } = params; const phase = phaseName(ctx, currentPhase(ctx, session)); const recommended = opts.find((o) => o.recommended)?.id ?? null; - const clean = stripReserved(opts.map((o) => o.label)); + const widget = buildSelectRequest({ id: `u${Date.now()}`, phase, title, intro: intro ?? null, recommended, - options: opts.filter((o) => !isReserved(o.label)).map((o) => ({ id: o.id, label: o.label })), + options: opts + .filter((o) => !isReserved(o.label)) + .map((o) => ({ id: o.id, label: o.label })), }); const resp = await emitAndWait(ctx, widget); // control responses (back/exit/other) are surfaced as text for the model to act on. - if (resp.control === "freetext") return textResult(`Altro (reviewer): ${resp.text}`); - if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); - if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); + if (resp.control === "freetext") + return textResult(`Altro (reviewer): ${resp.text}`); + if (resp.control === "back") + return textResult("Il reviewer vuole tornare indietro."); + if (resp.control === "exit") + return textResult("Il reviewer vuole uscire."); const chosen = opts.find((o) => o.id === resp.choice); - return textResult(`Scelta del reviewer: ${chosen ? chosen.label : resp.choice}`); + return textResult( + `Scelta del reviewer: ${chosen ? chosen.label : resp.choice}`, + ); }, }); @@ -351,6 +431,7 @@ export default function (pi) { allow_empty: Type.Optional(Type.Boolean()), advance: Type.Optional(Type.Boolean()), }), + prepareArguments: prepareReviewerArguments, async execute(_id, params, _signal, _onUpdate, ctx) { lockActive = true; const { session, title, options: opts, advance } = params; @@ -361,20 +442,34 @@ export default function (pi) { phase, title, allowEmpty: params.allow_empty ?? false, - options: opts.filter((o) => !isReserved(o.label)).map((o) => ({ id: o.id, label: o.label })), + options: opts + .filter((o) => !isReserved(o.label)) + .map((o) => ({ id: o.id, label: o.label })), recommended: opts.find((o) => o.recommended)?.id ?? null, }); const resp = await emitAndWait(ctx, widget); if (resp.control === "freetext") { - return textResult(`Altro (reviewer): ${resp.text}. Riformula la proposta tenendo conto.`); + return textResult( + `Altro (reviewer): ${resp.text}. Riformula la proposta tenendo conto.`, + ); } - if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); - if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); + if (resp.control === "back") + return textResult("Il reviewer vuole tornare indietro."); + if (resp.control === "exit") + return textResult("Il reviewer vuole uscire."); const chosen = opts.filter((o) => (resp.choices ?? []).includes(o.id)); for (const c of chosen) { const d = c.decision; - const args = ["decision", "add", "--session", session, "--type", d.type, - "--subject", d.subject]; + const args = [ + "decision", + "add", + "--session", + session, + "--type", + d.type, + "--subject", + d.subject, + ]; if (d.detail) args.push("--detail", d.detail); if (d.rationale) args.push("--rationale", d.rationale); const err = relayIfThtFails(ctx, args, ""); @@ -414,6 +509,7 @@ export default function (pi) { // kind:"cte_plan" only -- ordered list of CTE names to persist (tht cte plan --name). names: Type.Optional(Type.Array(Type.String())), }), + prepareArguments: prepareReviewerArguments, async execute(_id, params, _signal, _onUpdate, ctx) { lockActive = true; const { session, kind, title, artifact } = params; @@ -431,14 +527,20 @@ export default function (pi) { `Rifiutato${resp.text ? ` (motivo: ${resp.text})` : ""}: rivedi e riprova.`, ); } - if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); - if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); + if (resp.control === "back") + return textResult("Il reviewer vuole tornare indietro."); + if (resp.control === "exit") + return textResult("Il reviewer vuole uscire."); // approved -> execute the privileged action via the CLI. if (kind === "phase") { // Explicit human approval: advance unconditionally except for unmet // prerequisites. Plain `phase advance` (no --auto) enforces advance_problems // and exits 6 with the missing items, which relayIfThtFails surfaces. - const err = relayIfThtFails(ctx, ["phase", "advance", "--session", session], PHASE_RECOVERY); + const err = relayIfThtFails( + ctx, + ["phase", "advance", "--session", session], + PHASE_RECOVERY, + ); if (err) return err; return textResult(`Fase approvata (sessione ${session}).`); } @@ -456,13 +558,24 @@ export default function (pi) { for (const n of names) planArgs.push("--name", n); const err = relayIfThtFails(ctx, planArgs, ""); if (err) return err; - return textResult(`CTE plan approvato (${names.length} CTE, sessione ${session}).`); + return textResult( + `CTE plan approvato (${names.length} CTE, sessione ${session}).`, + ); } if (kind === "cte_result" || kind === "sql") { const dt = kind === "sql" ? "sql_approved" : "cte_approved"; const err = relayIfThtFails( ctx, - ["decision", "add", "--session", session, "--type", dt, "--subject", `phase:${currentPhase(ctx, session)}`], + [ + "decision", + "add", + "--session", + session, + "--type", + dt, + "--subject", + `phase:${currentPhase(ctx, session)}`, + ], "", ); if (err) return err; @@ -509,11 +622,16 @@ export default function (pi) { // --- slash command: /torna [session_id] [N] (rollback to a previous phase) -- pi.registerCommand("torna", { - description: "Torna a una fase precedente: /torna [N] (default: un passo).", + description: + "Torna a una fase precedente: /torna [N] (default: un passo).", handler: async (args, ctx) => { const parts = args.trim().split(/\s+/).filter(Boolean); const sessionId = - parts.length >= 2 ? parts[0] : parts.length === 1 && /^\d/.test(parts[0]) ? undefined : parts[0]; + parts.length >= 2 + ? parts[0] + : parts.length === 1 && /^\d/.test(parts[0]) + ? undefined + : parts[0]; const sid = sessionId ?? activeSessionId ?? process.env.THT_SESSION; if (!sid) { await ctx.ui.notify("Uso: /torna [N]", "warning"); @@ -523,10 +641,20 @@ export default function (pi) { const targetArg = parts.find((x) => /^\d+$/.test(x)); const target = targetArg ? parseInt(targetArg, 10) : cur - 1; if (target < 1 || target >= cur) { - await ctx.ui.notify(`Target non valido (fase corrente ${cur}).`, "warning"); + await ctx.ui.notify( + `Target non valido (fase corrente ${cur}).`, + "warning", + ); return; } - tht(ctx, ["phase", "reopen", "--session", sid, "--phase", String(target)]); + tht(ctx, [ + "phase", + "reopen", + "--session", + sid, + "--phase", + String(target), + ]); await pi.sendUserMessage( `Ho riaperto la Fase ${target} della sessione ${sid}. Riprendi il protocollo da quella fase.`, { deliverAs: "followUp" },