Files
ThothII/docs/superpowers/specs/2026-07-04-dual-mode-gate-evaluation.md
T
marcopanandClaude Opus 4.8 cc1ffb59fa docs(pi): revise dual-mode gate eval per review (P1/P2)
- present() = presentBlockingWidget: transport + no-limbo loop + response
  validation; both branches share validateUiResponse; the id invariant must be
  validated explicitly in the TUI branch (emitAndWait no longer runs there)
- TUI renderer uses numbered options + index parsing, never label mapping
  (frontend already answers with ids); artifact-gate editor is viewer-only,
  returned content ignored
- guards resolved: both TUI-only notices now ctx.mode === "tui"
- interactive-render.js as two layers (renderTuiDescriptor +
  validateSyntheticResponse); add negative test cases + a real TUI smoke

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:41:39 +02:00

15 KiB

Valutazione: doppia gestione del gate (TUI nativo interattivo + widget-descriptor RPC)

Date: 2026-07-04 Status: valutazione (nessuna implementazione — solo analisi di fattibilità) Pi runtime di riferimento: @earendil-works/pi-coding-agent 0.80.3 — versione live sul PATH dal 2026-07-04 (migrazione dal vecchio scope @mariozechner, congelato a 0.73.1; validata via model-matrix con GLM 5.2, scenari new+resume entrambi CHAINED, e uno smoke full-stack con gate F1 risposto). Tutte le citazioni types.d.ts sotto sono relative a questa versione.

Context

Domanda: è pensabile una doppia gestione del gate HITL? Interattiva con le primitive UI standard di Pi quando pi è lanciato da CLI, e quella attuale (widget-descriptor JSON verso il frontend) quando è lanciato in RPC.

Oggi il gate ha una sola via di emissione: emitAndWait → ctx.ui.input(JSON.stringify(descriptor), "") (tht-gate.js:300). In RPC il backend fa da bridge (extension_ui_request method input → SSE) e il frontend React rende i descrittori come widget veri. In interattivo, invece, ctx.ui.input mostra il JSON grezzo come prompt e pretende una risposta UiResponse in JSON digitata a mano → non usabile. Il design ha volutamente sostituito i widget TUI nativi con l'emissione descriptor — banner REWRITE punto (2), tht-gate.js:8-10: "the reviewer interaction is emitted as a widget-descriptor JSON … instead of rendered by blocking native TUI primitives (ctx.ui.select/custom)".

Verdetto: SÌ, è pensabile ed è architetturalmente supportato

Pi è già mode-aware, quindi la doppia gestione non va "inventata", va solo sfruttata:

  • ctx.mode: ExtensionMode (types.d.ts:207,212) — "tui" | "rpc" | "json" | "print", con commento dei tipi: "Use 'tui' to guard terminal-only UI such as custom components". È il discriminatore corretto per il ramo interattivo: guardare ctx.mode === "tui".
  • ctx.hasUI: boolean (types.d.ts:213) — commento reale: "Whether dialog-capable UI is available (true in TUI and RPC modes)". ⚠️ È true sia in TUI sia in RPC: quindi non distingue interattivo da RPC e non è un fallback valido per il ramo TUI — solo ctx.mode === "tui" lo fa. (Nota migrazione: il gate usa oggi if (ctx.hasUI) a tht-gate.js:319,397 con la vecchia semantica 0.73.1, dove hasUI era false in RPC. Su 0.80.3 quelle due guardie scattano anche in RPC → vedi Note implementative.)
  • ExtensionUIContext (types.d.ts:63-67) — "Each mode (interactive, RPC, print) provides its own implementation". Espone nativamente: select(title, options: string[], opts?) → Promise<string|undefined> (69), confirm(title, message, opts?) → Promise<boolean> (71), input(title, placeholder?, opts?) → Promise<string|undefined> (73), notify(message, type?) (75), editor(title, prefill?) → Promise<string|undefined> (134), e custom<T>(factory) → Promise<T> (116, componente TUI a pieno controllo). notify funziona già in entrambe le modalità.

UiResponse è un tipo INTERNO ThothII (non un export di Pi)

Tutta la logica a valle consuma un UiResponse: non è un tipo di Pi, è il contratto condiviso tra il gate e il frontend/renderer per la risposta a un descriptor. Va documentato esplicitamente (JSDoc typedef nel modulo del gate):

/**
 * @typedef {Object} UiResponse  Risposta a un widget-descriptor. Tipo INTERNO
 *   ThothII (NON esportato da Pi): contratto condiviso gate ↔ frontend/renderer.
 * @property {string}   [id]       deve combaciare con descriptor.id (invariante emitAndWait)
 * @property {string[]} [choices]  id opzione/i scelte (select: 1 elem; multiselect: N)
 * @property {string}   [choice]   forma singola, alternativa a choices
 * @property {string}   [control]  "back" | "exit" | "freetext" | "cancel"
 * @property {string}   [text]     testo libero (freetext) o input "Altro — specificare"
 */

Design proposto (una sola cucitura, riuso di tutto il resto)

L'eleganza sta nel fatto che a valle tutto consuma un UiResponse, classificato da resolveSelectOutcome / resolveConfirmOutcome / selectedChoice, con l'invariante id-match in emitAndWait (tht-gate.js:312: if (resp && resp.control !== "cancel" && resp.id === descriptor.id)). Quindi:

  • Invariati: gate/builders.js (costruzione descrittori) e i classificatori di esito. Sono il contratto condiviso tra le due modalità.
  • Unica modifica: sostituire emitAndWait(ctx, descriptor) con presentBlockingWidget(ctx, descriptor): Promise<UiResponse> mode-aware. Il confine non è solo il trasporto: è presentazione + loop no-limbo + validazione della risposta — oggi emitAndWait fa già trasporto → JSON.parse → check cancel → id-match → re-present su risposta invalida (tht-gate.js:298). Entrambi i rami confluiscono in un unico validatore condiviso validateUiResponse(descriptor, resp):
    • if (ctx.mode !== "tui") → percorso ATTUALE (RPC/json/print): ctx.ui.input(JSON.stringify(descriptor)) + JSON.parse → validateUiResponse. Nessun cambiamento nel frontend.
    • else (ctx.mode === "tui") → renderTuiDescriptor con le primitive native → sintetizza un UiResponse → stesso validateUiResponse. ⚠️ Critico: in TUI l'id-match di emitAndWait non gira più (present lo sostituisce), quindi l'invariante resp.id === descriptor.id va validato esplicitamente qui — non è più protetto in automatico.

I 3 gate bloccanti che passano da emitAndWait sono l'intera superficie da rendere: select (tht-gate.js:492), multiselect (576), artifact-gate (639). La colonna no-limbo dice cosa sintetizzare quando la primitiva nativa ritorna undefined (Esc / cancel / timeout / signal):

widget render interattivo no-limbo (nativo → undefined) UiResponse sintetizzato
select opzioni numerate (1. label … / b) Torna / e) Esci / o) Altro); parsing per indice → id, MAI per label re-present una volta; se ancora undefined → {control:"exit"} {id, choices:[optId]} o {control:"back"/"exit"/"freetext", text}
multiselect ctx.ui.custom (checkbox) oppure ctx.ui.input indicizzato (1,3,4) → dedupe + ordine stabile + rifiuto indici invalidi re-present una volta; poi {control:"exit"} {id, choices:[TUTTI gli id scelti]} — vedi nota reviewer_decide sotto
artifact-gate artefatto mostrato con ctx.ui.editor(title, testo) come viewer (il contenuto ritornato è ignorato — il gate approva/rifiuta, non modifica) + scelta approve/reject numerata re-present (advance solo su approve/reject esplicito, mai implicito) {id, choices:["approve"/"reject"]} o control

Non sono widget-descriptor (nessuna riga present() dedicata):

  • freetext non è un widget a sé: è il control:"freetext" restituito dall'opzione «Altro/Other» di select/artifact-gate → nel ramo TUI si raccoglie con ctx.ui.input dentro il render di quei due.
  • info non passa da present(): sono ctx.ui.notify(..., "info") scritti a mano (es. tht-gate.js:567, avviso F2 memoria vuota), già mode-agnostic. buildInfoRequest esiste in builders.js ma non è cablato nel gate (l'import a tht-gate.js:29-31 porta solo select/multiselect/artifact-gate) — builder morto.

Vincoli del runtime Pi verificati (0.80.3)

  • ctx.ui.select ritorna la label (string), non l'id — options: string[] → Promise<string|undefined> (types.d.ts:69). NON rimappare per label (label duplicate/localizzate collidono): opzioni numerate + parsing per indice → id. Il contratto ThothII è sugli id, e il frontend risponde già sempre con id, non label (SelectWidget:26, MultiselectWidget:66).
  • Nessuna multiselect nativa: ExtensionUIContext non la espone → custom (checkbox, livello A) o fallback a input indicizzato (livello C).
  • Dismiss / no-limbo nativo: select/confirm/input/editor ritornano undefined su cancel/Esc; ExtensionUIDialogOptions espone signal?: AbortSignal (types.d.ts:37) e timeout? per chiudere il dialogo programmaticamente. Il renderer deve gestire l'undefined esplicitamente (colonna no-limbo).
  • editor multi-linea disponibile (types.d.ts:134) — preferibile a notify per il testo dell'artefatto nel TUI.
  • (Non verificabile dai soli tipi, e comunque irrilevante: se custom sia supportato in RPC — lo usiamo solo nel ramo interattivo.)

Contratto d'estensibilità (nuovo widget kind)

Per mitigare a livello di codice il rischio "due percorsi da tenere in sync": il seam interattivo ha un default: esplicito. Un widget privo di renderer TUI dedicato non deve half-funzionare in silenzio → ctx.ui.notify("<kind> non supportato in TUI", "warning") + re-present/abort controllato. Così builders.js + classificatori restano l'unica fonte condivisa, e un kind nuovo fallisce forte in TUI finché non gli si dà un renderer, invece di derivare silenziosamente dal percorso RPC.

Due livelli di implementazione (scegliibili in seguito)

  • A — widget nativi Pi (select/confirm/editor/custom): esperienza TUI curata; il pezzo più costoso è la multiselect (Pi non ha una multiselect nativa → serve ctx.ui.custom).
  • C — render testuale + input numerato (minimo): in interattivo stampo il descrittore ben formattato (titolo/intro/opzioni numerate) e leggo un numero/lettera via ctx.ui.input, poi mappo su UiResponse. Molto meno lavoro, sufficiente "a fini di valutazione/dev". Consigliato dato lo scopo dichiarato.

Superficie di modifica (se/quando si implementa)

  • harness/.pi/extensions/tht-gate.js: introdurre present(ctx, descriptor) e sostituire le 3 chiamate a emitAndWait (reviewer_select/decide/confirm) con essa; il ramo RPC è l'attuale emitAndWait.
  • Nuovo modulo puro harness/.pi/extensions/gate/interactive-render.js a due strati: renderTuiDescriptor(descriptor) (descriptor → prompt numerato/native) e validateSyntheticResponse(descriptor, resp) (= validateUiResponse, condiviso dal ramo TUI e dal ramo RPC dopo JSON.parse). Entrambi L1-testabili come builders.js.
  • Nessun cambiamento a builders.js, ai classificatori, al backend o al frontend.

Note implementative (checklist prima di scrivere present())

  • Il commento a tht-gate.js:227 dice "usa l'API UI NATIVA di Pi (ctx.ui.input)". Quando emitAndWait viene incapsulato in present() con un ramo che usa i widget TUI nativi (non ctx.ui.input), quel commento diventa fuorviante → va riscritto insieme al codice.
  • ctx.ui.notify da razionalizzare. 6 notify nel gate; solo 319 e 397 sono guardate da ctx.hasUI, le altre 4 sono non guardate (567, 651, 824, 831). Oggi in RPC sono fire-and-forget verso il frontend; in TUI diventerebbero warning a terminale. Decidere per-notify se appartiene a entrambe le modalità — in particolare la 651 è dentro il loop di re-present del confirm gate.
  • Guardie modo-dipendenti (risolte). Entrambi i messaggi TUI-only sono ora guardati da ctx.mode === "tui": reLoop anti-limbo (tht-gate.js:322) e steering-durante-lock (tht-gate.js:400). In RPC il testo libero durante il lock resta comunque {action:"handled"} (silenziato) — cambia solo che il warning da terminale non viene più inviato al browser. Nessuna guardia ctx.hasUI residua per messaggi TUI-only.

Test (il vero guardrail contro la deriva)

  • Property test round-trip (non solo "descriptor→UiResponse"): per ogni descriptor prodotto da builders.js, il UiResponse sintetizzato da interactive-render, passato ai classificatori esistenti (resolveSelectOutcome / resolveConfirmOutcome / selectedChoice), deve produrre lo stesso {kind, ...} del UiResponse che il frontend invierebbe per lo stesso descriptor. È questo il guardrail contro la deriva tra i due percorsi, non un test di sola forma.
  • reviewer_decide (multiselect) — lettura inline: reviewer_decide non passa dai classificatori; legge resp.choices direttamente (tht-gate.js:594: opts.filter(o => (resp.choices ?? []).includes(o.id))). Il test di non-regressione deve quindi verificare che la sintesi interattiva del multiselect popoli tutti gli id scelti (non solo il primo), altrimenti in TUI si perderebbero selezioni.
  • Invariante id-match: in TUI emitAndWait non gira più → validateUiResponse deve verificare resp.id === descriptor.id esplicitamente (non basta che la sintesi imposti l'id: va validato, come faceva tht-gate.js:312).
  • Casi negativi (oltre al round-trip): id sbagliato, scelta inesistente, label duplicate, multiselect con duplicati, allow_empty=false con input vuoto, undefined ripetuto (no-limbo), control:"cancel".
  • Smoke TUI reale su Pi 0.80.3: i property test coprono la deriva del contratto ma non il comportamento delle primitive native (select/input/editor/custom) → serve almeno uno smoke interattivo vero, come già fatto per il percorso RPC.
  • RPC invariato: backend bridge + 115 test frontend + 53 test harness restano da rigirare per non-regressione; il percorso descriptor non cambia.

Trade-off e limiti (onesti)

  • Fedeltà interattiva ridotta: niente modal ricco, niente erDiagram/grafo schema-linking, niente rendering strutturato dell'artefatto — in TUI l'artefatto è testo (via editor). Va benissimo per debug/valutazione, non per la review clinica completa.
  • Multiselect: nessuna primitiva nativa → ctx.ui.custom (livello A) o fallback a input indicizzato (livello C).
  • Testabilità: il render nativo richiede il runtime TUI di Pi (difficile da unit-testare); la parte pura descriptor→UiResponse è testabile in L1 (round-trip sopra). Il percorso RPC resta invariato e già coperto.

Conclusione

Fattibile e pulito: la doppia gestione si ottiene con una sola cucitura mode-aware (present) attorno a emitAndWait, sfruttando che Pi è già mode-aware via ctx.mode (=== "tui" per il ramo interattivo; non hasUI, che su 0.80.3 è true anche in RPC) e che a valle tutto consuma un UiResponse interno. Il costo dipende dal livello di fedeltà interattiva voluto (C minimo, A curato); il guardrail è il property test round-trip. Nessun impatto su RPC/frontend.