feat(harness): load .env, add reviewer arg prep, refactor gate option filtering

- loadEnvFromDotenv: read ctx.cwd/.env into the process environment
- prepareReviewerArguments: normalize/parse reviewer tool inputs
- tidy reserved-option filtering and tht command argument assembly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:37:19 +02:00
co-authored by Claude Opus 4.8
parent d67409ff16
commit d999c0ca3a
+164 -36
View File
@@ -30,7 +30,32 @@ import {
buildMultiselectRequest, buildMultiselectRequest,
buildArtifactGate, buildArtifactGate,
} from "./gate/builders.js"; } 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) ----------- // --- anti-bypass block lists (spec D4, verbatim from source L169-177) -----------
const FORBIDDEN = [ const FORBIDDEN = [
@@ -38,7 +63,8 @@ const FORBIDDEN = [
/\btht\s+decision\s+add\b/, /\btht\s+decision\s+add\b/,
/\btht\s+cte\s+plan\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) - // --- kickoff payloads (verbatim from source L184-212, load-bearing model prose) -
const NUOVA_DOMANDA_KICKOFF = 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."; "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). // 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) ------------------------------------------- // --- helpers (verbatim from source) -------------------------------------------
@@ -90,8 +117,32 @@ function textResult(text) {
return { content: [{ type: "text", text }], details: {} }; 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/). // Single chokepoint for all CLI calls. cwd is the Pi project root (harness/).
function tht(ctx, args) { function tht(ctx, args) {
loadEnvFromDotenv(ctx);
return execFileSync("tht", args, { cwd: ctx.cwd, encoding: "utf8" }); return execFileSync("tht", args, { cwd: ctx.cwd, encoding: "utf8" });
} }
@@ -139,7 +190,10 @@ function advanceIfReady(ctx, session) {
return { advanced: true }; return { advanced: true };
} catch (e) { } catch (e) {
if (e.status === 6) return { advanced: false }; 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) { export async function emitAndWait(ctx, descriptor) {
for (;;) { for (;;) {
const value = await ctx.ui.input(JSON.stringify(descriptor), ""); 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; let resp;
try { resp = JSON.parse(value); } catch { await reLoop(ctx); continue; } try {
if (resp && resp.control !== "cancel" && resp.id === descriptor.id) return resp; resp = JSON.parse(value);
} catch {
await reLoop(ctx);
continue;
}
if (resp && resp.control !== "cancel" && resp.id === descriptor.id)
return resp;
await reLoop(ctx); await reLoop(ctx);
} }
} }
async function reLoop(ctx) { async function reLoop(ctx) {
if (ctx.hasUI) await ctx.ui.notify( if (ctx.hasUI)
"Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.", "warning"); await ctx.ui.notify(
"Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
"warning",
);
} }
// --- the extension ------------------------------------------------------------ // --- the extension ------------------------------------------------------------
@@ -202,7 +268,8 @@ export default function (pi) {
if (PROTECTED_FILES.test(path)) { if (PROTECTED_FILES.test(path)) {
return { return {
block: true, 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; lockActive = true;
lastSteered = false; lastSteered = false;
pendingKickoff = /^\/nuova-domanda\b/.test(raw) 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_PROVIDED(process.env.THT_SESSION)
: NUOVA_DOMANDA_KICKOFF) : NUOVA_DOMANDA_KICKOFF
: RIPRENDI_KICKOFF; : RIPRENDI_KICKOFF;
} }
// free-input block: attivo quando il lock è su, per qualsiasi input utente (non solo interattivo). // 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.length === 0) return { action: "continue" };
if (trimmed.startsWith("/")) return { action: "continue" }; if (trimmed.startsWith("/")) return { action: "continue" };
// `!`-prefixed free text -> forward to the model (the one sanctioned steer channel). // `!`-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) { if (ctx.hasUI) {
await ctx.ui.notify( await ctx.ui.notify(
"Durante la sessione rispondi con i widget del gate. " + "Durante la sessione rispondi con i widget del gate. " +
@@ -268,7 +336,9 @@ export default function (pi) {
last && last &&
last.role === "assistant" && last.role === "assistant" &&
Array.isArray(last.content) && 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"); !last.content.some((b) => b.type === "toolCall");
if (isProse && !lastSteered) { if (isProse && !lastSteered) {
lastSteered = true; lastSteered = true;
@@ -290,7 +360,9 @@ export default function (pi) {
"controllo (Altro/Torna indietro/Esci) sono sempre presenti. NON persiste: serve a " + "controllo (Altro/Torna indietro/Esci) sono sempre presenti. NON persiste: serve a " +
"chiedere, non a decidere.", "chiedere, non a decidere.",
parameters: Type.Object({ 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(), title: Type.String(),
options: Type.Array( options: Type.Array(
Type.Object({ Type.Object({
@@ -301,27 +373,35 @@ export default function (pi) {
), ),
intro: Type.Optional(Type.String()), intro: Type.Optional(Type.String()),
}), }),
prepareArguments: prepareReviewerArguments,
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session, title, options: opts, intro } = params; const { session, title, options: opts, intro } = params;
const phase = phaseName(ctx, currentPhase(ctx, session)); const phase = phaseName(ctx, currentPhase(ctx, session));
const recommended = opts.find((o) => o.recommended)?.id ?? null; const recommended = opts.find((o) => o.recommended)?.id ?? null;
const clean = stripReserved(opts.map((o) => o.label));
const widget = buildSelectRequest({ const widget = buildSelectRequest({
id: `u${Date.now()}`, id: `u${Date.now()}`,
phase, phase,
title, title,
intro: intro ?? null, intro: intro ?? null,
recommended, 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); const resp = await emitAndWait(ctx, widget);
// control responses (back/exit/other) are surfaced as text for the model to act on. // 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 === "freetext")
if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); return textResult(`Altro (reviewer): ${resp.text}`);
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.find((o) => o.id === resp.choice); 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()), allow_empty: Type.Optional(Type.Boolean()),
advance: Type.Optional(Type.Boolean()), advance: Type.Optional(Type.Boolean()),
}), }),
prepareArguments: prepareReviewerArguments,
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session, title, options: opts, advance } = params; const { session, title, options: opts, advance } = params;
@@ -361,20 +442,34 @@ export default function (pi) {
phase, phase,
title, title,
allowEmpty: params.allow_empty ?? false, 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, recommended: opts.find((o) => o.recommended)?.id ?? null,
}); });
const resp = await emitAndWait(ctx, widget); const resp = await emitAndWait(ctx, widget);
if (resp.control === "freetext") { 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 === "back")
if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); 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)); const chosen = opts.filter((o) => (resp.choices ?? []).includes(o.id));
for (const c of chosen) { for (const c of chosen) {
const d = c.decision; const d = c.decision;
const args = ["decision", "add", "--session", session, "--type", d.type, const args = [
"--subject", d.subject]; "decision",
"add",
"--session",
session,
"--type",
d.type,
"--subject",
d.subject,
];
if (d.detail) args.push("--detail", d.detail); if (d.detail) args.push("--detail", d.detail);
if (d.rationale) args.push("--rationale", d.rationale); if (d.rationale) args.push("--rationale", d.rationale);
const err = relayIfThtFails(ctx, args, ""); 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). // kind:"cte_plan" only -- ordered list of CTE names to persist (tht cte plan --name).
names: Type.Optional(Type.Array(Type.String())), names: Type.Optional(Type.Array(Type.String())),
}), }),
prepareArguments: prepareReviewerArguments,
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session, kind, title, artifact } = params; const { session, kind, title, artifact } = params;
@@ -431,14 +527,20 @@ export default function (pi) {
`Rifiutato${resp.text ? ` (motivo: ${resp.text})` : ""}: rivedi e riprova.`, `Rifiutato${resp.text ? ` (motivo: ${resp.text})` : ""}: rivedi e riprova.`,
); );
} }
if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); if (resp.control === "back")
if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); 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. // approved -> execute the privileged action via the CLI.
if (kind === "phase") { if (kind === "phase") {
// Explicit human approval: advance unconditionally except for unmet // Explicit human approval: advance unconditionally except for unmet
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems // prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
// and exits 6 with the missing items, which relayIfThtFails surfaces. // 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; if (err) return err;
return textResult(`Fase approvata (sessione ${session}).`); return textResult(`Fase approvata (sessione ${session}).`);
} }
@@ -456,13 +558,24 @@ export default function (pi) {
for (const n of names) planArgs.push("--name", n); for (const n of names) planArgs.push("--name", n);
const err = relayIfThtFails(ctx, planArgs, ""); const err = relayIfThtFails(ctx, planArgs, "");
if (err) return err; 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") { if (kind === "cte_result" || kind === "sql") {
const dt = kind === "sql" ? "sql_approved" : "cte_approved"; const dt = kind === "sql" ? "sql_approved" : "cte_approved";
const err = relayIfThtFails( const err = relayIfThtFails(
ctx, 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; if (err) return err;
@@ -509,11 +622,16 @@ export default function (pi) {
// --- slash command: /torna [session_id] [N] (rollback to a previous phase) -- // --- slash command: /torna [session_id] [N] (rollback to a previous phase) --
pi.registerCommand("torna", { pi.registerCommand("torna", {
description: "Torna a una fase precedente: /torna <session_id> [N] (default: un passo).", description:
"Torna a una fase precedente: /torna <session_id> [N] (default: un passo).",
handler: async (args, ctx) => { handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/).filter(Boolean); const parts = args.trim().split(/\s+/).filter(Boolean);
const sessionId = 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; const sid = sessionId ?? activeSessionId ?? process.env.THT_SESSION;
if (!sid) { if (!sid) {
await ctx.ui.notify("Uso: /torna <session_id> [N]", "warning"); await ctx.ui.notify("Uso: /torna <session_id> [N]", "warning");
@@ -523,10 +641,20 @@ export default function (pi) {
const targetArg = parts.find((x) => /^\d+$/.test(x)); const targetArg = parts.find((x) => /^\d+$/.test(x));
const target = targetArg ? parseInt(targetArg, 10) : cur - 1; const target = targetArg ? parseInt(targetArg, 10) : cur - 1;
if (target < 1 || target >= cur) { 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; return;
} }
tht(ctx, ["phase", "reopen", "--session", sid, "--phase", String(target)]); tht(ctx, [
"phase",
"reopen",
"--session",
sid,
"--phase",
String(target),
]);
await pi.sendUserMessage( await pi.sendUserMessage(
`Ho riaperto la Fase ${target} della sessione ${sid}. Riprendi il protocollo da quella fase.`, `Ho riaperto la Fase ${target} della sessione ${sid}. Riprendi il protocollo da quella fase.`,
{ deliverAs: "followUp" }, { deliverAs: "followUp" },