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

968 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
```javascript
// 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):
```bash
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**
```bash
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**
```javascript
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**
```javascript
// 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**
```bash
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**
```javascript
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:
```javascript
// 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**
```bash
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)**
```javascript
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`:
```javascript
// 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**
```bash
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**
```javascript
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:
```javascript
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**
```bash
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)**
```python
# 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**
```python
# 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:
```python
@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.
```python
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**
```bash
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**
```python
# 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**
```python
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**
```bash
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**
```python
# 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`):
```python
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:
```python
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`**
```python
@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**
```bash
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)**
```javascript
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**
```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).
```bash
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.
```json
// 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**
```javascript
// 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**
```javascript
// 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**
```bash
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.