Files
ThothII/docs/superpowers/plans/2026-06-27-harness-rpc-readiness.md
T

46 KiB
Raw Blame History

Harness RPC-readiness Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Rendere l'harness tht pienamente pilotabile da un client RPC esterno (il backend): il gate funziona in pi --mode rpc (kickoff + round-trip widget), il backend possiede l'id di sessione, il CLI espone le uscite --json necessarie, ed esistono i due test-double che chiudono il loop in CI.

Architecture: Si parte da uno spike che osserva il comportamento reale del gate dentro pi --mode rpc (mai testato finora, cf. docs/l2-run-report-2026-06-27.md). Le scoperte dello spike fissano l'esatta forma del wire e guidano l'adattamento del gate. Tutto l'adattamento del gate è coperto da un fake-pi-runtime (mock dell'API estensione pi) eseguibile in CI; le aggiunte al CLI sono TDD deterministiche; un fake-pi-rpc (processo che parla il protocollo JSONL su stdio) viene consegnato qui come asset condiviso per il Piano Backend, con un golden test di contratto (D10).

Tech Stack: Python 3.13 + Typer + pytest (CLI tht); JavaScript ESM/CJS + node --test (gate + test-double); Pi = @earendil-works/pi-coding-agent (binario pi, modalità --mode rpc).

Global Constraints

  • Node ≥ 20; JS test runner: node --test (come harness/package.json → npm test).
  • Python 3.13, ambiente in harness/.venv (pip install -e ".[dev]"); test: pytest da harness/.
  • Framing RPC: LF-only JSONL — serializzazione JSON.stringify(value) + "\n"; lettura: split su \n, strip di un eventuale \r finale. MAI readline (spezza su separatori Unicode validi dentro le stringhe JSON). Riferimento canonico: @earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js.
  • WIRE CONTRACT (corretto post-spike Task 1): ctx.sendRaw NON esiste in Pi e pi.on("extension_ui_response") NON viene dispatchato. L'unico meccanismo UI estensione in RPC è ctx.ui.select/confirm/input(...), che instradano la risposta via pendingExtensionRequests. Il gate trasporta il widget-descriptor completo come JSON nel title di ctx.ui.input: Pi emette {type:"extension_ui_request", id, method:"input", title:"<descriptor JSON>"} e il client risponde {type:"extension_ui_response", id, value:"<ui_response JSON>"} (oppure {id, cancelled:true} → no-limbo, re-loop). Il contratto verso il frontend (ui_request/ui_response, architettura §4) resta invariato: la traduzione native↔FE avviene nel backend (Piano 2).
  • --json mantiene stdout puro: in modalità JSON nessun warning/tabella umana su stdout (pattern già in tht/cli/search_cmd.py). Output: typer.echo(json.dumps(data, ensure_ascii=False, indent=2)).
  • Nessun segreto nel codice/test: le credenziali stanno solo in harness/.env (gitignored). I task L2/spike che toccano Pi reale richiedono .env + VPN e NON girano in CI.
  • Il gate resta load-bearing: gli invarianti verbatim del gate (anti-bypass tool_call hook, input-lock, no-limbo) non vanno indeboliti dagli adattamenti RPC.
  • Test setup per importare il gate (Task 3–5): tht-gate.js è ESM e importa typebox (fornito a runtime da Pi, NON presente in harness/). Per testarlo, aggiungere typebox come devDependency in harness/package.json (versione allineata a Pi: "typebox": "1.1.38") ed eseguire npm install una volta (Task 3, prima di scrivere i test). Su Node ≥ 22 require() di un modulo ESM senza top-level await funziona; se in questo ambiente dà problemi, usare await import("../../tht-gate.js") dentro un test async (i file di test possono restare node --test). Verificare l'import del gate PRIMA di scrivere asserzioni.

Task 1: Spike — comportamento del gate in pi --mode rpc

Spike di osservazione (non TDD): mai verificato finora. L'esito fissa la forma esatta del wire e decide i Task 3–4. Richiede Pi reale + .env + VPN.

Files:

  • Create: harness/scripts/rpc_probe.mjs (driver manuale usa-e-getta, committato come strumento)
  • Create: harness/docs/rpc-readiness-findings.md (referto delle osservazioni + decisioni)

Interfaces:

  • Produces: harness/docs/rpc-readiness-findings.md con le risposte alle 4 domande sotto, citate dai Task 3 e 4.

  • Step 1: Scrivere il driver di probe

harness/scripts/rpc_probe.mjs fa spawn di Pi in RPC, manda l'avvio del workflow come comando prompt, stampa ogni riga JSONL ricevuta con un prefisso, e quando arriva un extension_ui_request risponde con un extension_ui_response correlato per id.

// rpc_probe.mjs — manual probe: drive `pi --mode rpc` and observe the gate.
// Usage (from harness/, with .env loaded + VPN up):  node scripts/rpc_probe.mjs
import { spawn } from "node:child_process";

const pi = spawn("pi", ["--mode", "rpc"], { cwd: process.cwd(), env: process.env });

const send = (obj) => {
  const line = JSON.stringify(obj) + "\n";
  process.stdout.write(`>>> SEND ${line}`);
  pi.stdin.write(line);
};

let buf = "";
pi.stdout.on("data", (chunk) => {
  buf += chunk.toString("utf8");
  for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
    const line = buf.slice(0, nl).replace(/\r$/, "");
    buf = buf.slice(nl + 1);
    if (!line) continue;
    console.log(`<<< RECV ${line}`);
    let msg;
    try { msg = JSON.parse(line); } catch { continue; }
    if (msg.type === "extension_ui_request") {
      // Reply in BOTH the gate's expected shape and Pi's native shape; observe which one unblocks.
      const id = msg.id ?? msg.ui_request?.id;
      send({ type: "extension_ui_response", id, control: "freetext", text: "PROBE-ANSWER" });
    }
  }
});
pi.stderr.on("data", (d) => process.stdout.write(`!!! STDERR ${d}`));
pi.on("exit", (code) => console.log(`### pi exited ${code}`));

// Kick off the workflow via an RPC prompt command (NOT interactive keystrokes).
setTimeout(() => send({ type: "prompt", message: '/nuova-domanda "quante cardioversioni nel 2024"' }), 500);
setTimeout(() => { pi.stdin.end(); }, 60000);
  • Step 2: Eseguire il probe e catturare l'output

Run (da harness/, con .env caricato e VPN attiva):

set -a; . ./.env; set +a
node scripts/rpc_probe.mjs | tee /tmp/rpc_probe.log

Expected: una sequenza di righe <<< RECV {...}. Osservare in particolare se compare text_delta/eventi del modello e se compare una riga extension_ui_request.

  • Step 3: Registrare le 4 osservazioni decisive in rpc-readiness-findings.md

Documentare con evidenza (righe del log) le risposte a:

  1. Kickoff: inviando il workflow come comando prompt, il gate attiva kickoff + input-lock? (cioè: il modello riceve le istruzioni operative e parte dalla Fase 1, oppure l'input hook — che filtra event.source === "interactive" — non scatta?)
  2. Emissione widget: quando il gate chiama emitAndWait, su stdout appare {"type":"extension_ui_request","ui_request":{...}} (envelope custom del gate) oppure no?
  3. Routing risposta: inviando extension_ui_response con id correlato, il gate prosegue (la handleUiResponse risolve la promise) oppure la risposta viene assorbita da rpc-mode (pendingExtensionRequests) e il gate resta appeso?
  4. Source dell'input !: lo steering (prompt/steer con testo !...) raggiunge il modello?
  • Step 4: Decisione esplicita per i Task 3–4

In coda al referto, scrivere la decisione: per il kickoff (Task 3) e per il round-trip (Task 4), indicare se basta confermare il meccanismo esistente o serve adattarlo, e come. Casi attesi:

  • Se (1) è NO → il gate deve riconoscere l'avvio del workflow anche per event.source !== "interactive" (Task 3).

  • Se (3) è "assorbita" → il gate deve emettere il widget tramite il meccanismo nativo che registra in pendingExtensionRequests (Task 4, variante B), invece del sendRaw custom (variante A).

  • Step 5: Commit

git add harness/scripts/rpc_probe.mjs harness/docs/rpc-readiness-findings.md
git commit -m "spike(harness): probe gate behavior in pi --mode rpc + findings"

Task 2: fake-pi-runtime — mock dell'API estensione pi per i test del gate

Files:

  • Create: harness/.pi/extensions/gate/__tests__/fake_pi_runtime.js
  • Test: harness/.pi/extensions/gate/__tests__/fake_pi_runtime.test.js

Interfaces:

  • Produces: createFakePi() → { pi, ctx, tools, emit, enqueueUi } dove

    • pi.on(event, handler) registra handler; pi.registerTool(def, fn) li memorizza in tools (Map per nome).
    • pi.emit(event, payload) invoca i handler registrati (await se async) e ritorna il loro valore (per testare i ritorni {block, action} dei hook).
    • ctx.ui.notify(msg, level) accoda in ctx.notifications[]; ctx.ui.input/select/confirm(...) registrano la chiamata in ctx.uiCalls[] e ritornano il prossimo valore da ctx.uiQueue (FIFO) — questo modella l'API UI NATIVA di Pi (non esiste sendRaw); ctx.hasUI = true; ctx.cwd = "<tmp>".
    • enqueueUi(value) mette in coda la prossima risposta che ctx.ui.* restituirà (es. JSON.stringify(uiResponse) per input, o undefined per simulare cancel → no-limbo).
  • Consumes: nulla.

  • Step 1: Scrivere il test del runtime mock

const test = require("node:test");
const assert = require("node:assert");
const { createFakePi } = require("./fake_pi_runtime.js");

test("pi.on + emit invoca il handler e ne ritorna il valore", async () => {
  const { pi } = createFakePi();
  pi.on("tool_call", (e) => (e.toolName === "x" ? { block: true } : undefined));
  assert.deepEqual(await pi.emit("tool_call", { toolName: "x" }), { block: true });
  assert.equal(await pi.emit("tool_call", { toolName: "y" }), undefined);
});

test("ctx.ui.input registra la chiamata e ritorna il valore in coda (API nativa)", async () => {
  const { ctx, enqueueUi } = createFakePi();
  enqueueUi('{"id":"u1","choices":["a"]}');
  const v = await ctx.ui.input("title-json", "");
  assert.equal(v, '{"id":"u1","choices":["a"]}');
  assert.equal(ctx.uiCalls[0].method, "input");
  assert.equal(ctx.uiCalls[0].title, "title-json");
});

test("ctx.ui.input senza valore in coda ritorna undefined (modella cancel)", async () => {
  const { ctx } = createFakePi();
  assert.equal(await ctx.ui.input("t", ""), undefined);
});
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && node --test .pi/extensions/gate/__tests__/fake_pi_runtime.test.js Expected: FAIL — Cannot find module './fake_pi_runtime.js'.

  • Step 3: Implementare il runtime mock
// fake_pi_runtime.js — minimal mock of the Pi extension runtime for gate tests.
// Models Pi's NATIVE extension UI API (ctx.ui.select/confirm/input). There is NO
// ctx.sendRaw in Pi; the gate awaits ctx.ui.* and the runtime routes the response.
function createFakePi() {
  const handlers = new Map();
  const tools = new Map();
  const ctx = {
    hasUI: true,
    cwd: "/tmp/fake-pi-session",
    notifications: [],
    uiCalls: [],
    uiQueue: [],
    ui: {
      notify: async (message, level = "info") => ctx.notifications.push({ message, level }),
      input: async (title, placeholder, opts) => { ctx.uiCalls.push({ method: "input", title, placeholder, opts }); return ctx.uiQueue.shift(); },
      select: async (title, options, opts) => { ctx.uiCalls.push({ method: "select", title, options, opts }); return ctx.uiQueue.shift(); },
      confirm: async (title, message, opts) => { ctx.uiCalls.push({ method: "confirm", title, message, opts }); return ctx.uiQueue.shift(); },
    },
  };
  const pi = {
    on: (event, handler) => {
      if (!handlers.has(event)) handlers.set(event, []);
      handlers.get(event).push(handler);
    },
    registerTool: (def, fn) => tools.set(def?.name ?? def, { def, fn }),
    emit: async (event, payload) => {
      let result;
      for (const h of handlers.get(event) ?? []) result = await h(payload, ctx);
      return result;
    },
  };
  return { pi, ctx, tools, emit: pi.emit, enqueueUi: (v) => ctx.uiQueue.push(v) };
}
module.exports = { createFakePi };
  • Step 4: Eseguire il test (deve passare)

Run: cd harness && node --test .pi/extensions/gate/__tests__/fake_pi_runtime.test.js Expected: PASS (2 test).

  • Step 5: Commit
git add harness/.pi/extensions/gate/__tests__/fake_pi_runtime.js harness/.pi/extensions/gate/__tests__/fake_pi_runtime.test.js
git commit -m "test(harness): fake-pi-runtime mock for gate CI tests"

Task 3: Gate — kickoff + input-lock all'avvio del workflow in RPC mode

Applica la decisione del Task 1 (osservazione #1). Il gate deve attivare kickoff + input-lock quando il workflow parte via comando RPC prompt, non solo da input interattivo.

Files:

  • Modify: harness/.pi/extensions/tht-gate.js (handler pi.on("input", …), ~riga 220-235)
  • Test: harness/.pi/extensions/gate/__tests__/gate_entry.test.js

Interfaces:

  • Consumes: createFakePi() (Task 2); il default export di tht-gate.js (la funzione (pi) => {…}).

  • Produces: invariante "dopo un input di avvio workflow, lockActive è attivo e pendingKickoff è impostato", indipendentemente dal source.

  • Step 1: Scrivere il test di entry RPC

const test = require("node:test");
const assert = require("node:assert");
const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");

test("avvio workflow via input non-interattivo attiva il lock (free text bloccato)", async () => {
  const { pi } = createFakePi();
  installGate(pi);
  // entry del workflow con source 'rpc' (come un comando prompt RPC)
  await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
  // dopo l'entry, un testo libero senza '!' deve essere bloccato (lock attivo)
  const res = await pi.emit("input", { source: "rpc", text: "promuovi la tabella pazienti" });
  assert.equal(res.action, "handled");
});

test("testo con '!' passa al modello (steer) anche con lock attivo", async () => {
  const { pi } = createFakePi();
  installGate(pi);
  await pi.emit("input", { source: "rpc", text: "/nuova-domanda \"x\"" });
  const res = await pi.emit("input", { source: "rpc", text: "!considera solo il 2024" });
  assert.deepEqual(res, { action: "transform", text: "considera solo il 2024" });
});
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_entry.test.js Expected: FAIL — l'entry detection filtra source === "interactive", quindi il lock non si attiva e il primo emit ritorna {action:"continue"} (non handled).

  • Step 3: Adattare l'entry detection nel gate

In tht-gate.js, nel handler pi.on("input", …): l'entry del workflow (/nuova-domanda | /riprendi-sessione) deve essere riconosciuta a prescindere dal source; il blocco del free-text resta valido per gli input dell'utente in sessione (interattivi o via RPC prompt). Sostituire la condizione di entry e quella di filtro:

// entry detection: workflow-start funziona sia da TUI sia da comando RPC `prompt`.
if (/^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) {
  lockActive = true;
  lastSteered = false;
  pendingKickoff = /^\/nuova-domanda\b/.test(raw) ? NUOVA_DOMANDA_KICKOFF : RIPRENDI_KICKOFF;
}
// free-input block: attivo quando il lock è su, per qualsiasi input utente (non solo interattivo).
if (!lockActive) return { action: "continue" };

(Rimuovere i due event.source === "interactive" su entry e filtro. Conservare invariati: passthrough /…, canale ! → transform, notify.)

  • Step 4: Eseguire i test (devono passare)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_entry.test.js Expected: PASS (2 test). Poi npm test per assicurare nessuna regressione sui builder. Expected: tutti verdi.

  • Step 5: Commit
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_entry.test.js
git commit -m "fix(harness): gate kickoff/lock entry works in RPC mode (not only interactive)"

Task 4: Gate — round-trip del widget via API nativa ctx.ui.input (rewrite)

Corretto post-spike (Task 1): ctx.sendRaw NON esiste e pi.on("extension_ui_response") non viene dispatchato — il meccanismo attuale di emitAndWait crasherebbe. Si riscrive emitAndWait per usare l'API UI NATIVA di Pi: il widget-descriptor viaggia come JSON nel title di ctx.ui.input; la risposta torna come stringa value (o undefined su cancel → no-limbo). Si rimuovono _pending, handleUiResponse e la registrazione pi.on("extension_ui_response", …).

Files:

  • Modify: harness/.pi/extensions/tht-gate.js (emitAndWait, ~riga 148-175; rimozione handleUiResponse ~189-195 e pi.on("extension_ui_response", …) ~289)
  • Test: harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js

Interfaces:

  • Consumes: createFakePi()/enqueueUi (Task 2). Aggiungere a tht-gate.js un named export emitAndWait per testarlo direttamente.

  • Produces: emitAndWait(ctx, descriptor): Promise<uiResponse> — chiama ctx.ui.input(JSON.stringify(descriptor), ""); se value è undefined/null o JSON non valido o resp.control === "cancel" → ri-presenta (no-limbo); altrimenti ritorna JSON.parse(value). Invariante: il title passato a ctx.ui.input è esattamente JSON.stringify(descriptor).

  • Step 1: Scrivere il test del round-trip (via export diretto)

const test = require("node:test");
const assert = require("node:assert");
const { createFakePi } = require("./fake_pi_runtime.js");
const { emitAndWait } = require("../../tht-gate.js");

test("emitAndWait trasporta il descriptor come JSON nel title e ritorna la ui_response parsata", async () => {
  const { ctx, enqueueUi } = createFakePi();
  const descriptor = { id: "u1", widget: "select", options: [{ id: "a", label: "A" }] };
  enqueueUi(JSON.stringify({ id: "u1", choices: ["a"] }));
  const resp = await emitAndWait(ctx, descriptor);
  assert.deepEqual(resp, { id: "u1", choices: ["a"] });
  assert.equal(ctx.uiCalls[0].method, "input");
  assert.equal(ctx.uiCalls[0].title, JSON.stringify(descriptor));
});

test("no-limbo: undefined (cancel) ri-presenta lo stesso widget", async () => {
  const { ctx, enqueueUi } = createFakePi();
  const descriptor = { id: "u1", widget: "select", options: [] };
  enqueueUi(undefined);                                   // 1° giro: cancel
  enqueueUi(JSON.stringify({ id: "u1", choices: ["a"] })); // 2° giro: risposta valida
  const resp = await emitAndWait(ctx, descriptor);
  assert.deepEqual(resp, { id: "u1", choices: ["a"] });
  assert.equal(ctx.uiCalls.length, 2);                    // ri-presentato una volta
  assert.ok(ctx.notifications.some((n) => /Esc non chiude/.test(n.message)));
});
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js Expected: FAIL — emitAndWait non è esportato / usa ancora ctx.sendRaw.

  • Step 3: Riscrivere emitAndWait ed esportarlo

In tht-gate.js sostituire il blocco _pending/emitAndWait/handleUiResponse:

// widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input). Il descriptor
// viaggia come JSON nel title; la risposta torna come stringa `value`. No ctx.sendRaw,
// no pi.on("extension_ui_response"): Pi instrada la risposta via pendingExtensionRequests.
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; }
    let 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");
}

Rimuovere: la Map _pending, la funzione handleUiResponse e la riga pi.on("extension_ui_response", handleUiResponse). Le chiamate esistenti a emitAndWait(ctx, descriptor) (dai reviewer tool) restano invariate (stessa firma e stesso ritorno).

  • Step 4: Eseguire i test (devono passare)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_roundtrip.test.js && npm test Expected: PASS (2 nuovi test + nessuna regressione su builder/entry/runtime).

  • Step 5: Commit
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_roundtrip.test.js
git commit -m "fix(harness): gate widget round-trip via native ctx.ui.input (no sendRaw); no-limbo"

Task 5: Sessione con id fornito dall'esterno (BE-5)

Il backend pre-crea la sessione e ne possiede l'id; il gate deve USARE quell'id invece di istruire il modello a crearne uno. Veicolo: variabile d'ambiente THT_SESSION (già referenziata dal gate per /torna).

Files:

  • Modify: harness/.pi/extensions/tht-gate.js (payload NUOVA_DOMANDA_KICKOFF + selezione kickoff, ~riga 44-62, 226-231)
  • Test: harness/.pi/extensions/gate/__tests__/gate_provided_session.test.js

Interfaces:

  • Consumes: process.env.THT_SESSION (impostata dal backend allo spawn).

  • Produces: quando THT_SESSION è valorizzata, il kickoff iniettato istruisce il modello a USARE quell'id (niente tht session new); altrimenti comportamento attuale (il modello crea la sessione).

  • Step 1: Scrivere il test

const test = require("node:test");
const assert = require("node:assert");
const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");

test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async () => {
  process.env.THT_SESSION = "2026-06-27-100000-test";
  try {
    const { pi, ctx } = createFakePi();
    installGate(pi);
    await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
    const injected = await pi.emit("before_agent_start", { });
    const text = injected?.appendMessage ?? injected?.text ?? "";
    assert.match(text, /2026-06-27-100000-test/);
    assert.doesNotMatch(text, /tht session new/);
  } finally { delete process.env.THT_SESSION; }
});

Nota: adeguare il nome del campo ritornato da before_agent_start a come il gate inietta il kickoff (vedi handler ~riga 250). Il test asserisce il contenuto del testo iniettato.

  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_provided_session.test.js Expected: FAIL — il kickoff contiene sempre tht session new.

  • Step 3: Implementare il kickoff a id fornito

Aggiungere un secondo payload e selezionarlo quando THT_SESSION è presente:

const NUOVA_DOMANDA_KICKOFF_PROVIDED = (sessionId) =>
  "Istruzioni operative — sessione ThothII (workflow human-in-the-middle). " +
  `La sessione è GIÀ creata: usa l'id \`${sessionId}\` in OGNI comando \`tht\`. ` +
  "NON eseguire `tht session new`.\n" +
  "1. Carica la skill leggendo `.pi/skills/tht-sessione/SKILL.md` con il tool `read`, " +
  `poi segui il workflow dalla Fase 1 usando l'id \`${sessionId}\`.\n` +
  "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.";

// nella selezione del kickoff (input hook):
pendingKickoff = /^\/nuova-domanda\b/.test(raw)
  ? (process.env.THT_SESSION ? NUOVA_DOMANDA_KICKOFF_PROVIDED(process.env.THT_SESSION) : NUOVA_DOMANDA_KICKOFF)
  : RIPRENDI_KICKOFF;
  • Step 4: Eseguire i test (devono passare)

Run: cd harness && node --test .pi/extensions/gate/__tests__/gate_provided_session.test.js && npm test Expected: PASS, nessuna regressione.

  • Step 5: Commit
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_provided_session.test.js
git commit -m "feat(harness): gate uses externally-provided THT_SESSION id (BE-5)"

Task 6: CLI — tht sql preview --json + --offset

Alimenta la paginazione AGGrid del backend. do_run ritorna già rows/columns/execution_ms/truncated; serve l'uscita JSON e l'iniezione di OFFSET.

Files:

  • Modify: harness/tht/cli/sql_cmd.py (preview_cmd, ~riga 157; do_run, ~riga 105)
  • Modify: harness/tht/execute/__init__.py e harness/tht/rest/execute.py (firma run_controlled* con offset)
  • Test: harness/tests/test_sql_preview_json.py

Interfaces:

  • Consumes: do_run(cfg, sql, *, limit, offset=0).

  • Produces: tht sql preview <file> --json [--limit N] [--offset M] [--session S] stampa su stdout {"columns": [...], "rows": [[...]], "execution_ms": int, "truncated": bool, "limit": int, "offset": int} e nient'altro.

  • Step 1: Scrivere il test (offset injection + json shape)

# harness/tests/test_sql_preview_json.py
import json
from tht.execute.limit import inject_limit_offset  # helper puro da creare

def test_inject_limit_offset_wraps_query():
    sql = "SELECT a FROM t ORDER BY a"
    out = inject_limit_offset(sql, limit=10, offset=20)
    assert "LIMIT 10" in out and "OFFSET 20" in out
    # la query originale resta una sottoquery (niente clobber di un LIMIT esistente)
    assert "SELECT a FROM t ORDER BY a" in out

def test_inject_limit_offset_zero_offset_no_offset_clause():
    out = inject_limit_offset("SELECT 1", limit=5, offset=0)
    assert "LIMIT 5" in out
    assert "OFFSET" not in out
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && pytest tests/test_sql_preview_json.py -v Expected: FAIL — ModuleNotFoundError: tht.execute.limit.

  • Step 3: Implementare l'helper di iniezione
# harness/tht/execute/limit.py
def inject_limit_offset(sql: str, *, limit: int, offset: int = 0) -> str:
    """Wrappa la query come sottoquery e applica LIMIT/OFFSET in modo non distruttivo.
    Evita di sovrascrivere un LIMIT già presente nella query dell'utente."""
    inner = sql.strip().rstrip(";")
    clause = f"LIMIT {int(limit)}" + (f" OFFSET {int(offset)}" if offset else "")
    return f"SELECT * FROM (\n{inner}\n) AS _tht_page {clause}"
  • Step 4: Eseguire il test (deve passare)

Run: cd harness && pytest tests/test_sql_preview_json.py -v Expected: PASS (2 test).

  • Step 5: Cablare --json/--offset in preview_cmd e do_run

In do_run aggiungere offset: int = 0 e usare inject_limit_offset quando offset > 0 (altrimenti il path attuale a solo LIMIT). In preview_cmd aggiungere offset e json_out; in modalità JSON sopprimere tabella rich e warning, stampare solo il dict:

@sql_app.command("preview")
def preview_cmd(
    file: Path = typer.Argument(...),
    limit: int = typer.Option(None, "--limit"),
    offset: int = typer.Option(0, "--offset"),
    session: str = typer.Option(None, "--session"),
    json_out: bool = typer.Option(False, "--json", help="Output JSON puro per il backend."),
    config: Path = CONFIG_OPT,
) -> None:
    cfg = _load_config_or_exit(config)
    require_action(cfg, "preview")
    sql = _read_sql(file)
    check = validate_or_exit(cfg, sql, session)
    effective_limit = limit if limit is not None else cfg.execution.max_preview_rows
    try:
        result = do_run(cfg, sql, limit=effective_limit, offset=offset)
    except ExecutionError as e:
        if json_out:
            typer.echo(json.dumps({"error": str(e)}, ensure_ascii=False)); raise typer.Exit(code=1)
        typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True); raise typer.Exit(code=1)
    if json_out:
        typer.echo(json.dumps({
            "columns": list(result.columns),
            "rows": [list(r) for r in result.rows],
            "execution_ms": result.execution_ms,
            "truncated": result.truncated,
            "limit": effective_limit, "offset": offset,
        }, ensure_ascii=False))
        return
    # ... (path umano esistente invariato)
  • Step 6: Test del contratto JSON (fixture senza DB)

Aggiungere a test_sql_preview_json.py un test che invoca preview_cmd con do_run monkeypatchato a un risultato fittizio e verifica che stdout sia JSON puro con le chiavi attese.

def test_preview_json_pure_stdout(monkeypatch, tmp_path, capsys):
    from tht.cli import sql_cmd
    from types import SimpleNamespace
    fake = SimpleNamespace(columns=["a"], rows=[[1],[2]], execution_ms=3, truncated=False)
    monkeypatch.setattr(sql_cmd, "do_run", lambda *a, **k: fake)
    monkeypatch.setattr(sql_cmd, "validate_or_exit", lambda *a, **k: SimpleNamespace(ast=None))
    monkeypatch.setattr(sql_cmd, "require_action", lambda *a, **k: None)
    monkeypatch.setattr(sql_cmd, "_load_config_or_exit",
                        lambda *a, **k: SimpleNamespace(execution=SimpleNamespace(max_preview_rows=100)))
    f = tmp_path / "q.sql"; f.write_text("SELECT 1")
    sql_cmd.preview_cmd(file=f, limit=None, offset=0, session=None, json_out=True, config=None)
    out = capsys.readouterr().out.strip()
    data = json.loads(out)  # deve parsare: stdout puro
    assert data["columns"] == ["a"] and data["rows"] == [[1],[2]]

Run: cd harness && pytest tests/test_sql_preview_json.py -v Expected: PASS (tutti).

  • Step 7: Commit
git add harness/tht/execute/limit.py harness/tht/cli/sql_cmd.py harness/tht/execute/__init__.py harness/tht/rest/execute.py harness/tests/test_sql_preview_json.py
git commit -m "feat(harness): tht sql preview --json + --offset for AGGrid paging (BE-2)"

Task 7: CLI — tht session list --json + tht session show --json

Alimenta la lista e il dettaglio sessioni nel FE. list è nuovo; show oggi stampa testo umano.

Files:

  • Modify: harness/tht/cli/session_cmd.py (nuovo list_cmd; show_cmd con --json)
  • Test: harness/tests/test_session_list_json.py

Interfaces:

  • Produces:

    • tht session list --json → [{"id","status","question","summary","created_at","updated_at","author"}, ...] ordinato per created_at desc.
    • tht session show <id> --json → manifest completo + {"phase": <int derivata>, "has_schema_linking": bool}.
  • Step 1: Scrivere il test

# harness/tests/test_session_list_json.py
import json
from tht.session.store import create_session
from tht.session.models import SessionManifest

def test_list_json_lists_created_sessions(tmp_path):
    from tht.config import DatabaseConfig
    db = DatabaseConfig(database="d", schema="s", transport="rest")  # adattare ai campi reali
    m1 = create_session("prima domanda", db, tmp_path)
    m2 = create_session("seconda domanda", db, tmp_path)
    from tht.cli.session_cmd import _list_sessions  # helper puro
    rows = _list_sessions(tmp_path)
    ids = [r["id"] for r in rows]
    assert m1.id in ids and m2.id in ids
    assert set(["id","status","question","created_at"]).issubset(rows[0].keys())
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && pytest tests/test_session_list_json.py -v Expected: FAIL — _list_sessions non esiste.

  • Step 3: Implementare helper + comandi
def _list_sessions(sessions_root: Path) -> list[dict]:
    out = []
    for d in sorted([p for p in sessions_root.iterdir() if (p / "session_manifest.yaml").exists()]):
        m = SessionManifest.from_yaml(d / "session_manifest.yaml")
        out.append({"id": m.id, "status": m.status, "question": m.question,
                    "summary": m.summary, "created_at": m.created_at.isoformat(),
                    "updated_at": m.updated_at.isoformat() if m.updated_at else None,
                    "author": m.author})
    out.sort(key=lambda r: r["created_at"], reverse=True)
    return out

@session_app.command("list")
def list_cmd(json_out: bool = typer.Option(False, "--json"), config: Path = CONFIG_OPT) -> None:
    cfg = _load_config_or_exit(config)
    rows = _list_sessions(cfg.paths.sessions)
    if json_out:
        typer.echo(json.dumps(rows, ensure_ascii=False, indent=2)); return
    for r in rows:
        typer.echo(f"{r['id']}  [{r['status']}]  {r['summary']}")

E in show_cmd aggiungere json_out: bool = typer.Option(False, "--json"); in modalità JSON stampare il manifest (model_dump(mode="json", by_alias=True)) + phase (da current_phase) + has_schema_linking.

  • Step 4: Eseguire il test (deve passare)

Run: cd harness && pytest tests/test_session_list_json.py -v Expected: PASS.

  • Step 5: Commit
git add harness/tht/cli/session_cmd.py harness/tests/test_session_list_json.py
git commit -m "feat(harness): tht session list/show --json for FE session list"

Task 8: Manifest — campi provider/model/thinking/name + opzioni di tht session new

Persistono la scelta di modello/thinking/provider e il nome, riapplicati al resume dal backend (BE-6/BE-7).

Files:

  • Modify: harness/tht/session/models.py (SessionManifest)
  • Modify: harness/tht/session/store.py (create_session)
  • Modify: harness/tht/cli/session_cmd.py (new_cmd opzioni)
  • Test: harness/tests/test_manifest_pi_fields.py

Interfaces:

  • Consumes: create_session(question, db, sessions_root, *, author=None, summary=None, provider=None, model=None, thinking=None, name=None).

  • Produces: manifest con campi opzionali provider, model, thinking, name; tht session new <q> [--provider P --model M --thinking T --name N] [--json] (con --json stampa {"id": ...} su stdout puro).

  • Step 1: Scrivere il test

# harness/tests/test_manifest_pi_fields.py
from tht.session.store import create_session
from tht.session.models import SessionManifest

def test_manifest_persists_pi_fields(tmp_path):
    from tht.config import DatabaseConfig
    db = DatabaseConfig(database="d", schema="s", transport="rest")
    m = create_session("q", db, tmp_path, provider="zai", model="glm-5.2",
                        thinking="medium", name="sessione test")
    reload = SessionManifest.from_yaml(tmp_path / m.id / "session_manifest.yaml")
    assert reload.provider == "zai" and reload.model == "glm-5.2"
    assert reload.thinking == "medium" and reload.name == "sessione test"

def test_manifest_pi_fields_optional(tmp_path):
    from tht.config import DatabaseConfig
    db = DatabaseConfig(database="d", schema="s", transport="rest")
    m = create_session("q", db, tmp_path)
    assert m.provider is None and m.model is None and m.thinking is None and m.name is None
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && pytest tests/test_manifest_pi_fields.py -v Expected: FAIL — create_session non accetta provider, ecc.

  • Step 3: Aggiungere i campi al modello e a create_session

In SessionManifest (dopo schema_version):

    provider: str | None = None
    model: str | None = None
    thinking: str | None = None
    name: str | None = None

In create_session aggiungere i parametri keyword e passarli al costruttore del manifest:

def create_session(question, db, sessions_root, *, author=None, summary=None,
                   provider=None, model=None, thinking=None, name=None):
    ...
    manifest = SessionManifest(
        id=session_id, created_at=now, question=question,
        database=db.database, schema=db.db_schema,
        author=who, summary=summary or _summarize(question),
        updated_at=now, updated_by=who, schema_version=schema_version,
        provider=provider, model=model, thinking=thinking, name=name,
    )
  • Step 4: Eseguire il test (deve passare)

Run: cd harness && pytest tests/test_manifest_pi_fields.py -v Expected: PASS (2 test).

  • Step 5: Aggiungere le opzioni a tht session new + --json
@session_app.command("new")
def new_cmd(
    question: str = typer.Argument(...),
    provider: str = typer.Option(None, "--provider"),
    model: str = typer.Option(None, "--model"),
    thinking: str = typer.Option(None, "--thinking"),
    name: str = typer.Option(None, "--name"),
    json_out: bool = typer.Option(False, "--json"),
    config: Path = CONFIG_OPT,
) -> None:
    from tht.session.store import create_session
    cfg = _load_config_or_exit(config)
    manifest = create_session(question, cfg.database, cfg.paths.sessions,
                              provider=provider, model=model, thinking=thinking, name=name)
    if json_out:
        typer.echo(json.dumps({"id": manifest.id}, ensure_ascii=False)); return
    typer.secho(f"OK: sessione creata in {session_dir(cfg, manifest.id)}", fg=typer.colors.GREEN)
    typer.echo(manifest.id)

Run: cd harness && pytest tests/test_manifest_pi_fields.py tests/test_session_list_json.py -v Expected: PASS (regressione esistente verde).

  • Step 6: Commit
git add harness/tht/session/models.py harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_manifest_pi_fields.py
git commit -m "feat(harness): manifest provider/model/thinking/name + session new options (BE-6/7)"

Task 9: .pi/settings.json — quietStartup + trust

Correttezza dello spawn RPC: niente rumore di avvio su stdout (sporcherebbe il JSONL), file project-local fidati (niente prompt di trust che appende il loop).

Files:

  • Modify: harness/.pi/settings.json
  • Test: harness/.pi/extensions/gate/__tests__/settings.test.js

Interfaces:

  • Produces: .pi/settings.json contiene almeno {"theme": "thothii-mono", "quietStartup": true}; il trust dei file project-local è documentato (verifica empirica nello spike/Task 10).

  • Step 1: Scrivere il test (forma del settings)

const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");

test("settings.json abilita quietStartup", () => {
  const s = JSON.parse(fs.readFileSync(path.join(__dirname, "../../settings.json"), "utf8"));
  assert.equal(s.quietStartup, true);
  assert.equal(s.theme, "thothii-mono");
});
  • Step 2: Eseguire il test (deve fallire)

Run: cd harness && node --test .pi/extensions/gate/__tests__/settings.test.js Expected: FAIL — quietStartup assente.

  • Step 3: Aggiornare settings.json
{
  "theme": "thothii-mono",
  "quietStartup": true
}
  • Step 4: Eseguire il test (deve passare)

Run: cd harness && node --test .pi/extensions/gate/__tests__/settings.test.js Expected: PASS.

  • Step 5: Documentare il trust + commit

Aggiungere a harness/docs/rpc-readiness-findings.md una nota: come è stato concesso il trust dei file project-local allo spawn RPC (verificato che NON compaia un prompt di trust che blocca il loop — confermato nel Task 10 / spike).

git add harness/.pi/settings.json harness/.pi/extensions/gate/__tests__/settings.test.js harness/docs/rpc-readiness-findings.md
git commit -m "chore(harness): quietStartup + project-local trust for clean RPC spawn"

Task 10: fake-pi-rpc — test-double del protocollo RPC + golden test di contratto (D10)

Asset condiviso consegnato qui per il Piano Backend: un processo che parla il protocollo JSONL su stdio, scriptabile per emettere sequenze di eventi e accettare comandi. Un golden test fissa il contratto del widget-descriptor (D10).

Files:

  • Create: harness/tests/fake_pi/fake_pi_rpc.mjs
  • Create: harness/tests/fake_pi/scripts/f1_disambiguation.json (scenario scriptato)
  • Test: harness/tests/fake_pi/test_fake_pi_contract.mjs

Interfaces:

  • Produces: fake_pi_rpc.mjs — eseguibile con node fake_pi_rpc.mjs <script.json>. Emette la shape NATIVA di Pi (corretto post-spike): per ogni descriptor in on_prompt, emette {type:"extension_ui_request", id:<descriptor.id>, method:"input", title: JSON.stringify(descriptor)}. Su {type:"extension_ui_response", id} correla per id ed emette gli eventi on_response[id] (la risposta nativa porta {id, value}). Risponde a {type:"get_available_models"} con un set fisso; eco {type:"response", command, success:true} per steer/get_state.

    • framing su stdout: JSON.stringify(evt) + "\n".
  • Consumes: lo schema widget-descriptor (architettura §4) per i descriptor di esempio.

  • Step 1: Definire lo scenario scriptato (golden)

Lo scenario tiene il descriptor come oggetto (ui_request_descriptor); è il fake a serializzarlo nel title nativo, così lo scenario resta leggibile.

// harness/tests/fake_pi/scripts/f1_disambiguation.json
{
  "on_prompt": [
    { "ui_request_descriptor": { "type": "ui_request", "id": "u1", "schema_version": 1,
        "phase": "F1_chiarimento", "title": "Disambigua",
        "widget": "select",
        "options": [ {"id":"a","label":"interpretazione A"}, {"id":"b","label":"interpretazione B"} ],
        "reserved": ["back","exit","other"] } }
  ],
  "on_response": { "u1": [ { "type": "agent_end" } ] },
  "available_models": [ {"provider":"zai","id":"glm-5.2"} ]
}
  • Step 2: Scrivere il test di contratto
// harness/tests/fake_pi/test_fake_pi_contract.mjs  — run: node --test
import test from "node:test";
import assert from "node:assert";
import { spawn } from "node:child_process";
import path from "node:path";

function drive(scriptPath, commands) {
  return new Promise((resolve) => {
    const fp = spawn("node", [path.join(import.meta.dirname, "fake_pi_rpc.mjs"), scriptPath]);
    const events = []; let buf = "";
    fp.stdout.on("data", (c) => {
      buf += c.toString("utf8");
      for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
        const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 1);
        if (line) events.push(JSON.parse(line));
      }
    });
    fp.on("exit", () => resolve(events));
    for (const cmd of commands) fp.stdin.write(JSON.stringify(cmd) + "\n");
    setTimeout(() => fp.stdin.end(), 300);
  });
}

test("on prompt emette il widget F1; on response avanza", async () => {
  const sp = path.join(import.meta.dirname, "scripts/f1_disambiguation.json");
  const events = await drive(sp, [
    { type: "prompt", message: "/nuova-domanda \"x\"" },
    { type: "extension_ui_response", id: "u1",
      value: JSON.stringify({ id: "u1", choices: ["a"], decision: { type: "concept_clarified" } }) },
  ]);
  const widget = events.find((e) => e.type === "extension_ui_request");
  assert.equal(widget.method, "input");          // shape nativa di Pi
  assert.equal(widget.id, "u1");
  const descriptor = JSON.parse(widget.title);    // il descriptor viaggia nel title
  assert.equal(descriptor.widget, "select");
  assert.equal(descriptor.id, "u1");
  assert.ok(events.some((e) => e.type === "agent_end"));
});
  • Step 3: Eseguire il test (deve fallire)

Run: cd harness && node --test tests/fake_pi/test_fake_pi_contract.mjs Expected: FAIL — fake_pi_rpc.mjs non esiste.

  • Step 4: Implementare il fake-pi-rpc
// harness/tests/fake_pi/fake_pi_rpc.mjs — scripted RPC test double (LF-only JSONL).
import fs from "node:fs";
const script = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = (evt) => process.stdout.write(JSON.stringify(evt) + "\n");

let buf = "";
process.stdin.on("data", (chunk) => {
  buf += chunk.toString("utf8");
  for (let nl; (nl = buf.indexOf("\n")) !== -1; ) {
    const line = buf.slice(0, nl).replace(/\r$/, ""); buf = buf.slice(nl + 1);
    if (!line) continue;
    let cmd; try { cmd = JSON.parse(line); } catch { continue; }
    if (cmd.type === "prompt") {
      for (const step of script.on_prompt ?? []) {
        if (step.ui_request_descriptor) {
          const d = step.ui_request_descriptor;
          out({ type: "extension_ui_request", id: d.id, method: "input", title: JSON.stringify(d) });
        } else { out(step); }  // eventi non-UI (text_delta, agent_end, …) passano tali e quali
      }
    } else if (cmd.type === "extension_ui_response") {
      for (const evt of (script.on_response ?? {})[cmd.id] ?? []) out(evt);
    } else if (cmd.type === "get_available_models") {
      out({ type: "response", command: "get_available_models", id: cmd.id, success: true,
            data: { models: script.available_models ?? [] } });
    } else if (cmd.type === "steer") {
      out({ type: "response", command: "steer", id: cmd.id, success: true });
    } else if (cmd.type === "get_state") {
      out({ type: "response", command: "get_state", id: cmd.id, success: true,
            data: { sessionId: "fake", thinkingLevel: "medium", isStreaming: false } });
    }
  }
});
process.stdin.on("end", () => process.exit(0));
  • Step 5: Eseguire il test (deve passare)

Run: cd harness && node --test tests/fake_pi/test_fake_pi_contract.mjs Expected: PASS.

  • Step 6: Validazione end-to-end con Pi reale (L2, informativo, non-CI)

Con .env + VPN, ri-eseguire node scripts/rpc_probe.mjs (Task 1) e confermare che, dopo i Task 3–5, il gate: (a) parte sul prompt, (b) emette il widget, (c) riceve la risposta e avanza. Annotare l'esito in rpc-readiness-findings.md. Questo chiude il rischio "path RPC mai testato".

  • Step 7: Commit
git add harness/tests/fake_pi/
git commit -m "test(harness): fake-pi-rpc protocol double + F1 widget contract golden (D10)"

Self-Review

Spec coverage (vs 2026-06-27-backend-design.md §7 + decisioni BE):

  • BE-5 (id fornito): Task 5 ✓
  • BE-6/BE-7 (model/thinking/provider/name nel manifest): Task 8 ✓; settings spawn (quietStartup/trust): Task 9 ✓
  • §7.1 (preview --json/--offset): Task 6 ✓
  • §7.2 (kickoff con id): Task 5 ✓
  • §7.3 (campi manifest): Task 8 ✓
  • §7.4 (settings.json): Task 9 ✓
  • §7.5 (session list/show --json): Task 7 ✓
  • §7.6 (fake-Pi condiviso): Task 10 (fake-pi-rpc) + Task 2 (fake-pi-runtime) ✓
  • Rischio "gate RPC mai testato": Task 1 (spike) + Task 3/4 (adattamento) + Task 10 Step 6 (validazione reale) ✓

Placeholder scan: Task 1 è uno spike dichiarato (osservazione, non TDD) — i suoi "step" sono azioni concrete con output atteso. Le varianti A/B del Task 4 sono entrambe specificate; la scelta è guidata dall'evidenza dello spike, non un TBD.

Type consistency: create_session(..., provider, model, thinking, name) (Task 8) coerente con i campi del manifest (Task 8) e con l'uso del backend (Piano 2). inject_limit_offset(sql, *, limit, offset) (Task 6) usato da do_run(..., offset=). _list_sessions (Task 7) ritorna le chiavi usate dal FE. Envelope {"type":"extension_ui_request","ui_request":{...}} (Task 10) coerente con l'emissione del gate (Task 4) e con ciò che il backend tradurrà (Piano 2).

Nota di sequenza: il Task 1 (spike) richiede Pi reale + VPN; se non disponibile al momento dell'esecuzione, i Task 2 e 6–9 (CI puri) possono procedere in parallelo; i Task 3–4 (adattamento gate) richiedono la decisione dello spike e vanno dopo.