From b739e7ddd97e3154a7db4fcc44efd606a800dbc5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:02:49 +0200 Subject: [PATCH 001/164] FE-744: Document Pi command containment evidence --- docs/architecture/pi-ui-extension-patterns.md | 148 ++++++++++++++++++ memory/CARDS.md | 118 ++++++++++++++ memory/PLAN.md | 9 +- memory/SPEC.md | 4 +- 4 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/pi-ui-extension-patterns.md create mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md new file mode 100644 index 00000000..85fdd0bd --- /dev/null +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -0,0 +1,148 @@ +# Pi UI Extension Patterns + +This memo records evidence for the `pi-ui-extension-patterns` frontier. It is intentionally evidence-tiered: source audit, raw Pi harness observations, Brunch-host proof, RPC controllability, and remaining assumptions are separate. + +## Current verdicts + +| Area | Verdict | Required before downstream work? | Evidence tier | +| --- | --- | --- | --- | +| Built-in slash autocomplete allowlist | feasible-with-cost | desirable before M5 UI polish; not enough for policy | source audit | +| Built-in exact slash execution allowlist | requires-pi-change for strict suppression | required before claiming strict product-shell containment; not required for graph-command safety if dangerous effects are blocked separately | source audit + raw RPC probe | +| Branch-flow effect blocking (`/fork`, `/clone`, `/tree`) | proven for lifecycle/API effect cancellation; residual pre-cancel UI exposure remains | required for I19-L and already partly used by Brunch | source audit + raw RPC probe | +| Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | +| RPC-visible chrome/status degradation | partially proven | informs fixture-driver expectations | raw RPC probe | + +## Evidence inventory + +- **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. +- **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. +- **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. +- **Brunch-host oracle:** not yet run for Card 1. Brunch already has Brunch TUI branch-cancellation coverage in SPEC I19-L; this card does not add a new Brunch wrapper. + +## Command inventory and containment matrix + +Policy buckets: + +- **allow/product-owned:** acceptable only when routed through Brunch-owned behavior or harmless in product shell. +- **hide:** should not appear as a default Brunch affordance. +- **block effect:** dangerous downstream effect must be cancelled even if UI exposure remains. +- **requires Pi policy:** strict command suppression needs a Pi upstream/API seam. + +| Command / source | Pi execution path | Brunch policy | Suppression seam | Blocker seam | Residual exposure | API ask | +| --- | --- | --- | --- | --- | --- | --- | +| `/settings` | `InteractiveMode.setupEditorSubmitHandler()` opens generic Pi settings | hide | autocomplete wrapper can hide suggestions | none found | exact command still opens settings in interactive mode | command policy needed for strict block | +| `/model` | interactive built-in; `Ctrl+L` also opens selector; `Ctrl+P` cycles model | hide or replace with Brunch policy | autocomplete/keybinding config can reduce visibility | no extension cancel hook; `model_select` is notification-only | exact slash and keybindings can expose model policy surface | command/keybinding policy needed if strict | +| `/scoped-models` | interactive built-in selector | hide | autocomplete wrapper | none found | exact command opens Pi selector | command policy needed | +| `/export` | interactive built-in export | hide unless Brunch adopts it deliberately | autocomplete wrapper | none found | exact command can export Pi session | command policy needed if disallowed | +| `/import` | interactive built-in import/resume flow | hide/block until Brunch validates session binding | autocomplete wrapper | no general import hook found; switch hooks may cover resulting session switch only | import UI can start before any cancel path | command policy needed | +| `/share` | interactive built-in gist share | hide/block | autocomplete wrapper | none found | exact command exposes non-Brunch sharing | command policy needed | +| `/copy` | interactive built-in clipboard copy | allow-with-low-risk or hide | autocomplete wrapper | none found | harmless but Pi-branded | optional | +| `/name` | interactive built-in session naming | hide/replace with Brunch session naming | autocomplete wrapper | none found | can mutate Pi display name outside Brunch vocabulary | command policy desirable | +| `/session` | interactive info pane | hide or allow diagnostic-only | autocomplete wrapper | none found | exposes Pi session stats/identity | optional/desirable | +| `/changelog` | interactive Pi changelog | hide | autocomplete wrapper | none found | exact command exposes Pi product surface | command policy desirable | +| `/hotkeys` | interactive Pi hotkeys | hide or replace with Brunch hotkeys | autocomplete wrapper | none found | exact command exposes Pi actions including branch actions | command policy desirable | +| `/fork` | interactive built-in branch creation after selector | hide + block effect | autocomplete wrapper | `session_before_fork` can cancel | selector/UI may appear before cancel depending path; exact command remains visible | command policy desirable; effect block available | +| `/clone` | interactive built-in branch duplication | hide + block effect | autocomplete wrapper | `session_before_fork` can cancel | command accepted before cancellation notice | command policy desirable; effect block available | +| `/tree` | interactive built-in branch navigator | hide + block effect | autocomplete wrapper | `session_before_tree` can cancel/customize | tree UI may start before cancellation path | command policy desirable; effect block available | +| `/login` / `/logout` | interactive OAuth selectors | hide unless Brunch owns provider setup | autocomplete wrapper | none found | exposes Pi provider auth surface | command policy needed if disallowed | +| `/new` | interactive session replacement | replace with Brunch same-spec coordinator flow | autocomplete wrapper | `session_before_switch` can cancel raw new-session effect | exact command still starts Pi new-session path before cancellation | command policy or Brunch command replacement needed | +| `/compact` | interactive/manual compaction | allow only after Brunch context policy exists | autocomplete wrapper | `session_before_compact` can cancel/customize | exact command starts Pi compaction UI/path before cancellation | command policy desirable | +| `/resume` | interactive session picker | hide/block unless Brunch validates binding | autocomplete wrapper | `session_before_switch` can cancel selected switch | generic picker exposure remains | command policy desirable | +| `/reload` | interactive resource reload | allow for dev, hide in product | autocomplete wrapper | none found; extension command `ctx.reload()` exists for custom reload | exact command reloads Pi resources/extensions | command policy optional for POC, desirable for product shell | +| `/quit` | interactive shutdown | allow | autocomplete wrapper not needed | n/a | Pi command name acceptable or replace later | no | +| Hidden debug/easter egg commands (`/debug`, `/arminsayshi`, `/dementedelves`) | hardcoded in `setupEditorSubmitHandler()` but not advertised in `BUILTIN_SLASH_COMMANDS` | hide/block | not in normal autocomplete inventory | none found | exact command remains callable if known | command policy needed for strict block | +| Extension commands | `AgentSession.prompt()` checks extension commands before `input` | allow only Brunch-owned names | register only Brunch commands | handler routes writes through Brunch handlers / `CommandExecutor` | built-in name collisions do not override built-ins | no if product-named | +| Prompt templates | autocomplete + expansion after `input` | hide unless Brunch owns prompt surface | settings/resources policy; `input` can handle before expansion | `input` can intercept template text before expansion | not built-in interactive command risk | optional | +| Skill commands (`/skill:name`) | autocomplete if `enableSkillCommands`; expansion after `input` | hide in Brunch POC | disable skill commands or autocomplete wrapper | `input` can intercept before expansion | generic Pi skill surface | optional if disabled | +| RPC-only session commands (`new_session`, `switch_session`, `fork`, `clone`, `compact`) | RPC command handlers | Brunch RPC should expose named product methods instead | not slash autocomplete | lifecycle hooks cancel session replacement/fork effects | raw Pi RPC is not Brunch public API | Brunch wrapper/policy, not Pi interactive policy | +| Keybindings: model select/cycle, session new/tree/fork/resume, double-Escape tree/fork | `setupKeyHandlers()` and settings | hide/block branch/model/session generic flows | keybindings config can unbind some defaults; settings can set double-Escape to `none` | lifecycle hooks for session replacement/fork/tree | keyboard route can bypass slash autocomplete visibility | command/keybinding policy desirable | + +## Autocomplete and execution findings + +### Autocomplete filtering + +`InteractiveMode.createBaseAutocompleteProvider()` builds a `CombinedAutocompleteProvider` from: + +1. `BUILTIN_SLASH_COMMANDS`, +2. prompt templates, +3. extension commands that do not conflict with built-ins, +4. skill commands when `settingsManager.getEnableSkillCommands()` is true. + +`setupAutocompleteProvider()` then applies extension-provided autocomplete wrappers. `docs/extensions.md` documents `ctx.ui.addAutocompleteProvider((current) => ...)`, including delegation to the previous provider for file/path completion and custom `#` completions. Therefore a Brunch allowlist wrapper should be able to hide disallowed slash suggestions while delegating file/path and future `#` mention completion. + +**Limit:** this is visibility suppression only. It does not change exact slash execution. + +### Exact slash execution + +`InteractiveMode.setupEditorSubmitHandler()` handles built-ins directly before normal `AgentSession.prompt()` flow. `AgentSession.prompt()` handles extension commands first, then emits `input`, then expands skills/templates. Therefore extension `input` interception cannot reliably block exact interactive built-ins such as `/settings`, `/model`, `/fork`, `/tree`, `/new`, `/compact`, `/resume`, or `/quit`, because they have already been consumed by interactive mode. + +Raw RPC probe corroborates the order split rather than replacing the source audit: + +- `/brunch-probe` extension command executed immediately and emitted RPC `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`. +- `/brunch-block-me` was not an extension command; the `input` hook handled it and skipped agent execution. +- `/settings` in RPC mode was not a built-in command; it entered normal prompt flow as user text. This confirms built-ins are interactive-only; it does not prove interactive suppression. + +### Extension command collisions + +`InteractiveMode.getBuiltInCommandConflictDiagnostics()` warns on extension commands with built-in names and skips conflicting built-in-name extension commands from autocomplete. `ExtensionRunner.resolveRegisteredCommands()` suffixes duplicate extension commands (`name:1`, `name:2`). Extension commands therefore cannot override `/model`, `/settings`, or other built-ins. Brunch commands should use product names unless Pi grows a command-policy seam. + +## Branch-flow guard evidence + +Lifecycle hooks provide effect blocking for branch/session transitions even though they do not fully suppress the generic Pi UI surface. + +- `session_before_fork` cancels `/fork`, `/clone`, and RPC `fork`/`clone` effects. +- `session_before_tree` cancels `/tree` navigation effects. +- `session_before_switch` cancels `/new`, `/resume`, RPC `new_session`, and RPC `switch_session` effects. +- `session_before_compact` can cancel/customize `/compact`, but compaction policy is not identical to branch policy. + +Raw RPC probe results with the temporary extension: + +```json +{"id":"new","type":"response","command":"new_session","success":true,"data":{"cancelled":true}} +{"id":"clone","type":"response","command":"clone","success":true,"data":{"cancelled":true}} +``` + +The same probe emitted corresponding `notify` requests (`cancel switch new`, `cancel fork/clone`). No Brunch product transcript fixture was created; the probe used `--no-session`. + +## RPC controllability observations relevant to command containment + +Raw Pi RPC success is not Brunch integration proof, but it matters for the fixture-driver oracle: + +- Extension command handlers are RPC-invocable via `prompt` for extension command names. +- `ctx.ui.setStatus()` emits RPC `extension_ui_request` with method `setStatus`. +- `ctx.ui.setWidget()` emits RPC `extension_ui_request` with method `setWidget` when the widget is a string array. +- `ctx.ui.notify()` emits RPC `extension_ui_request` with method `notify`. +- Built-in interactive slash commands are not included in RPC `prompt` handling as built-ins; Brunch must not infer interactive command safety from RPC prompt behavior. + +## Minimum Pi API ask + +Strict Brunch product-shell containment needs an upstream command/keybinding policy seam. A minimal shape would be either session/interactive-mode options or extension API: + +```ts +pi.setCommandPolicy({ + hiddenBuiltins: ["settings", "model", "scoped-models", "export", "import", "share", "fork", "clone", "tree", "login", "logout", "new", "resume"], + blockedBuiltins: ["fork", "clone", "tree", "new", "resume", "settings", "model"], + onBlockedBuiltin: async (name, ctx) => ctx.ui.notify(`/${name} is not available in Brunch`, "warning"), +}); +``` + +Equivalent launch-time option: + +```ts +allowedBuiltInCommands: ["compact", "reload", "quit"] +``` + +The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. + +## Downstream posture + +- For the POC, Brunch can plausibly proceed if it hides disallowed commands from autocomplete and blocks branch/session effects with lifecycle hooks, **provided product documentation does not claim strict built-in suppression**. +- `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. +- M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. +- A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. + +## Open evidence gaps + +- Interactive autocomplete filtering was source-proven but not visually observed in a TUI session from this API-only run. +- Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. +- Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..4cb7cc75 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,118 @@ +# Scope Cards — pi-ui-extension-patterns + +Volatile execution queue for the existing `pi-ui-extension-patterns` frontier in `memory/PLAN.md`. Delete or overwrite this file when the queue is exhausted or superseded. These cards narrow one PLAN frontier; they do not create separate Linear issues or branches. + +## Orientation + +- **Containing seam:** Pi extension/TUI UI affordance seam for Brunch's opinionated product shell; this informs M5 lenses/review-sets, M6 authority gates, and M7 turn-boundary delivery. +- **Frontier item:** `pi-ui-extension-patterns` under PLAN `Parallel / Low-conflict`; active implementation should use one frontier-level Linear issue/Graphite branch, not one branch per card. +- **Volatile state:** `docs/architecture/pi-ui-extension-patterns.md` now holds Card 1 command-containment evidence; `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` still holds expanded future-affordance inventory until this queue is exhausted. +- **Main open risk:** Strict built-in command suppression requires a Pi command-policy API; Card 2 must still prove whether Brunch-owned chrome makes the shell feel product-owned despite that residual exposure, while preserving RPC degradation facts. + +Frontier-level obligations to preserve throughout this queue: + +- Brunch hides Pi's generic extension surface from users rather than becoming a configurable Pi shell. +- Brunch-controlled flows preserve linear transcript policy (`I19-L`) and must not introduce `/tree`, `/fork`, `/clone`, branch adaptation, or parallel chat/turn state. +- Slash commands, action affordances, and future writes route through Brunch-owned handlers/`CommandExecutor`; prototype UI state must not become a bypass path. +- Establishment-offer rendering remains orientation-first and user-invoked when expanded, not a default exhaustive lens menu. +- Evidence must distinguish source-audit findings, raw Pi-harness observations, Brunch-host observations, and assumptions. + +## Queue + +### Card 1 — status: done + +## Full scope card — Command containment feasibility + +### Target Behavior + +A command-containment matrix classifies Pi interactive commands by Brunch policy, suppression seam, blocker seam, residual exposure, and required API ask with supporting evidence. + +### Boundary Crossings + +```text +→ Pi docs/source audit for commands, autocomplete, input events, lifecycle hooks, shortcuts, and RPC commands +→ scratch Pi extension or Brunch-internal probe for autocomplete and execution interception +→ branch-policy/effect-blocking checks for `/fork`, `/clone`, `/tree`, `/new`, `/resume`, and `/compact` +→ feasibility matrix in the final Pi UI extension memo or provisional artifact +``` + +### Risks and Assumptions + +- RISK: Autocomplete suppression may hide commands while exact slash execution still exposes off-brand Pi UI → MITIGATION: score visibility suppression, effect blocking, and product-surface containment separately. +- RISK: Hidden interactive commands or shortcuts bypass the advertised `BUILTIN_SLASH_COMMANDS` inventory → MITIGATION: audit `InteractiveMode.setupEditorSubmitHandler`, keybindings, and RPC command docs in addition to `slash-commands.ts`. +- RISK: Lifecycle hooks block dangerous effects only after Pi UI has already started → MITIGATION: record pre-cancel exposure as residual product risk rather than calling the command “blocked.” +- ASSUMPTION: “Hide from autocomplete plus block dangerous effects” may be sufficient for the POC if strict command-policy hooks are unavailable → VALIDATE: user/product review of the matrix verdict before downstream UI work treats this as settled → memory/SPEC.md §Open Assumptions A18-L. + +### Acceptance Criteria + +✓ Command inventory — advertised built-ins, hidden interactive commands, relevant keybindings, extension commands, prompt/skill commands, and RPC-only session commands are classified. +✓ Autocomplete probe — an allowlist wrapper either demonstrates filtered slash suggestions while preserving file/path and future `#` completion behavior, or records why the seam cannot do so. +✓ Execution probe — extension `input`, lifecycle hooks, command collision behavior, settings knobs, and custom-editor interception are tested or source-proven against representative allowed/disallowed commands. +✓ Branch-flow guard — `/fork`, `/clone`, and `/tree` effects remain blocked or explicitly fail-fast in any prototype path, with no branchy Brunch transcript fixture created. +✓ API ask — if strict suppression is not feasible, the memo contains a minimal Pi command-policy API request and marks whether it is required before M5/M6/M7 or merely desirable. + +### Verification Approach + +- Inner: static/source oracle plus `npm run fix` for committed artifacts — proves the inventory and docs/probe code stay coherent with repo style. +- Middle: scripted or manual probe runbook — proves advertised suppression/blocking outcomes for representative commands and records exact Pi version/source paths. +- Outer: product-shell review checklist — decides whether residual built-in exposure is acceptable for the POC or requires a Pi upstream/API change. + +### Cross-cutting obligations + +- Preserve `I19-L`: no prototype may create or normalize Pi branches as Brunch product behavior. +- Do not treat extension command collision as an override mechanism; Brunch commands should be product-named unless Pi grows command policy. +- Keep command policy separate from `CommandExecutor` mutation policy: command containment gates product shell exposure; `CommandExecutor` still owns graph/product writes. +- Record evidence tiers explicitly: source audit vs raw Pi harness vs Brunch host vs assumption. + +--- + +### Card 2 — status: next + +## Full scope card — Dynamic Brunch chrome proof + +### Target Behavior + +A Brunch-owned chrome renderer updates Pi TUI header, footer, status, and widgets from one product-state snapshot with documented idle, streaming, reload, and RPC-degradation behavior. + +### Boundary Crossings + +```text +→ Brunch chrome/product-state snapshot fixture +→ Brunch-owned renderer wrapper over Pi `ExtensionUIContext` +→ Pi TUI chrome seams: `setHeader`, `setFooter`, `setStatus`, `setWidget`, optional `setWorkingIndicator` +→ raw Pi harness and/or Brunch TUI host demo +→ feasibility matrix entry and runbook evidence +``` + +### Risks and Assumptions + +- RISK: Chrome update calls scattered across probes become de facto architecture → MITIGATION: centralize in a named wrapper/prototype API such as `renderBrunchChrome(ctx, state)` before downstream cards call raw Pi UI methods. +- RISK: Dynamic updates work while idle but corrupt input or visual state during streaming → MITIGATION: simulate observer/reviewer queue changes during both idle and streaming states. +- RISK: Reload/session replacement loses chrome state in a confusing way → MITIGATION: either reconstruct from durable/product state on `session_start` or document deliberate reset semantics. +- RISK: RPC behavior differs from TUI behavior → MITIGATION: record that header/footer/custom components are TUI-only while status/widget string updates have RPC fire-and-forget parity. +- ASSUMPTION: Strong chrome replacement is enough for Brunch to feel product-owned even if some Pi built-ins remain technically callable → VALIDATE: product-shell review after Card 1 and Card 2 evidence are both present → memory/SPEC.md §Open Assumptions A10-L. + +### Acceptance Criteria + +✓ Chrome wrapper — one Brunch-named wrapper/prototype owns calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` for the demo. +✓ State coverage — demo state includes cwd, selected spec, session, phase/stage, active lens or “none,” coherence verdict, observer/reviewer/reconciler status, reconciliation-need count, and latest establishment-offer summary when present. +✓ Dynamic behavior — evidence records update behavior while idle, during assistant streaming, after `/reload`, and after session replacement or selected-session reopen where applicable. +✓ Styling behavior — the demo proves color/glyph styling is legible in narrow terminals and does not depend on raw Pi branding/footer data as the primary product surface. +✓ RPC degradation — memo records which chrome calls produce RPC `extension_ui_request` events and which are no-ops, so fixture-driver expectations do not assume TUI-only behavior. + +### Verification Approach + +- Inner: formatter/unit oracle for pure chrome-state formatting plus `npm run fix` — proves the wrapper’s deterministic string/state mapping. +- Middle: runbook oracle against a scratch/raw Pi harness or Brunch TUI host — proves idle/streaming/reload/session-replacement observations with captured notes or logs. +- Outer: manual visual walkthrough — judges whether the shell reads as Brunch-owned and whether establishment-offer chrome stays orientation-first. + +### Cross-cutting obligations + +- Chrome state is projection state over canonical Brunch/session facts, not a new store or authority layer. +- Establishment-offer rendering remains ambient orientation; expanded offer inspection must remain user-invoked. +- Do not introduce graph/product writes from chrome controls in this card; any future action affordance must route through Brunch handlers/`CommandExecutor`. +- Keep raw Pi UI calls behind the wrapper so M5/M6/M7 can reuse product-named affordances rather than Pi primitives directly. + +## Queue stop rule + +Stop after these two cards before scoping review-set overlays or structured prompt components if Card 1 concludes strict command containment needs a Pi upstream/API change, or if Card 2 shows dynamic chrome cannot be reconstructed safely across reload/session replacement. Otherwise the next scoping pass can prepare structured prompt and review-set interaction cards using the evidence from this queue. diff --git a/memory/PLAN.md b/memory/PLAN.md index 815473fa..08111ad2 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,7 +30,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. +- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Command-containment evidence has landed: strict exact built-in suppression requires a Pi command-policy API, while POC safety can rely on autocomplete hiding plus branch/session effect blocking if product review accepts residual exposure. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. ### Horizon @@ -218,16 +218,17 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### pi-ui-extension-patterns - **Name:** Prove Pi extension patterns for Brunch UI affordances -- **Linear:** unassigned +- **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) +- **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** not-started +- **Status:** in-progress (Card 1 command-containment feasibility evidence landed; Card 2 dynamic chrome proof is next) - **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs for elicitation-lens and review-set flows without forking Pi or building a parallel rendering substrate. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec switching and entity selection; ambient rendering of the latest `brunch.establishment_offer`. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. - **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection). Outer — manual TUI walkthrough validating visual quality and interaction feel; comparative walkthrough between scripted-driver and manual paths to record controllability cost. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R20, R21 / D2-L, D11-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L / I18-L, I19-L / A10-L, A14-L, A17-L -- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md); new memo to be created during the spike. +- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index be240a57..dc4deeff 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -107,6 +107,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A15-L | Establishment hints as transcript-native custom entries (`brunch.establishment_offer`) provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms lens offers are reconstructable from transcript; chrome region renders ambient affordances from the latest such entry. | | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — observer/reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | | A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | +| A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L | `pi-ui-extension-patterns` product-shell review after command-containment and chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | ### Active Decisions @@ -114,6 +115,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. +- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. #### Data model & vocabulary @@ -183,7 +185,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | | I17-L | Every generative-lens proposal entry (`brunch.review_set_proposal`) declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and explicit grounding-bundle coverage for the four grounding anchors, with the status consistent with that coverage at proposal time; UI renderings honor this status as a presentation contract. | planned (M5+ proposal-entry schema test; fixture asserts status under thin and rich grounding) | D30-L; A14-L | | I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; observer-job and reviewer-job routing filters on this field. | planned (M5+ observer/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | -| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`) | D24-L, D6-L, D11-L, D13-L | +| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict) | D24-L, D6-L, D11-L, D13-L, D34-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | From ff11a43a6cf7fe0aeba81809d71f3942ae3f9e8d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:10:10 +0200 Subject: [PATCH 002/164] FE-744: Prove dynamic Brunch chrome wrapper --- docs/architecture/pi-ui-extension-patterns.md | 37 +++++- memory/CARDS.md | 118 ------------------ memory/PLAN.md | 8 +- memory/SPEC.md | 7 +- src/brunch-tui.test.ts | 109 ++++++++++++++-- src/brunch-tui.ts | 116 +++++++++++++++-- 6 files changed, 252 insertions(+), 143 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 85fdd0bd..ee42fbf0 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -10,14 +10,17 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Built-in exact slash execution allowlist | requires-pi-change for strict suppression | required before claiming strict product-shell containment; not required for graph-command safety if dangerous effects are blocked separately | source audit + raw RPC probe | | Branch-flow effect blocking (`/fork`, `/clone`, `/tree`) | proven for lifecycle/API effect cancellation; residual pre-cancel UI exposure remains | required for I19-L and already partly used by Brunch | source audit + raw RPC probe | | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | -| RPC-visible chrome/status degradation | partially proven | informs fixture-driver expectations | raw RPC probe | +| RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | +| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | ## Evidence inventory - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** not yet run for Card 1. Brunch already has Brunch TUI branch-cancellation coverage in SPEC I19-L; this card does not add a new Brunch wrapper. +- **Brunch-host oracle:** Card 2 adds `renderBrunchChrome(ctx.ui, state)` in `src/brunch-tui.ts`, with tests proving one Brunch-owned wrapper drives `setHeader`, `setFooter`, `setStatus`, `setWidget`, optional `setWorkingIndicator`, and terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`. +- **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. +- **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. ## Command inventory and containment matrix @@ -104,14 +107,39 @@ Raw RPC probe results with the temporary extension: The same probe emitted corresponding `notify` requests (`cancel switch new`, `cancel fork/clone`). No Brunch product transcript fixture was created; the probe used `--no-session`. -## RPC controllability observations relevant to command containment +## Dynamic Brunch chrome proof + +Card 2 adds a product-named wrapper, `renderBrunchChrome(ctx.ui, state)`, rather than letting downstream affordance probes scatter raw Pi UI calls. The wrapper treats chrome as projection state over canonical Brunch/session facts and renders: + +- cwd, selected spec, and session label/id; +- phase, stage, chat mode, and streaming state; +- active lens or `none`; +- coherence verdict and reconciliation-need count; +- observer, reviewer, and reconciler status; +- latest establishment-offer summary or `offer: none`. + +The wrapper currently uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`, `●`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer factories render in TUI; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. + +Observed behavior: + +| Scenario | Result | Evidence | +| --- | --- | --- | +| Idle TUI mount | Header, footer, status, widget, and title are called from one snapshot; raw TUI transcript shows Brunch header/footer/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | +| Streaming/progress update | Wrapper formats streaming/worker state deterministically; raw RPC extension command updates status/widget to `stage: streaming`, `lens: problem-framing`, `needs: 3`. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | +| `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. This is safe for same-spec coordinator flows but does not authorize raw Pi session switching. | `src/brunch-tui.test.ts` | +| RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | + +## RPC controllability observations relevant to command containment and chrome Raw Pi RPC success is not Brunch integration proof, but it matters for the fixture-driver oracle: - Extension command handlers are RPC-invocable via `prompt` for extension command names. - `ctx.ui.setStatus()` emits RPC `extension_ui_request` with method `setStatus`. - `ctx.ui.setWidget()` emits RPC `extension_ui_request` with method `setWidget` when the widget is a string array. +- `ctx.ui.setTitle()` emits RPC `extension_ui_request` with method `setTitle`. - `ctx.ui.notify()` emits RPC `extension_ui_request` with method `notify`. +- `ctx.ui.setHeader()`, `ctx.ui.setFooter()`, and `ctx.ui.setWorkingIndicator()` are TUI-only in current Pi RPC mode and should be treated as no-ops for fixture-driver expectations. - Built-in interactive slash commands are not included in RPC `prompt` handling as built-ins; Brunch must not infer interactive command safety from RPC prompt behavior. ## Minimum Pi API ask @@ -137,8 +165,10 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Downstream posture - For the POC, Brunch can plausibly proceed if it hides disallowed commands from autocomplete and blocks branch/session effects with lifecycle hooks, **provided product documentation does not claim strict built-in suppression**. +- Dynamic Brunch chrome is strong enough to make the primary idle/working TUI surface read as Brunch-owned in a local proof, but exact built-in commands remain a residual shell-containment risk for product review. - `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. - M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. +- M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. - A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. ## Open evidence gaps @@ -146,3 +176,4 @@ The policy must run before interactive-mode built-in dispatch and before autocom - Interactive autocomplete filtering was source-proven but not visually observed in a TUI session from this API-only run. - Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. +- Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; a full Brunch-host manual walkthrough remains useful before product signoff because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data. diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 4cb7cc75..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,118 +0,0 @@ -# Scope Cards — pi-ui-extension-patterns - -Volatile execution queue for the existing `pi-ui-extension-patterns` frontier in `memory/PLAN.md`. Delete or overwrite this file when the queue is exhausted or superseded. These cards narrow one PLAN frontier; they do not create separate Linear issues or branches. - -## Orientation - -- **Containing seam:** Pi extension/TUI UI affordance seam for Brunch's opinionated product shell; this informs M5 lenses/review-sets, M6 authority gates, and M7 turn-boundary delivery. -- **Frontier item:** `pi-ui-extension-patterns` under PLAN `Parallel / Low-conflict`; active implementation should use one frontier-level Linear issue/Graphite branch, not one branch per card. -- **Volatile state:** `docs/architecture/pi-ui-extension-patterns.md` now holds Card 1 command-containment evidence; `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` still holds expanded future-affordance inventory until this queue is exhausted. -- **Main open risk:** Strict built-in command suppression requires a Pi command-policy API; Card 2 must still prove whether Brunch-owned chrome makes the shell feel product-owned despite that residual exposure, while preserving RPC degradation facts. - -Frontier-level obligations to preserve throughout this queue: - -- Brunch hides Pi's generic extension surface from users rather than becoming a configurable Pi shell. -- Brunch-controlled flows preserve linear transcript policy (`I19-L`) and must not introduce `/tree`, `/fork`, `/clone`, branch adaptation, or parallel chat/turn state. -- Slash commands, action affordances, and future writes route through Brunch-owned handlers/`CommandExecutor`; prototype UI state must not become a bypass path. -- Establishment-offer rendering remains orientation-first and user-invoked when expanded, not a default exhaustive lens menu. -- Evidence must distinguish source-audit findings, raw Pi-harness observations, Brunch-host observations, and assumptions. - -## Queue - -### Card 1 — status: done - -## Full scope card — Command containment feasibility - -### Target Behavior - -A command-containment matrix classifies Pi interactive commands by Brunch policy, suppression seam, blocker seam, residual exposure, and required API ask with supporting evidence. - -### Boundary Crossings - -```text -→ Pi docs/source audit for commands, autocomplete, input events, lifecycle hooks, shortcuts, and RPC commands -→ scratch Pi extension or Brunch-internal probe for autocomplete and execution interception -→ branch-policy/effect-blocking checks for `/fork`, `/clone`, `/tree`, `/new`, `/resume`, and `/compact` -→ feasibility matrix in the final Pi UI extension memo or provisional artifact -``` - -### Risks and Assumptions - -- RISK: Autocomplete suppression may hide commands while exact slash execution still exposes off-brand Pi UI → MITIGATION: score visibility suppression, effect blocking, and product-surface containment separately. -- RISK: Hidden interactive commands or shortcuts bypass the advertised `BUILTIN_SLASH_COMMANDS` inventory → MITIGATION: audit `InteractiveMode.setupEditorSubmitHandler`, keybindings, and RPC command docs in addition to `slash-commands.ts`. -- RISK: Lifecycle hooks block dangerous effects only after Pi UI has already started → MITIGATION: record pre-cancel exposure as residual product risk rather than calling the command “blocked.” -- ASSUMPTION: “Hide from autocomplete plus block dangerous effects” may be sufficient for the POC if strict command-policy hooks are unavailable → VALIDATE: user/product review of the matrix verdict before downstream UI work treats this as settled → memory/SPEC.md §Open Assumptions A18-L. - -### Acceptance Criteria - -✓ Command inventory — advertised built-ins, hidden interactive commands, relevant keybindings, extension commands, prompt/skill commands, and RPC-only session commands are classified. -✓ Autocomplete probe — an allowlist wrapper either demonstrates filtered slash suggestions while preserving file/path and future `#` completion behavior, or records why the seam cannot do so. -✓ Execution probe — extension `input`, lifecycle hooks, command collision behavior, settings knobs, and custom-editor interception are tested or source-proven against representative allowed/disallowed commands. -✓ Branch-flow guard — `/fork`, `/clone`, and `/tree` effects remain blocked or explicitly fail-fast in any prototype path, with no branchy Brunch transcript fixture created. -✓ API ask — if strict suppression is not feasible, the memo contains a minimal Pi command-policy API request and marks whether it is required before M5/M6/M7 or merely desirable. - -### Verification Approach - -- Inner: static/source oracle plus `npm run fix` for committed artifacts — proves the inventory and docs/probe code stay coherent with repo style. -- Middle: scripted or manual probe runbook — proves advertised suppression/blocking outcomes for representative commands and records exact Pi version/source paths. -- Outer: product-shell review checklist — decides whether residual built-in exposure is acceptable for the POC or requires a Pi upstream/API change. - -### Cross-cutting obligations - -- Preserve `I19-L`: no prototype may create or normalize Pi branches as Brunch product behavior. -- Do not treat extension command collision as an override mechanism; Brunch commands should be product-named unless Pi grows command policy. -- Keep command policy separate from `CommandExecutor` mutation policy: command containment gates product shell exposure; `CommandExecutor` still owns graph/product writes. -- Record evidence tiers explicitly: source audit vs raw Pi harness vs Brunch host vs assumption. - ---- - -### Card 2 — status: next - -## Full scope card — Dynamic Brunch chrome proof - -### Target Behavior - -A Brunch-owned chrome renderer updates Pi TUI header, footer, status, and widgets from one product-state snapshot with documented idle, streaming, reload, and RPC-degradation behavior. - -### Boundary Crossings - -```text -→ Brunch chrome/product-state snapshot fixture -→ Brunch-owned renderer wrapper over Pi `ExtensionUIContext` -→ Pi TUI chrome seams: `setHeader`, `setFooter`, `setStatus`, `setWidget`, optional `setWorkingIndicator` -→ raw Pi harness and/or Brunch TUI host demo -→ feasibility matrix entry and runbook evidence -``` - -### Risks and Assumptions - -- RISK: Chrome update calls scattered across probes become de facto architecture → MITIGATION: centralize in a named wrapper/prototype API such as `renderBrunchChrome(ctx, state)` before downstream cards call raw Pi UI methods. -- RISK: Dynamic updates work while idle but corrupt input or visual state during streaming → MITIGATION: simulate observer/reviewer queue changes during both idle and streaming states. -- RISK: Reload/session replacement loses chrome state in a confusing way → MITIGATION: either reconstruct from durable/product state on `session_start` or document deliberate reset semantics. -- RISK: RPC behavior differs from TUI behavior → MITIGATION: record that header/footer/custom components are TUI-only while status/widget string updates have RPC fire-and-forget parity. -- ASSUMPTION: Strong chrome replacement is enough for Brunch to feel product-owned even if some Pi built-ins remain technically callable → VALIDATE: product-shell review after Card 1 and Card 2 evidence are both present → memory/SPEC.md §Open Assumptions A10-L. - -### Acceptance Criteria - -✓ Chrome wrapper — one Brunch-named wrapper/prototype owns calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` for the demo. -✓ State coverage — demo state includes cwd, selected spec, session, phase/stage, active lens or “none,” coherence verdict, observer/reviewer/reconciler status, reconciliation-need count, and latest establishment-offer summary when present. -✓ Dynamic behavior — evidence records update behavior while idle, during assistant streaming, after `/reload`, and after session replacement or selected-session reopen where applicable. -✓ Styling behavior — the demo proves color/glyph styling is legible in narrow terminals and does not depend on raw Pi branding/footer data as the primary product surface. -✓ RPC degradation — memo records which chrome calls produce RPC `extension_ui_request` events and which are no-ops, so fixture-driver expectations do not assume TUI-only behavior. - -### Verification Approach - -- Inner: formatter/unit oracle for pure chrome-state formatting plus `npm run fix` — proves the wrapper’s deterministic string/state mapping. -- Middle: runbook oracle against a scratch/raw Pi harness or Brunch TUI host — proves idle/streaming/reload/session-replacement observations with captured notes or logs. -- Outer: manual visual walkthrough — judges whether the shell reads as Brunch-owned and whether establishment-offer chrome stays orientation-first. - -### Cross-cutting obligations - -- Chrome state is projection state over canonical Brunch/session facts, not a new store or authority layer. -- Establishment-offer rendering remains ambient orientation; expanded offer inspection must remain user-invoked. -- Do not introduce graph/product writes from chrome controls in this card; any future action affordance must route through Brunch handlers/`CommandExecutor`. -- Keep raw Pi UI calls behind the wrapper so M5/M6/M7 can reuse product-named affordances rather than Pi primitives directly. - -## Queue stop rule - -Stop after these two cards before scoping review-set overlays or structured prompt components if Card 1 concludes strict command containment needs a Pi upstream/API change, or if Card 2 shows dynamic chrome cannot be reconstructed safely across reload/session replacement. Otherwise the next scoping pass can prepare structured prompt and review-set interaction cards using the evidence from this queue. diff --git a/memory/PLAN.md b/memory/PLAN.md index 08111ad2..031f7fec 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,7 +30,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Command-containment evidence has landed: strict exact built-in suppression requires a Pi command-policy API, while POC safety can rely on autocomplete hiding plus branch/session effect blocking if product review accepts residual exposure. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. +- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Command-containment evidence has landed: strict exact built-in suppression requires a Pi command-policy API, while POC safety can rely on autocomplete hiding plus branch/session effect blocking if product review accepts residual exposure. Dynamic chrome evidence has also landed: a Brunch wrapper can own header/footer/status/widget projection, with RPC degradation limited to status/widget/title events. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. ### Horizon @@ -221,13 +221,13 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (Card 1 command-containment feasibility evidence landed; Card 2 dynamic chrome proof is next) +- **Status:** in-progress (command-containment and dynamic chrome proofs landed; next scoping pass should decide whether to continue into structured prompts/review-set overlays or pause for product-shell review of residual built-in command exposure) - **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs for elicitation-lens and review-set flows without forking Pi or building a parallel rendering substrate. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec switching and entity selection; ambient rendering of the latest `brunch.establishment_offer`. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. - **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection). Outer — manual TUI walkthrough validating visual quality and interaction feel; comparative walkthrough between scripted-driver and manual paths to record controllability cost. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R20, R21 / D2-L, D11-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L / I18-L, I19-L / A10-L, A14-L, A17-L +- **Traceability:** R4, R14, R16, R20, R21 / D2-L, D11-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D34-L, D35-L / I18-L, I19-L / A10-L, A14-L, A17-L, A18-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index dc4deeff..e3dbfed9 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -100,14 +100,14 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Fixture runs across briefs #1–#7: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | -| A10-L | A persistent TUI chrome region showing cwd / spec / phase / chat-mode can be added on top of `pi-tui`'s root layout without modifying pi. | medium | open | D2-L | M0 — walking skeleton attempts to mount the chrome; escalates to a pi upstream issue only if blocked. | +| A10-L | A persistent TUI chrome region showing cwd / spec / phase / chat-mode can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | A durable observer-job queue keyed by session id and elicitation-exchange entry range can recover async extraction after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | M5: observer extraction tests exercise restart/idempotence once graph writes exist. | | A14-L | LLM elicitor agents can reliably produce graph-structurally-legal intent-graph proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation) for generative lenses. | medium | open | D27-L | Fixture replay across briefs that exercise `propose-scenarios-with-tradeoffs`-shaped lenses; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | | A15-L | Establishment hints as transcript-native custom entries (`brunch.establishment_offer`) provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms lens offers are reconstructable from transcript; chrome region renders ambient affordances from the latest such entry. | | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — observer/reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | | A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | -| A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L | `pi-ui-extension-patterns` product-shell review after command-containment and chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | +| A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L, D35-L | `pi-ui-extension-patterns` product-shell review after command-containment and dynamic Brunch chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | ### Active Decisions @@ -116,6 +116,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs. #### Data model & vocabulary @@ -185,7 +186,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | | I17-L | Every generative-lens proposal entry (`brunch.review_set_proposal`) declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and explicit grounding-bundle coverage for the four grounding anchors, with the status consistent with that coverage at proposal time; UI renderings honor this status as a presentation contract. | planned (M5+ proposal-entry schema test; fixture asserts status under thin and rich grounding) | D30-L; A14-L | | I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; observer-job and reviewer-job routing filters on this field. | planned (M5+ observer/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | -| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict) | D24-L, D6-L, D11-L, D13-L, D34-L | +| I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 53181275..0b1b061c 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -12,7 +12,10 @@ import { import { createBrunchChromeExtension, + formatBrunchChromeFooterLines, + formatBrunchChromeHeaderLines, formatChromeWidgetLines, + renderBrunchChrome, runBrunchTui, } from "./brunch-tui.js" import { verifyWorkspaceSessionStores } from "./workspace-session-coordinator.js" @@ -44,18 +47,97 @@ describe("Brunch TUI boot", () => { } }) - it("passes coordinator chrome state to the persistent chrome widget", async () => { - const lines = formatChromeWidgetLines({ + it("formats Brunch chrome from one product-state snapshot", async () => { + const state = { cwd: "/tmp/project", spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + stage: "observer-review" as const, + chatMode: "responding-to-elicitation" as const, + activeLens: "problem-framing", + coherenceVerdict: "needs_review" as const, + observerStatus: "running" as const, + reviewerStatus: "queued" as const, + reconcilerStatus: "idle" as const, + reconciliationNeedCount: 3, + latestEstablishmentOfferSummary: + "Recommended lens: problem-framing; missing constraints.", + streaming: true, + } + + expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( + "Spec One", + ) + expect(formatChromeWidgetLines(state).join("\n")).toContain( + "lens: problem-framing", + ) + expect(formatChromeWidgetLines(state).join("\n")).toContain("needs: 3") + expect(formatBrunchChromeFooterLines(state).join("\n")).toContain( + "observer: running", + ) + expect(formatBrunchChromeFooterLines(state).join("\n")).toContain( + "offer: Recommended lens: problem-framing; missing constraints.", + ) + }) + + it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { + const calls: FakeUiCall[] = [] + const ui: FakeExtensionUi = { + setHeader: (...args: unknown[]) => + calls.push({ method: "setHeader", args }), + setFooter: (...args: unknown[]) => + calls.push({ method: "setFooter", args }), + setStatus: (...args: unknown[]) => + calls.push({ method: "setStatus", args }), + setWidget: (...args: unknown[]) => + calls.push({ method: "setWidget", args }), + setWorkingIndicator: (...args: unknown[]) => + calls.push({ method: "setWorkingIndicator", args }), + setTitle: (...args: unknown[]) => + calls.push({ method: "setTitle", args }), + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome(ui, { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1" }, phase: "elicitation", + stage: "idle", chatMode: "responding-to-elicitation", + activeLens: null, + coherenceVerdict: "coherent", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + streaming: false, }) - expect(lines.join("\n")).toContain("cwd: /tmp/project") - expect(lines.join("\n")).toContain("spec: Spec One") - expect(lines.join("\n")).toContain("phase: elicitation") - expect(lines.join("\n")).toContain("chat: responding-to-elicitation") + expect(calls.map((call) => call.method)).toEqual([ + "setHeader", + "setFooter", + "setStatus", + "setWidget", + "setWorkingIndicator", + "setTitle", + ]) + expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ + "brunch.chrome", + "Brunch · elicitation · no active lens · coherent · needs 0", + ]) + expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ + "brunch.chrome", + [ + "cwd: /tmp/project", + "spec: Spec One session: session-1 stage: idle", + "lens: none coherence: coherent needs: 0", + "observer: idle reviewer: idle reconciler: idle", + ], + { placement: "aboveEditor" }, + ]) }) it("binds replacement sessions through internal session boundary events", async () => { @@ -64,11 +146,15 @@ describe("Brunch TUI boot", () => { const boundSessionIds: string[] = [] const widgets = new Map() const ui: FakeExtensionUi = { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, setWidget: (key: string, content: unknown) => { if (isStringArray(content)) { widgets.set(key, content) } }, + setWorkingIndicator: (_options) => {}, setTitle: (_title: string) => {}, notify: (_message: string, _type?: "info" | "warning" | "error") => {}, } @@ -139,7 +225,11 @@ describe("Brunch TUI boot", () => { const ctx: FakeExtensionContext = { sessionManager: manager, ui: { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, setWidget: (_key: string, _content: unknown) => {}, + setWorkingIndicator: (_options) => {}, setTitle: (_title: string) => {}, notify: (message, type) => notifications.push({ message, type }), }, @@ -205,11 +295,16 @@ describe("Brunch TUI boot", () => { }) }) +interface FakeUiCall { + method: string + args: unknown[] +} + type FakeExtensionContext = Pick & { ui: FakeExtensionUi } -type FakeExtensionUi = Pick +type FakeExtensionUi = Pick function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string") diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index b818ea81..ec40dc31 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -10,6 +10,7 @@ import { SessionManager, type CreateAgentSessionRuntimeFactory, type ExtensionFactory, + type ExtensionUIContext, } from "@earendil-works/pi-coding-agent" import { @@ -34,6 +35,28 @@ export interface BrunchTuiOptions { export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." +export type BrunchChromeStage = "idle" | "streaming" | "observer-review" +export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" +export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" + +export interface BrunchChromeState extends WorkspaceSessionChromeState { + session: { + id: string + label?: string + } + stage: BrunchChromeStage + activeLens: string | null + coherenceVerdict: BrunchChromeCoherenceVerdict + observerStatus: BrunchChromeWorkerStatus + reviewerStatus: BrunchChromeWorkerStatus + reconcilerStatus: BrunchChromeWorkerStatus + reconciliationNeedCount: number + latestEstablishmentOfferSummary: string | null + streaming: boolean +} + +type BrunchChromeInputState = WorkspaceSessionChromeState | BrunchChromeState + export async function runBrunchTui( options: BrunchTuiOptions = {}, ): Promise { @@ -60,16 +83,96 @@ export async function runBrunchTui( }) } +export function formatBrunchChromeHeaderLines( + state: BrunchChromeInputState, +): string[] { + const chrome = normalizeBrunchChromeState(state) + return [ + "brunch specification workspace", + `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, + ] +} + export function formatChromeWidgetLines( - chrome: WorkspaceSessionChromeState, + state: BrunchChromeInputState, ): string[] { - const spec = chrome.spec ? chrome.spec.title : "" + const chrome = normalizeBrunchChromeState(state) return [ - `brunch cwd: ${chrome.cwd}`, - ` spec: ${spec} phase: ${chrome.phase} chat: ${chrome.chatMode}`, + `cwd: ${chrome.cwd}`, + `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, + `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, + `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, ] } +export function formatBrunchChromeFooterLines( + state: BrunchChromeInputState, +): string[] { + const chrome = normalizeBrunchChromeState(state) + const offer = chrome.latestEstablishmentOfferSummary + ? `offer: ${chrome.latestEstablishmentOfferSummary}` + : "offer: none" + return [ + `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, + offer, + ] +} + +export function renderBrunchChrome( + ui: Pick, + state: BrunchChromeInputState, +): void { + const chrome = normalizeBrunchChromeState(state) + ui.setHeader(() => ({ + render: () => formatBrunchChromeHeaderLines(chrome), + invalidate: () => {}, + })) + ui.setFooter(() => ({ + render: () => formatBrunchChromeFooterLines(chrome), + invalidate: () => {}, + })) + ui.setStatus( + "brunch.chrome", + `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, + ) + ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { + placement: "aboveEditor", + }) + ui.setWorkingIndicator( + chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, + ) + ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) +} + +function normalizeBrunchChromeState( + state: BrunchChromeInputState, +): BrunchChromeState { + if ("session" in state) { + return state + } + return { + ...state, + session: { id: "unbound" }, + stage: state.phase === "elicitation" ? "idle" : "idle", + activeLens: null, + coherenceVerdict: "unknown", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + streaming: false, + } +} + +function formatSpec(chrome: BrunchChromeState): string { + return chrome.spec?.title ?? "no spec selected" +} + +function formatSession(chrome: BrunchChromeState): string { + return chrome.session.label ?? chrome.session.id +} + export function createBrunchChromeExtension( chrome: WorkspaceSessionChromeState, onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, @@ -77,10 +180,7 @@ export function createBrunchChromeExtension( return (pi) => { pi.on("session_start", async (_event, ctx) => { await onSessionBoundary?.(ctx.sessionManager as SessionManager) - ctx.ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { - placement: "aboveEditor", - }) - ctx.ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) + renderBrunchChrome(ctx.ui, chrome) }) pi.on("before_agent_start", async (_event, ctx) => { await onSessionBoundary?.(ctx.sessionManager as SessionManager) From 6d3962de58b682fe8e9a127c538062519deaa468 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:15:30 +0200 Subject: [PATCH 003/164] restore provisional plan --- ...-ui-extension-patterns-provisional-plan.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/architecture/pi-ui-extension-patterns-provisional-plan.md diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md new file mode 100644 index 00000000..09995453 --- /dev/null +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -0,0 +1,537 @@ +# Pi UI Extension Patterns — Provisional Handoff Plan + +> Generated by `ln-handoff` at 2026-05-22T11:33:57Z. Read this file to resume `pi-ui-extension-patterns` work. +> This file is volatile transfer state for a spike-shaped frontier, not canonical product truth. Reconcile durable conclusions into `memory/SPEC.md`, `memory/PLAN.md`, and/or `docs/architecture/pi-seam-extensions.md` once the spike produces evidence. + +## Goal + +Prove which Pi extension and TUI customization seams Brunch can use to become an opinionated elicitation product shell — including narrowed commands, Brunch-owned chrome, dynamic background status, structured prompts, review-set interactions, and fixture/RPC controllability — without forking Pi or exposing Pi's generic extension system to Brunch users. + +## Session State + +- **Last completed skill**: `ln-consult` — classified this as the existing `pi-ui-extension-patterns` parallel frontier, structural and spike-flavored, adjacent to active `graph-data-plane` and required before or during M5 readiness. +- **Current skill**: `ln-handoff` — capturing the expanded audit and exploration plan into this provisional doc so a fresh thread can start scoping immediately. +- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → [refactor] → [sync]`; current position is between `plan` and `scope`, with a likely `ln-scope → ln-spike` next step. +- **Handoff trigger**: the conversation expanded beyond the detail currently in `memory/PLAN.md`; the user requested a thorough provisional planning doc under `docs/` and asked that it follow `ln-handoff` guidance. + +## Current canonical context + +- `memory/PLAN.md` active frontier is `graph-data-plane` (M4), but `pi-ui-extension-patterns` is explicitly listed under **Parallel / Low-conflict** and should inform M5/M6/M7. +- `pi-ui-extension-patterns` objective in PLAN: prove Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome, overlays, multi-choice prompts, action buttons, picker modals, ambient establishment-offer rendering, and agent-as-user driver controllability. +- `memory/SPEC.md` contains the durable stance that Brunch uses Pi internally but hides Pi's generic extension surface from Brunch users, preserves linear transcript policy, keeps establishment offers orientation-first, and routes writes through `CommandExecutor`. +- No `HANDOFF.md` existed at root when this doc was created. + +## In-flight work + +### A. Expanded need inventory + +| Need | Brunch purpose | Pi seams to probe | Known risk / question | +| --- | --- | --- | --- | +| Custom slash commands | `/lens`, `/spec`, user-invoked orientation views, review actions, debug/demo commands | `pi.registerCommand`, `getArgumentCompletions`, `ctx.waitForIdle`, extension command lifecycle | Writes must route through Brunch handlers/`CommandExecutor`; built-in command collisions do not override Pi built-ins. | +| Suppression of standard slash commands | Brunch should feel like an opinionated product, not a general Pi shell; hide or block commands Brunch does not support | autocomplete wrapping, settings (`enableSkillCommands`), lifecycle cancellation hooks, input interception, possible upstream Pi API | Autocomplete suppression and execution suppression differ. Built-in commands are handled by `InteractiveMode` before extension `input` events. Full allowlisting likely needs a Pi change or Brunch-owned wrapper. | +| Styled persistent chrome | Always-visible cwd/spec/session/phase/lens/coherence/status summary | `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, theme colors/glyphs | Need to know whether replacing Pi header/footer and widgets is enough to make the shell feel Brunch-owned even if some built-ins remain technically callable. | +| Dynamic background-status chrome | Observer/reviewer/reconciler running; N reconciliation needs; N observer jobs; new stage/mode available | `setStatus`, `setWidget`, event hooks, Brunch state renderer, maybe `pi.events` | Must update while idle and during streaming without corrupting UI; must survive reload/session replacement via state reconstruction. | +| Ambient establishment-offer rendering | Show latest `brunch.establishment_offer` as orientation, not a default lens menu | `setWidget`, `registerMessageRenderer`, transcript custom entries | Must preserve D32-L: orientation-first, user-invoked expanded view, not exhaustive persistent next-action menu. | +| Modal/popover overlays | Proposal review, orientation inspection, spec/entity pickers | `ctx.ui.custom(..., { overlay: true })`, overlay options, TUI components | Overlay stacking/priority, visual quality, cancellation semantics. | +| Radio / checkbox / select prompts | Structured elicitation answers and authority confirmations | `ctx.ui.select`, `ctx.ui.custom()`, `SelectList`, custom checkbox/radio component | Built-in `select` is single-choice; checkbox/freeform-plus-choice likely need custom component. | +| Freeform-plus-choice prompt | User can pick an option or write an escape-hatch answer | `ctx.ui.custom()`, `Editor`, questionnaire pattern | Must capture durable `brunch.offer_response`, not ephemeral UI-only state. | +| Clickable/navigable action buttons | Accept / request changes / reject review-set proposals | keyboard-navigable custom component, action bar, maybe mouse support if available | Clarify whether “clickable” is keyboard-only in TUI. Mouse support should be proven or explicitly left to web. | +| Picker/list-selection modals | Spec switching, entity selection, mention target selection | `SelectList`, `ctx.ui.custom`, `addAutocompleteProvider` | Spec switching must preserve `cwd → spec → session`; mentions must rewrite to stable IDs. | +| Message rendering for custom entries | Display offers, lens hints, review proposals, side-task results, world updates | `pi.registerMessageRenderer`, `pi.sendMessage`, `pi.appendEntry` | Need explicit context-participating vs persistence-only distinction. | +| Tool rendering / graph tool affordances | Show graph mutations and dry-run validation clearly | custom tool `renderCall`/`renderResult`, `renderShell` | Useful for M5 graph tools; must not obscure command-result discriminants. | +| RPC controllability of UI | Agent-as-user driver exercises choices/actions in fixtures | RPC extension UI protocol for built-in dialogs; Brunch RPC method families for custom affordances | `ctx.ui.custom()` returns `undefined` in RPC mode, so rich custom components are not automatically fixture-controllable. Biggest cross-cutting risk after command suppression. | +| Branch-flow blocking | Enforce Brunch linear transcript policy | `session_before_tree`, `session_before_fork`, `session_before_switch` | Already partly proven in M3; prototypes must not regress I19-L. | +| Prompt/tool/lens switching | Lenses as system prompt + active tools + context projection | `before_agent_start`, `context`, `pi.setActiveTools`, `registerTool` | Pi extensions cannot be cleanly unregistered; Brunch should register once and gate with active tools/session state. | +| Autocomplete providers | `#` graph mentions; slash arg completions | `ctx.ui.addAutocompleteProvider`, command completions | Need stable ID insertion, not title anchoring. | + +### B. Source audit findings already gathered + +#### B1. Built-in commands are hardcoded and always included in base autocomplete + +Evidence: + +- `~/Clones/earendil-works/pi/packages/coding-agent/src/core/slash-commands.ts` defines `BUILTIN_SLASH_COMMANDS` with: + - `/settings`, `/model`, `/scoped-models`, `/export`, `/import`, `/share`, `/copy`, `/name`, `/session`, `/changelog`, `/hotkeys`, `/fork`, `/clone`, `/tree`, `/login`, `/logout`, `/new`, `/compact`, `/resume`, `/reload`, `/quit`. +- `~/Clones/earendil-works/pi/packages/coding-agent/src/modes/interactive/interactive-mode.ts` imports `BUILTIN_SLASH_COMMANDS` and builds autocomplete from them in `createBaseAutocompleteProvider()`. +- The same autocomplete method appends prompt-template commands, extension commands, and skill commands when `settingsManager.getEnableSkillCommands()` is true. + +Implication: + +- Autocomplete narrowing is probably feasible by wrapping the autocomplete provider and/or disabling skill commands, but built-in commands are a default base layer. +- Need to test whether `ctx.ui.addAutocompleteProvider()` can filter slash suggestions after delegating to the base provider. + +#### B2. Built-in command execution happens before extension `input` interception + +Evidence: + +- `InteractiveMode.setupEditorSubmitHandler()` checks exact command strings directly (`/settings`, `/model`, `/fork`, `/tree`, `/new`, `/compact`, `/reload`, `/resume`, `/quit`, etc.) before normal message submission. +- `AgentSession.prompt()` executes extension commands first, then emits extension `input`, then expands skill/prompt-template commands. +- Since many built-ins are handled in `InteractiveMode` before `AgentSession.prompt()`, extension `input` cannot be relied upon to block built-in interactive commands. + +Implication: + +- Full built-in command allowlisting is not currently a clean extension-level capability. +- Some effects can be cancelled by lifecycle events (`session_before_fork`, `session_before_tree`, `session_before_switch`, `session_before_compact`), but that is not the same as suppressing command availability or intercepting execution. +- One spike output should be a minimal Pi upstream/API ask if Brunch needs true command policy. + +#### B3. Extension command collisions do not override built-ins + +Evidence: + +- `InteractiveMode.getBuiltInCommandConflictDiagnostics()` detects extension commands whose names conflict with built-ins and warns/skips in autocomplete or suffixes invocation names. +- `ExtensionRunner.resolveRegisteredCommands()` suffixes duplicate extension command names as `name:1`, `name:2`, etc. + +Implication: + +- Brunch cannot override `/model` or `/settings` by registering an extension command of the same name. +- Brunch commands should use product-specific names or rely on a future command policy hook. + +#### B4. Chrome replacement/update seams are strong + +Evidence from docs and examples: + +- `custom-header.ts` uses `ctx.ui.setHeader(...)` to replace the built-in header. +- `custom-footer.ts` uses `ctx.ui.setFooter(...)` and can access `footerData.getGitBranch()` and extension statuses. +- `status-line.ts` uses `ctx.ui.setStatus(...)` from `session_start`, `turn_start`, and `turn_end`. +- `widget-placement.ts` uses `ctx.ui.setWidget(...)` above and below the editor. +- `working-indicator.ts` uses `setWorkingIndicator` and status updates. +- `hidden-thinking-label.ts` customizes the hidden thinking label. +- `InteractiveMode.init()` has built-in header construction, but extension `setHeader` exists as a replacement seam. + +Implication: + +- Brunch chrome replacement/dynamic status looks feasible and lower-risk than command suppression. +- A Brunch UI state renderer should concentrate calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` rather than scattering raw Pi UI calls across subsystems. + +#### B5. Custom UI is powerful in TUI but degraded in RPC + +Evidence: + +- `docs/extensions.md` and `docs/tui.md` describe `ctx.ui.custom()`, overlay mode, `overlayOptions`, and custom components. +- `overlay-test.ts` and `overlay-qa-tests.ts` exercise custom overlays. +- `questionnaire.ts` implements a multi-question custom UI with options, tabs, and freeform input. +- `docs/rpc.md` says RPC supports dialog/fire-and-forget methods (`select`, `confirm`, `input`, `editor`, `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`), but `custom()` returns `undefined` in RPC mode and several TUI-specific methods are no-ops. + +Implication: + +- Rich TUI UI can be built, but fixture-controllable semantics must not depend on `ctx.ui.custom()` alone. +- Critical Brunch interactions should be represented as product payloads/commands with mode-specific renderers: TUI custom overlay, web component, RPC decision method or built-in dialog fallback. + +#### B6. This Pi-based harness can be the live test bed + +User insight: + +- Because this coding harness itself is Pi and can auto-reload extension changes, the ideal test bed for many extension explorations is the same harness we are working in, in real time. + +Implication: + +- The spike should include a `scratch` or project-local Pi extension loaded into this harness, not only tests in Brunch’s future TUI host. +- Use auto-discovered extension locations or `pi -e` style prototypes where appropriate, but avoid committing harness-local experiments as Brunch product code unless promoted. +- Document any manual/realtime observations: what reloaded cleanly, what required restart, what UI state survived reload, which hooks fire inside the harness. + +### C. Strategically grouped exploration inventory + +#### Group A — Product-shell containment: “Can Brunch narrow Pi?” + +**A1. Built-in command inventory and policy matrix** + +Audit each built-in command: + +- `/settings` +- `/model` +- `/scoped-models` +- `/export` +- `/import` +- `/share` +- `/copy` +- `/name` +- `/session` +- `/changelog` +- `/hotkeys` +- `/fork` +- `/clone` +- `/tree` +- `/login` +- `/logout` +- `/new` +- `/compact` +- `/resume` +- `/reload` +- `/quit` + +Classify each: + +| Command | Hide autocomplete? | Block execution by hook? | Safe to leave? | Needs Pi change? | Notes | +| --- | --- | --- | --- | --- | --- | +| `/fork` | TBD | likely yes via `session_before_fork` | no | maybe | Branch creation unsupported by Brunch POC. | +| `/clone` | TBD | likely yes via `session_before_fork` | no | maybe | Same branch-policy concern. | +| `/tree` | TBD | likely yes via `session_before_tree` | no | maybe | Branch navigation unsupported. | +| `/new` | TBD | maybe via `session_before_switch`; Brunch needs custom same-spec behavior | maybe with coordinator | likely if full replacement needed | Must preserve selected spec. | +| `/resume` | TBD | maybe via `session_before_switch` | uncertain | maybe | Needs explicit Brunch session/spec validation. | +| `/model` | TBD | no known hook | maybe hidden or allowed internally | likely if strict | Product may want curated model policy. | +| `/settings` | TBD | no known hook | probably no for product shell | likely if strict | Generic Pi settings expose non-Brunch surface. | +| all others | TBD | TBD | TBD | TBD | Complete in spike. | + +**A2. Autocomplete allowlist probe** + +Prototype an extension that wraps the autocomplete provider and filters slash suggestions to a Brunch allowlist while preserving file/path completion and future `#` mention completion. + +Acceptance evidence: + +- Brunch-allowed commands appear. +- Disallowed built-ins do not appear in suggestions. +- Path/file completions still work. +- Skill commands can be disabled or filtered. + +**A3. Execution allowlist probe** + +Try to block disallowed commands through: + +1. extension `input` event, +2. custom editor wrapper, +3. lifecycle hooks, +4. registering conflicting extension commands, +5. settings knobs. + +Expected result: + +- `input` is too late for built-in interactive commands. +- lifecycle hooks can block specific session operations but not all commands. +- command conflicts do not override built-ins. +- if a custom editor can pre-intercept submit, determine whether it is safe enough or too invasive. + +**A4. Minimum Pi upstream/API ask** + +If strict suppression is not possible, write a tiny upstream/API request, e.g. one of: + +```ts +pi.setCommandPolicy({ + hiddenBuiltins: ["fork", "clone", "tree", "settings"], + blockedBuiltins: ["fork", "clone", "tree"], +}); +``` + +or a launch/session option: + +```ts +allowedBuiltInCommands: ["new", "compact", "quit"] +``` + +Spike must distinguish “nice to have” from “required before M5/M6/M7.” + +#### Group B — Brunch chrome: “Can the shell feel like Brunch, not Pi?” + +**B1. Header/footer replacement demo** + +Use `setHeader` and `setFooter` to replace Pi branding/help with Brunch-specific chrome. + +Questions: + +- Can startup hints be fully removed/replaced? +- Does footer replacement lose any useful status Brunch needs? +- Can model/tool/debug info be hidden or made secondary? +- Does this work after `/reload` and session replacement? + +**B2. Persistent status/widget layout demo** + +Use: + +- `setStatus` for compact counters, +- `setWidget(aboveEditor)` for establishment/coherence summary, +- `setWidget(belowEditor)` for queue/status details. + +Prototype fields: + +- cwd, +- spec, +- session, +- phase/stage, +- active lens, +- coherence verdict, +- observer/reviewer queue state, +- reconciliation need count. + +**B3. Dynamic background updates demo** + +Simulate: + +- reviewer starts/runs/completes, +- observer queue count increments/decrements, +- reconciliation need count changes, +- new stage/mode becomes available. + +Acceptance evidence: + +- Updates render while idle. +- Updates render during streaming. +- Updates do not corrupt editor input. +- Updates survive `/reload` by reconstructing from state or deliberately reset with clear semantics. + +#### Group C — Guided interaction primitives: “Can Brunch ask in product-native shapes?” + +**C1. Built-in dialog coverage** + +Probe `ctx.ui.select`, `confirm`, `input`, and `editor`. + +Map to: + +- simple authority confirmation, +- single-choice question, +- freeform answer, +- multiline request-changes. + +Record which are supported in interactive TUI and RPC. + +**C2. Custom radio/checkbox/freeform component** + +Build one `ctx.ui.custom()` component covering: + +- radio, +- checkbox, +- freeform-plus-choice, +- skip/cancel, +- optional timeout if feasible. + +Acceptance evidence: + +- returns typed payload, +- handles keyboard navigation, +- renders clearly in narrow terminals, +- can write `brunch.offer_response`-shaped payloads via a Brunch wrapper, +- has a non-custom RPC fallback path. + +**C3. Picker/list modal** + +Use `SelectList` pattern for: + +- spec picker, +- entity picker, +- lens/orientation inspection. + +Constraints: + +- Establishment offer expansion remains user-invoked. +- Spec picker cannot mutate session binding directly; it must route through coordinator/command handler. +- Entity picker must return stable IDs. + +#### Group D — Review-set UX: “Can accept/request/reject be controllable?” + +**D1. Review-set overlay prototype** + +Use `ctx.ui.custom(..., { overlay: true })` to render: + +- proposal summary, +- candidate entities/edges, +- grounding coverage, +- epistemic status, +- actions: approve / request changes / reject. + +**D2. Action-button semantics** + +Clarify and document whether TUI target is: + +- keyboard-navigable only, +- mouse-clickable, +- or web-only clickable. + +Likely posture: keyboard-navigable in TUI is sufficient unless Pi mouse support is proven cheaply. + +**D3. Transcript persistence check** + +Every action must produce durable transcript/product state: + +- `brunch.review_set_response` or equivalent, +- `acceptReviewSet` command for approve, +- regeneration request for request-changes, +- rejection entry for reject. + +No review-set decision may be UI-only. + +#### Group E — RPC / fixture controllability: “Can the agent-as-user driver exercise this?” + +**E1. Built-in RPC extension UI parity** + +Confirm RPC support for: + +- `select`, +- `confirm`, +- `input`, +- `editor`, +- `notify`, +- `setStatus`, +- `setWidget`, +- `setTitle`, +- `setEditorText`. + +Use `rpc-demo.ts` plus `docs/rpc.md` as reference. + +**E2. Custom component gap** + +Because `ctx.ui.custom()` returns `undefined` in RPC mode, evaluate options: + +1. restrict critical fixture paths to RPC-supported dialogs, +2. add Brunch-owned RPC methods for offer/review decisions, +3. model custom TUI choices as transcript-native offers with RPC-specific decision renderers, +4. accept rich overlays as manual-only but test their payload contracts separately. + +Recommended direction: + +- Separate semantic offer/review payloads from mode-specific renderers. +- TUI overlay, web component, and RPC driver should all answer the same Brunch-level pending interaction, not each invent state. + +#### Group F — Custom transcript/message rendering + +**F1. Custom message renderer audit** + +Probe `registerMessageRenderer` for: + +- `brunch.establishment_offer`, +- `brunch.elicitor_intent_hint`, +- `brunch.review_set_proposal`, +- `brunch.side_task_result`, +- `worldUpdate`, +- `brunch.mention_staleness_hint`. + +**F2. Context vs persistence distinction** + +For each entry type, record whether it is: + +- persisted only via `appendEntry`, +- context-participating via `sendMessage`, +- displayed, +- hidden/internal, +- part of exchange projection, +- relevant to RPC/web subscriptions. + +This prevents accidental parallel chat/turn state and protects M2/M3 transcript decisions. + +#### Group G — Live harness test-bed strategy + +**G1. Use this Pi harness as realtime prototype host** + +Because the agent harness itself is Pi and supports extension reloads, use the current working harness as a fast feedback loop for extension seams. + +Candidate approach: + +- Put scratch extensions in a clearly temporary location, ideally outside Brunch product source or under a `docs/architecture/artifacts/pi-ui-extension-patterns/` scratch area if committed artifacts are desired. +- Prefer project-local `.pi/extensions/` or explicit `pi -e` for quick tests; if using this repository’s `.pi/`, ensure experiments do not imply Brunch user-facing configuration. +- Use `/reload` to test hot reload and state reconstruction. +- Capture findings in the feasibility matrix, not as production code by default. + +**G2. Promote only wrappers that survive the spike** + +The spike may leave behind minimum-viable wrappers, but they should be Brunch-owned and semantically named, e.g.: + +- `renderBrunchChrome(ctx, state)` +- `requestBrunchChoice(ctx, offer)` +- `requestReviewSetDecision(ctx, reviewSet)` +- `installBrunchCommandPolicy(pi, policy)` if feasible +- `installBrunchAutocomplete(pi, provider)` + +Do not spread raw Pi extension calls throughout M5/M6/M7 code. + +### D. Recommended exploration order + +1. **Command/chrome containment audit** — decide whether Brunch can feel product-owned without Pi changes; highest planning leverage. +2. **Dynamic chrome demo** — prove live background status can be represented cheaply. +3. **Structured prompt primitives** — radio/checkbox/freeform picker. +4. **Review-set overlay** — richest UX, depends on primitives. +5. **RPC controllability pass** — determine which affordances need semantic fallback methods. +6. **Wrapper design** — define Brunch-owned APIs over Pi primitives so M5/M6/M7 do not depend directly on raw Pi extension calls. +7. **Feasibility matrix + memo** — update `docs/architecture/pi-ui-extension-patterns.md` or `docs/architecture/pi-seam-extensions.md`, then reconcile SPEC/PLAN. + +### E. Proposed feasibility matrix shape + +Create during spike: + +| Affordance | User-visible purpose | Pi seam(s) | Demo status | RPC/fixture controllable? | Brunch wrapper required | Verdict | Residual risk | Downstream frontier | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Command autocomplete allowlist | Hide unsupported Pi commands | `addAutocompleteProvider`, settings | not started | n/a | yes | TBD | execution still separate | M5/M6 | +| Built-in command execution block | Prevent unsupported product flows | lifecycle hooks / maybe Pi change | not started | n/a | yes | TBD | likely incomplete | M0/M5/M7 | +| Dynamic chrome | Show product state | `setHeader`, `setFooter`, `setStatus`, `setWidget` | not started | partial (`setWidget/status`) | yes | likely feasible | reload/rebind | M5/M7/M8 | +| Multi-choice prompt | Structured elicitation | `select`, `custom` | not started | partial (`select`) | yes | TBD | custom RPC gap | M5 | +| Review-set overlay | Accept/request/reject | `custom` overlay | not started | no unless fallback | yes | TBD | fixture controllability | M5/M6 | + +## Review findings + +No `ln-review` was run in this session, so there are no review findings to preserve. + +| # | Finding | Status | Implications | +| --- | --- | --- | --- | +| — | No review findings from this session. | n/a | n/a | + +## Diagnostic evidence + +- `memory/PLAN.md`: `pi-ui-extension-patterns` is a parallel/low-conflict frontier and explicitly lists custom slash commands, styled chrome, overlays, multi-choice prompts, action buttons, picker modals, establishment-offer rendering, and agent-as-user controllability as acceptance concerns. +- `memory/SPEC.md`: Brunch hides Pi's generic extension surface from users, preserves linear transcript policy, keeps establishment offers orientation-first, and routes writes through `CommandExecutor`. +- Pi docs `docs/extensions.md`: extensions can register tools/commands, subscribe to lifecycle events, call UI methods, set widgets/status/header/footer, add autocomplete providers, custom-render messages/tools, and use `sendMessage`/`appendEntry` with delivery modes. +- Pi docs `docs/tui.md`: `ctx.ui.custom()` supports custom components and overlays; built-in components include `SelectList`, `SettingsList`, `Editor`, `Text`, `Container`, etc.; every render line must fit width; components must handle invalidation/theme changes. +- Pi docs `docs/rpc.md`: RPC extension UI supports built-in dialogs and fire-and-forget UI updates; `ctx.ui.custom()` returns `undefined` in RPC mode. +- Pi source `src/core/slash-commands.ts`: built-in commands are statically enumerated. +- Pi source `src/modes/interactive/interactive-mode.ts`: built-in command execution is handled in the editor submit handler before normal prompt flow. +- Pi source `src/core/agent-session.ts`: extension commands are tried before extension `input`; extension `input` fires before skill/template expansion, but too late for built-ins already handled by interactive mode. +- Pi source/examples: `custom-header.ts`, `custom-footer.ts`, `status-line.ts`, `widget-placement.ts`, `working-indicator.ts`, `questionnaire.ts`, `overlay-test.ts`, `rpc-demo.ts`, and `plan-mode/index.ts` provide concrete implementation patterns for the likely Brunch affordances. + +## Decisions and assumptions + +| Item | Type | Status | Source | +| --- | --- | --- | --- | +| Treat `pi-ui-extension-patterns` as a structural spike, not ordinary UI polish. | decision | persisted in PLAN, reinforced by conversation | `memory/PLAN.md`, conversation | +| Built-in command suppression is now a first-class spike question. | decision | volatile; not yet reconciled into PLAN/SPEC | conversation | +| Dynamic background-status chrome is a first-class need: observer/reviewer/reconciler running, queues, reconciliation needs, new stage/mode. | decision | volatile; not yet reconciled into PLAN/SPEC | conversation | +| This Pi harness should serve as a realtime test bed for extension changes and reload behavior. | decision | volatile; not yet reconciled into PLAN/SPEC | user conversation | +| Chrome replacement/update seams are likely feasible. | assumption | volatile, moderate confidence | docs/examples/source audit | +| Full built-in command execution allowlisting is likely not feasible solely through current public extension APIs. | assumption | volatile, moderate confidence | source audit | +| Rich custom TUI affordances need semantic RPC fallbacks because `ctx.ui.custom()` is not available in RPC mode. | assumption | volatile, high confidence from docs | `docs/rpc.md` | + +## Repo state + +- **Branch**: `ln/fe-741-graph-data-plane` +- **Recent commits**: + - `eab91dfb Restore ln-judo-review skill` + - `64406a91 Sync web shell closeout` + - `1cbd57b4 Use typed web session projection target` + - `ab28054e Use explicit transcript custom entry classifiers` + - `f5a26ea0 Share Brunch session envelope reader` +- **Dirty files before writing this doc**: none. +- **Dirty files after writing this doc**: expected `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`. +- **Test status**: not run; this session only read docs/source and wrote a planning/handoff document. + +## Artifact status + +| Artifact | Exists | Current vs conversation | +| --- | --- | --- | +| `memory/SPEC.md` | yes | mostly current for durable architecture, but does not yet include the expanded command-suppression/realtime-harness-test-bed detail. | +| `memory/PLAN.md` | yes | current at frontier level, but `pi-ui-extension-patterns` definition is less detailed than this provisional plan. | +| `memory/CARDS.md` | no | n/a | +| `memory/REFACTOR.md` | no | n/a | +| `docs/architecture/pi-seam-extensions.md` | yes | contains earlier Pi seam analysis; should receive or link to final feasibility matrix after spike. | +| `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` | yes | this temporary/provisional handoff plan; retire or supersede after scoping/spike. | + +## Next steps + +1. Run `ln-scope` for the `pi-ui-extension-patterns` frontier, using this doc as the in-flight input. The scope should be a thin spike slice, not the full implementation of all wrappers. +2. Before creating branch/issue work, follow project protocol for a new frontier item: create a Linear issue in FE/brunch and a Graphite branch, unless the user explicitly treats this as pre-branch scoping only. Read `docs/praxis/graphite-workflow.md` before branch work. +3. Scope the first slice around **command/chrome containment + dynamic chrome proof**, because this resolves the highest-risk product-shell questions first. +4. During the spike, use the local Pi clone (`~/Clones/earendil-works/pi`) and the current Pi harness as a live test bed where possible. Record which observations came from source audit vs realtime harness behavior. +5. Produce a feasibility matrix and either create `docs/architecture/pi-ui-extension-patterns.md` or update `docs/architecture/pi-seam-extensions.md` with stable results. +6. Reconcile durable conclusions into `memory/SPEC.md` and `memory/PLAN.md` via `ln-sync` once evidence exists. + +## Retirement rule + +- Delete or overwrite this file once its volatile planning state is absorbed into a scoped card, a spike memo/feasibility matrix, `memory/SPEC.md`, `memory/PLAN.md`, or a newer handoff. +- Do not treat this file as canonical product contract; its job is to preserve the expanded exploration inventory and reasoning for the next thread. + +## Open questions + +- Is hiding unsupported built-in commands from autocomplete enough for Brunch POC, if dangerous effects like branch creation are blocked by lifecycle hooks? +- Does Brunch require a Pi upstream/API change for true built-in command allowlisting before M5, or can this wait? +- Should TUI “action buttons” be keyboard-navigable only, or should mouse-clickability be a hard requirement? +- Which rich custom interactions must be fixture-controllable in RPC mode for M5, and which can remain manual outer-loop checks? +- Where should realtime harness scratch extensions live so they are useful but not confused with Brunch product code? + +## Resume prompt + +Paste this into a new session: + +> Read `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`, `memory/SPEC.md`, and `memory/PLAN.md`. We are preparing the `pi-ui-extension-patterns` parallel frontier. The immediate next step is to run `ln-scope` for a thin spike slice focused first on command/chrome containment and dynamic Brunch chrome, using the local Pi clone at `~/Clones/earendil-works/pi` and, where useful, this Pi harness itself as a realtime extension test bed. Preserve the distinction between provisional findings and canonical SPEC/PLAN truth. From 120bd982de0eceb923141728cafb9001230a2983 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:22:36 +0200 Subject: [PATCH 004/164] FE-744: Refresh provisional Pi UI handoff --- ...-ui-extension-patterns-provisional-plan.md | 140 +++++++++++------- 1 file changed, 89 insertions(+), 51 deletions(-) diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md index 09995453..0faa4d8c 100644 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -2,6 +2,8 @@ > Generated by `ln-handoff` at 2026-05-22T11:33:57Z. Read this file to resume `pi-ui-extension-patterns` work. > This file is volatile transfer state for a spike-shaped frontier, not canonical product truth. Reconcile durable conclusions into `memory/SPEC.md`, `memory/PLAN.md`, and/or `docs/architecture/pi-seam-extensions.md` once the spike produces evidence. +> +> **Status update (2026-05-22):** The restored body matches the previously read provisional handoff content: it contains the same expanded need inventory, source-audit findings, exploration groups A–G, proposed matrix, repo-state snapshot, and resume prompt that were visible before deletion. Since then, Cards 1–2 have landed on FE-744 / `ln/fe-744-pi-ui-extension-patterns`; durable findings now live in `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, and `memory/PLAN.md`. Keep this file only as the remaining future-affordance inventory and scoping aid. ## Goal @@ -9,17 +11,25 @@ Prove which Pi extension and TUI customization seams Brunch can use to become an ## Session State -- **Last completed skill**: `ln-consult` — classified this as the existing `pi-ui-extension-patterns` parallel frontier, structural and spike-flavored, adjacent to active `graph-data-plane` and required before or during M5 readiness. -- **Current skill**: `ln-handoff` — capturing the expanded audit and exploration plan into this provisional doc so a fresh thread can start scoping immediately. -- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → [refactor] → [sync]`; current position is between `plan` and `scope`, with a likely `ln-scope → ln-spike` next step. -- **Handoff trigger**: the conversation expanded beyond the detail currently in `memory/PLAN.md`; the user requested a thorough provisional planning doc under `docs/` and asked that it follow `ln-handoff` guidance. +- **Originally captured by**: `ln-handoff` after `ln-consult` classified this as the existing `pi-ui-extension-patterns` parallel frontier. +- **Current branch/issue**: FE-744 / `ln/fe-744-pi-ui-extension-patterns`, tracked in Graphite off `ln/fe-737-web-shell` and parallel to `ln/fe-741-graph-data-plane`. +- **Completed since original handoff**: + - Card 1 — command containment feasibility: landed in commit `4b1c2604`; established `A18-L`/`D34-L` and the command-containment matrix. + - Card 2 — dynamic Brunch chrome proof: landed in commit `233c2cd1`; added `renderBrunchChrome` and established `D35-L`; validated `A10-L`. +- **Current flow position**: after two `ln-build` cards. Next step is not the original first scope; use this doc to scope remaining affordance work (structured prompts, overlays, action buttons, pickers, message rendering, RPC controllability) or to prepare a product-shell review of residual built-in command exposure. +- **Retirement posture**: this file should no longer describe completed command/chrome work as future work; completed results are summarized below and authoritative detail is in `docs/architecture/pi-ui-extension-patterns.md`. ## Current canonical context - `memory/PLAN.md` active frontier is `graph-data-plane` (M4), but `pi-ui-extension-patterns` is explicitly listed under **Parallel / Low-conflict** and should inform M5/M6/M7. - `pi-ui-extension-patterns` objective in PLAN: prove Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome, overlays, multi-choice prompts, action buttons, picker modals, ambient establishment-offer rendering, and agent-as-user driver controllability. - `memory/SPEC.md` contains the durable stance that Brunch uses Pi internally but hides Pi's generic extension surface from Brunch users, preserves linear transcript policy, keeps establishment offers orientation-first, and routes writes through `CommandExecutor`. -- No `HANDOFF.md` existed at root when this doc was created. +- Durable updates since this plan was written: + - `A18-L` remains open: autocomplete hiding plus effect blocking may be sufficient for the POC shell, but product review must accept exact built-in residual exposure. + - `D34-L` records that command containment separates visibility suppression from effect blocking; strict exact built-in suppression requires a Pi command/keybinding policy seam. + - `A10-L` is validated: persistent/dynamic TUI chrome can be mounted without forking Pi. + - `D35-L` records that dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives; downstream affordances should use Brunch wrappers, not raw `ctx.ui.*` calls. +- No `HANDOFF.md` exists; `memory/CARDS.md` was exhausted and retired after Cards 1–2. ## In-flight work @@ -86,7 +96,7 @@ Implication: - Brunch cannot override `/model` or `/settings` by registering an extension command of the same name. - Brunch commands should use product-specific names or rely on a future command policy hook. -#### B4. Chrome replacement/update seams are strong +#### B4. Chrome replacement/update seams are proven for the current POC wrapper Evidence from docs and examples: @@ -97,11 +107,14 @@ Evidence from docs and examples: - `working-indicator.ts` uses `setWorkingIndicator` and status updates. - `hidden-thinking-label.ts` customizes the hidden thinking label. - `InteractiveMode.init()` has built-in header construction, but extension `setHeader` exists as a replacement seam. +- Brunch now has `renderBrunchChrome(ctx.ui, state)` in `src/brunch-tui.ts`; tests prove it drives header/footer/status/widget/working-indicator/title from one product-state snapshot. +- A raw TUI transcript proof showed Brunch header/footer/widget text rendered in a live Pi TUI. A raw RPC probe showed status/widget/title are observable over RPC while header/footer/working-indicator are no-ops. Implication: -- Brunch chrome replacement/dynamic status looks feasible and lower-risk than command suppression. -- A Brunch UI state renderer should concentrate calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` rather than scattering raw Pi UI calls across subsystems. +- Brunch chrome replacement/dynamic status is proven enough for downstream M5/M6/M7 wrappers to build on. +- A Brunch UI state renderer should continue to concentrate calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` rather than scattering raw Pi UI calls across subsystems. +- Remaining chrome evidence gap: full Brunch-host manual walkthrough with real coordinator-derived graph/lens/coherence data, not just unit tests and temporary raw Pi harness probes. #### B5. Custom UI is powerful in TUI but degraded in RPC @@ -133,9 +146,11 @@ Implication: #### Group A — Product-shell containment: “Can Brunch narrow Pi?” -**A1. Built-in command inventory and policy matrix** +**Status:** Mostly answered by Card 1. Keep this group as evidence background and product-review input, not as the next implementation target unless strict containment becomes mandatory. -Audit each built-in command: +**A1. Built-in command inventory and policy matrix — done** + +The completed matrix is now in `docs/architecture/pi-ui-extension-patterns.md`. Original audit target: - `/settings` - `/model` @@ -172,20 +187,22 @@ Classify each: | `/settings` | TBD | no known hook | probably no for product shell | likely if strict | Generic Pi settings expose non-Brunch surface. | | all others | TBD | TBD | TBD | TBD | Complete in spike. | -**A2. Autocomplete allowlist probe** +**A2. Autocomplete allowlist probe — source-proven, not visually proven** -Prototype an extension that wraps the autocomplete provider and filters slash suggestions to a Brunch allowlist while preserving file/path completion and future `#` mention completion. +Card 1 source-audited that `ctx.ui.addAutocompleteProvider()` can wrap the base provider and should be able to filter slash suggestions while delegating file/path and future `#` mention completion. Visual TUI autocomplete proof remains open if product review needs it. -Acceptance evidence: +Original acceptance evidence: - Brunch-allowed commands appear. - Disallowed built-ins do not appear in suggestions. - Path/file completions still work. - Skill commands can be disabled or filtered. -**A3. Execution allowlist probe** +**A3. Execution allowlist probe — done, strict allowlist blocked on Pi API** + +Card 1 established that exact interactive built-ins are consumed by `InteractiveMode` before extension `input`; lifecycle hooks can block dangerous effects but cannot strictly suppress all built-in execution. -Try to block disallowed commands through: +Original probe list: 1. extension `input` event, 2. custom editor wrapper, @@ -200,9 +217,9 @@ Expected result: - command conflicts do not override built-ins. - if a custom editor can pre-intercept submit, determine whether it is safe enough or too invasive. -**A4. Minimum Pi upstream/API ask** +**A4. Minimum Pi upstream/API ask — done** -If strict suppression is not possible, write a tiny upstream/API request, e.g. one of: +`docs/architecture/pi-ui-extension-patterns.md` now records the minimal command/keybinding policy ask. Original shape: ```ts pi.setCommandPolicy({ @@ -221,9 +238,11 @@ Spike must distinguish “nice to have” from “required before M5/M6/M7.” #### Group B — Brunch chrome: “Can the shell feel like Brunch, not Pi?” -**B1. Header/footer replacement demo** +**Status:** Initial command/chrome question answered by Card 2. The core wrapper exists; remaining work is product-shell walkthrough and extending the wrapper for future real graph/lens/coherence data. -Use `setHeader` and `setFooter` to replace Pi branding/help with Brunch-specific chrome. +**B1. Header/footer replacement demo — done at raw TUI + unit level** + +`renderBrunchChrome` uses `setHeader` and `setFooter` to replace Pi branding/help with Brunch-specific chrome. Questions: @@ -232,13 +251,14 @@ Questions: - Can model/tool/debug info be hidden or made secondary? - Does this work after `/reload` and session replacement? -**B2. Persistent status/widget layout demo** +**B2. Persistent status/widget layout demo — done for above-editor/status path** -Use: +`renderBrunchChrome` uses: - `setStatus` for compact counters, -- `setWidget(aboveEditor)` for establishment/coherence summary, -- `setWidget(belowEditor)` for queue/status details. +- `setWidget(aboveEditor)` for spec/session/lens/coherence/worker summary. + +`setWidget(belowEditor)` remains available but was not needed for the first wrapper. Prototype fields: @@ -251,9 +271,11 @@ Prototype fields: - observer/reviewer queue state, - reconciliation need count. -**B3. Dynamic background updates demo** +**B3. Dynamic background updates demo — partially done** -Simulate: +Card 2 simulated streaming/worker state via unit tests and a raw RPC extension command; full live Brunch-host idle-vs-streaming manual walkthrough remains open. + +Original target simulations: - reviewer starts/runs/completes, - observer queue count increments/decrements, @@ -432,13 +454,13 @@ Do not spread raw Pi extension calls throughout M5/M6/M7 code. ### D. Recommended exploration order -1. **Command/chrome containment audit** — decide whether Brunch can feel product-owned without Pi changes; highest planning leverage. -2. **Dynamic chrome demo** — prove live background status can be represented cheaply. -3. **Structured prompt primitives** — radio/checkbox/freeform picker. +1. **Command/chrome containment audit** — done in Card 1; see `docs/architecture/pi-ui-extension-patterns.md`. +2. **Dynamic chrome demo** — done for wrapper/unit/raw-TUI/raw-RPC proof in Card 2; full Brunch-host walkthrough remains optional/product-review debt. +3. **Structured prompt primitives** — next likely build target: radio/checkbox/freeform picker with semantic payloads and RPC fallback. 4. **Review-set overlay** — richest UX, depends on primitives. -5. **RPC controllability pass** — determine which affordances need semantic fallback methods. -6. **Wrapper design** — define Brunch-owned APIs over Pi primitives so M5/M6/M7 do not depend directly on raw Pi extension calls. -7. **Feasibility matrix + memo** — update `docs/architecture/pi-ui-extension-patterns.md` or `docs/architecture/pi-seam-extensions.md`, then reconcile SPEC/PLAN. +5. **RPC controllability pass** — determine which affordances need semantic fallback methods; already known that TUI custom components are not RPC-controllable directly. +6. **Wrapper design** — started with `renderBrunchChrome`; continue with Brunch-owned APIs over Pi primitives so M5/M6/M7 do not depend directly on raw Pi extension calls. +7. **Feasibility matrix + memo** — started in `docs/architecture/pi-ui-extension-patterns.md`; continue updating it as new affordance categories are proven. ### E. Proposed feasibility matrix shape @@ -446,9 +468,9 @@ Create during spike: | Affordance | User-visible purpose | Pi seam(s) | Demo status | RPC/fixture controllable? | Brunch wrapper required | Verdict | Residual risk | Downstream frontier | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Command autocomplete allowlist | Hide unsupported Pi commands | `addAutocompleteProvider`, settings | not started | n/a | yes | TBD | execution still separate | M5/M6 | -| Built-in command execution block | Prevent unsupported product flows | lifecycle hooks / maybe Pi change | not started | n/a | yes | TBD | likely incomplete | M0/M5/M7 | -| Dynamic chrome | Show product state | `setHeader`, `setFooter`, `setStatus`, `setWidget` | not started | partial (`setWidget/status`) | yes | likely feasible | reload/rebind | M5/M7/M8 | +| Command autocomplete allowlist | Hide unsupported Pi commands | `addAutocompleteProvider`, settings | source-proven; visual TUI proof open | n/a | yes | feasible-with-cost | execution still separate | M5/M6 | +| Built-in command execution block | Prevent unsupported product flows | lifecycle hooks / Pi command-policy API | proven incomplete for strict exact built-ins; effect blocking proven for branch/session flows | n/a | yes | requires-pi-change for strict suppression | exact interactive built-ins remain callable; lifecycle hooks block dangerous effects only | M0/M5/M7 | +| Dynamic chrome | Show product state | `setHeader`, `setFooter`, `setStatus`, `setWidget`, `setWorkingIndicator`, `setTitle` | proven for wrapper/unit/raw-TUI/raw-RPC | partial (`setStatus`, string-array `setWidget`, `setTitle`) | yes: `renderBrunchChrome` | proven for POC wrapper | full Brunch-host walkthrough still useful; reload reconstructs from product state | M5/M7/M8 | | Multi-choice prompt | Structured elicitation | `select`, `custom` | not started | partial (`select`) | yes | TBD | custom RPC gap | M5 | | Review-set overlay | Accept/request/reject | `custom` overlay | not started | no unless fallback | yes | TBD | fixture controllability | M5/M6 | @@ -477,15 +499,17 @@ No `ln-review` was run in this session, so there are no review findings to prese | Item | Type | Status | Source | | --- | --- | --- | --- | | Treat `pi-ui-extension-patterns` as a structural spike, not ordinary UI polish. | decision | persisted in PLAN, reinforced by conversation | `memory/PLAN.md`, conversation | -| Built-in command suppression is now a first-class spike question. | decision | volatile; not yet reconciled into PLAN/SPEC | conversation | -| Dynamic background-status chrome is a first-class need: observer/reviewer/reconciler running, queues, reconciliation needs, new stage/mode. | decision | volatile; not yet reconciled into PLAN/SPEC | conversation | -| This Pi harness should serve as a realtime test bed for extension changes and reload behavior. | decision | volatile; not yet reconciled into PLAN/SPEC | user conversation | -| Chrome replacement/update seams are likely feasible. | assumption | volatile, moderate confidence | docs/examples/source audit | -| Full built-in command execution allowlisting is likely not feasible solely through current public extension APIs. | assumption | volatile, moderate confidence | source audit | -| Rich custom TUI affordances need semantic RPC fallbacks because `ctx.ui.custom()` is not available in RPC mode. | assumption | volatile, high confidence from docs | `docs/rpc.md` | +| Built-in command suppression is now a first-class spike question. | decision | reconciled: `D34-L`; strict exact suppression requires a Pi command/keybinding policy seam | `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md` | +| Dynamic background-status chrome is a first-class need: observer/reviewer/reconciler running, queues, reconciliation needs, new stage/mode. | decision | reconciled: `D35-L`; core wrapper proven, full product walkthrough still useful | `src/brunch-tui.ts`, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md` | +| This Pi harness should serve as a realtime test bed for extension changes and reload behavior. | decision | still provisional practice; evidence should remain tiered as raw Pi harness vs Brunch-host proof | user conversation, Cards 1–2 probe evidence | +| Chrome replacement/update seams are feasible for the POC wrapper. | assumption | validated: `A10-L` | Card 2 unit/raw-TUI/raw-RPC evidence, `memory/SPEC.md` | +| Full built-in command execution allowlisting is not feasible solely through current public extension APIs. | assumption | supported by Card 1 source/RPC evidence; Pi API ask recorded | `docs/architecture/pi-ui-extension-patterns.md`, `D34-L` | +| Rich custom TUI affordances need semantic RPC fallbacks because `ctx.ui.custom()` is not available in RPC mode. | assumption | still open for remaining affordance work; high confidence from docs | `docs/rpc.md` | ## Repo state +Original snapshot when this handoff was written: + - **Branch**: `ln/fe-741-graph-data-plane` - **Recent commits**: - `eab91dfb Restore ln-judo-review skill` @@ -497,29 +521,43 @@ No `ln-review` was run in this session, so there are no review findings to prese - **Dirty files after writing this doc**: expected `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`. - **Test status**: not run; this session only read docs/source and wrote a planning/handoff document. +Current snapshot after Cards 1–2: + +- **Branch**: `ln/fe-744-pi-ui-extension-patterns` +- **Linear**: FE-744 +- **Relevant commits**: + - `4b1c2604 FE-744: Document Pi command containment evidence` + - `233c2cd1 FE-744: Prove dynamic Brunch chrome wrapper` + - `ee3faff8 restore provisional plan` +- **Verification after Card 2**: `npm run fix` and `npm run verify` passed. +- **Current update intent**: keep this provisional plan aligned as future-affordance inventory; do not treat the original repo-state snapshot as current. + ## Artifact status | Artifact | Exists | Current vs conversation | | --- | --- | --- | -| `memory/SPEC.md` | yes | mostly current for durable architecture, but does not yet include the expanded command-suppression/realtime-harness-test-bed detail. | -| `memory/PLAN.md` | yes | current at frontier level, but `pi-ui-extension-patterns` definition is less detailed than this provisional plan. | -| `memory/CARDS.md` | no | n/a | +| `memory/SPEC.md` | yes | current for command containment and dynamic chrome: includes `A18-L`, validated `A10-L`, `D34-L`, `D35-L`, and updated `I19-L`. | +| `memory/PLAN.md` | yes | current for FE-744 branch/issue, Cards 1–2 progress, and wrapper/RPC obligations; this provisional plan remains more detailed for future affordance inventory. | +| `memory/CARDS.md` | no | exhausted after Cards 1–2 and retired. | | `memory/REFACTOR.md` | no | n/a | | `docs/architecture/pi-seam-extensions.md` | yes | contains earlier Pi seam analysis; should receive or link to final feasibility matrix after spike. | | `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` | yes | this temporary/provisional handoff plan; retire or supersede after scoping/spike. | ## Next steps -1. Run `ln-scope` for the `pi-ui-extension-patterns` frontier, using this doc as the in-flight input. The scope should be a thin spike slice, not the full implementation of all wrappers. -2. Before creating branch/issue work, follow project protocol for a new frontier item: create a Linear issue in FE/brunch and a Graphite branch, unless the user explicitly treats this as pre-branch scoping only. Read `docs/praxis/graphite-workflow.md` before branch work. -3. Scope the first slice around **command/chrome containment + dynamic chrome proof**, because this resolves the highest-risk product-shell questions first. -4. During the spike, use the local Pi clone (`~/Clones/earendil-works/pi`) and the current Pi harness as a live test bed where possible. Record which observations came from source audit vs realtime harness behavior. -5. Produce a feasibility matrix and either create `docs/architecture/pi-ui-extension-patterns.md` or update `docs/architecture/pi-seam-extensions.md` with stable results. -6. Reconcile durable conclusions into `memory/SPEC.md` and `memory/PLAN.md` via `ln-sync` once evidence exists. +1. Decide whether to pause for product-shell review of Cards 1–2. Review question: given strict command suppression is unavailable, are autocomplete hiding + effect blocking + strong Brunch chrome sufficient for the POC? +2. If continuing implementation, run `ln-scope` for the next remaining affordance category rather than command/chrome again. Strong candidates: + - structured prompt primitives (radio / checkbox / freeform-plus-choice) with semantic payloads and RPC fallback; + - review-set overlay action semantics (approve / request changes / reject) after prompt primitives; + - picker/list-selection modals for spec/entity/lens orientation; + - message rendering for establishment offers, review-set proposals, side-task results, world updates, and mention staleness. +3. Continue using the local Pi clone (`~/Clones/earendil-works/pi`) and temporary `pi -e`/raw harness probes where useful. Record source audit, raw Pi harness, Brunch-host, and RPC evidence as separate tiers. +4. Keep `docs/architecture/pi-ui-extension-patterns.md` as the stable feasibility memo; update it when each new affordance category is proven or rejected. +5. Reconcile only durable conclusions into `memory/SPEC.md` and `memory/PLAN.md`; keep this provisional file as future-affordance inventory until superseded. ## Retirement rule -- Delete or overwrite this file once its volatile planning state is absorbed into a scoped card, a spike memo/feasibility matrix, `memory/SPEC.md`, `memory/PLAN.md`, or a newer handoff. +- Delete or overwrite this file only once its remaining future-affordance inventory (Groups C–G and the open questions below) is absorbed into scoped cards, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, `memory/PLAN.md`, or a newer handoff. Cards 1–2 alone do **not** exhaust this file. - Do not treat this file as canonical product contract; its job is to preserve the expanded exploration inventory and reasoning for the next thread. ## Open questions @@ -534,4 +572,4 @@ No `ln-review` was run in this session, so there are no review findings to prese Paste this into a new session: -> Read `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`, `memory/SPEC.md`, and `memory/PLAN.md`. We are preparing the `pi-ui-extension-patterns` parallel frontier. The immediate next step is to run `ln-scope` for a thin spike slice focused first on command/chrome containment and dynamic Brunch chrome, using the local Pi clone at `~/Clones/earendil-works/pi` and, where useful, this Pi harness itself as a realtime extension test bed. Preserve the distinction between provisional findings and canonical SPEC/PLAN truth. +> Read `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, and `memory/PLAN.md`. We are on FE-744 / `ln/fe-744-pi-ui-extension-patterns`. Command containment and dynamic Brunch chrome have landed; strict exact built-in suppression still requires a Pi command-policy API, while `renderBrunchChrome` proves Brunch-owned chrome projection. The next decision is whether to pause for product-shell review or scope the next remaining affordance category (structured prompt primitives, review-set overlays, picker/list modals, message rendering, or RPC controllability). Preserve evidence tiers: source audit vs raw Pi harness vs Brunch-host proof vs RPC behavior. From d7d4655a81440dd35135c2832369801a077d7464 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:22:36 +0200 Subject: [PATCH 005/164] Tighten ln-build artifact cleanup guardrails --- .agents/skills/ln-build/SKILL.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index 40f166d1..df012268 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -150,12 +150,19 @@ Before finishing reconciliation, perform a quick cross-skill check: if a later a ### Retire derivative artifacts -After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones: +After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones, but deletion is narrowly scoped. -- `HANDOFF.md` — keep only if unfinished volatile transfer state still exists; otherwise delete it -- `memory/CARDS.md` — keep only while queued scope cards still remain; otherwise delete it -- `memory/REFACTOR.md` — keep only while unfinished refactor steps still depend on it; otherwise delete it -- Do not create archive copies, numbered handoffs, or completion-pointer files +Default deletion target: + +- `memory/CARDS.md` — delete only when the execution queue is fully exhausted, superseded, or empty after reconciliation. + +Other volatile artifacts are **review-before-delete**, not automatic cleanup: + +- `HANDOFF.md` — delete only when it contains no unfinished transfer state and no future-context inventory that is not already captured in `memory/SPEC.md`, `memory/PLAN.md`, an active scope card, or a stable design memo. +- `memory/REFACTOR.md` — delete only when every listed refactor step is done/dropped and no future sequence depends on it. +- Provisional docs outside `memory/` (for example `docs/**/provisional*.md`, handoff plans, spike plans, or exploration inventories) — do **not** delete during `ln-build` cleanup unless the user explicitly asks or you first prove that all remaining future-facing inventory has been absorbed elsewhere. If only the current card is done but the artifact still contains later affordances, open questions, or scoping input, update it instead of deleting it. + +Before deleting anything other than `memory/CARDS.md`, name the file, state why no future agent would need it, and prefer asking the user when uncertain. Do not create archive copies, numbered handoffs, or completion-pointer files. ## Routing From ab7f6fc130cd8d740db86664337aa4b9b1596c4c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:26:29 +0200 Subject: [PATCH 006/164] capture brunch ANSI logo exploration and decision --- assets/brunch-logo-quad-56x18-240.ansi | 19 ++++++++++++++ assets/brunch-logo-quad-56x18.ansi | 19 ++++++++++++++ assets/brunch.png | Bin 0 -> 280046 bytes docs/architecture/pi-ui-extension-patterns.md | 24 ++++++++++++++++++ package.json | 3 ++- 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 assets/brunch-logo-quad-56x18-240.ansi create mode 100644 assets/brunch-logo-quad-56x18.ansi create mode 100644 assets/brunch.png diff --git a/assets/brunch-logo-quad-56x18-240.ansi b/assets/brunch-logo-quad-56x18-240.ansi new file mode 100644 index 00000000..b5dc6e78 --- /dev/null +++ b/assets/brunch-logo-quad-56x18-240.ansi @@ -0,0 +1,19 @@ +[?25l   +   +   +  ▀▀▀▘ ▀▀▀▀ ▝▀▀▀  +  ▀▀▀▘ ▗▀▘ ▀▀▀▀▀▀▀▘▘  ▀▀▀▀▀▀▖  +  ▘▝▀▀▀▀▀▀▗▗  ▀▀ ▀▀▀ ▀▀▀ ▖ ▀ ▀▀  +  ▀ ▀ ▐ ▀▀▀ ▀▀▀ ▀▀▀▐▐  ▗▀▀▀ ▀▖  +  ▐▀▀▀▀ ▖ ▐▐▀▀ ▀▀ ▀▀▀▀▀▀▀ ▐▐  ▗▘ ▀▀▀ ▝  +  ▝▀ ▀▀▀▀▀▀▀ ▖▀▀ ▀▀▀▀▀▀ ▀▀▀  ▗▘ ▀ ▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀ ▀▀ ▀ ▀▀ ▀▀ ▀▀▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▝▀▀▀▀▀▀▀▀▀▀▘▘ ▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▝▀  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▀▀▀▀▀▀▀ ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▀  +  ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀  +  ▀ ▀▀▀▀▖ ▀  +   +   +   +[?25h \ No newline at end of file diff --git a/assets/brunch-logo-quad-56x18.ansi b/assets/brunch-logo-quad-56x18.ansi new file mode 100644 index 00000000..0dbafe1b --- /dev/null +++ b/assets/brunch-logo-quad-56x18.ansi @@ -0,0 +1,19 @@ +[?25l   +   +     +   ▀▀▀▘▘▀▀▀▀▝▝▀▀▀   +   ▀▀▀▀▘▘▘ ▗▀▝▘▗▀▀▀▀▀▀▀▀▀▀▀▀▝ ▀▀▀▀▀▀▖   +  ▘▝▀▀▀▀▀▀▀▗▘▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▖▀▀▀▀▀  ▀▀   +  ▗▀▀▀▀▀▖▝▀▐▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▐▐▝▖▐▗▗▀▀▀▗▝▖  +  ▐▀▀▀▀▀▀▀▀▐▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▖▐▐ ▐▐▗▘▀▀▀▀▀▝  +   ▝▀▀▀▀▀▀▀▀▀▝▖▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▘▝▀▀▀▀▀▀▀▀▀▀▗  +  ▘▀▀▀▀▀▀▀▀▀▀▀▖▝▀ ▖▀▀▀▀▀▀▀▀▘▗▗▝▀▘▘▀▀▀▀▀▀▀▀▀▀▀▗   +  ▘▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▝▀▀▀▀▀▀▀▀▀▀▘▝▗▀▀▀▀▀▀▀▀▀▀▀▝▀  +  ▝▀ ▖▖▗▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▝▀   +   ▀▀▀▀▀▀▀  ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▗▗▀  +   ▀▗ ▀▀▀▀▀▀▀▀▀▀▀  ▀   +   ▀  ▀▀▀▀  ▀   +   +   +   +[?25h \ No newline at end of file diff --git a/assets/brunch.png b/assets/brunch.png new file mode 100644 index 0000000000000000000000000000000000000000..c24918a089175a0d31d3c783dfdb13638dd5b282 GIT binary patch literal 280046 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[ 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%0Df3MRc1rcz|mZhK0b?2P*K^U&tUi;eD zKKTp3@C#qV%p{*!lM%VT@h#8d@TuT-#a^!fGv}`m>#^3|E0#5|ErP~#7z^U47m!Ey z=yi+KpE-lYV1UnHc?U=td~==vCxcKGqj#3o)%_Zer5i14LUFd$nq7X=mVy4xpE|;> zja6xIZrk+!tp)jG_CGoS@@XCE4htXs(t~w}VN@>BhtgHvFC!iuAO+hqBUmEnsPeK1 z|Gv2OJ%8-92>4OaQBUvAEeTROB7?=k@=7=XZYR*MIXjfAf1k_`wezkA3W8M`!1jt+m!` zEa<4i;(M{_0P1AfI1ud zX18pJ)%rVM6`a^R((0m(--Ab^AN4S1@NJ;>*hHlH8jS>yi-mUy;+t>Al|PhC2P;o5 z1_0ERO+QpWy~IN*t`}Y1vc2tXZ~G&^>R0`$7uHq)q~pWIZ2HWLzW&}PIk%JkCU1is zlYz21IW$J^*x;Oh=4k75*cEUq=+$|oj)Mc}OAso0>JG$*=wD+T2b?$MTL5faxS9S& zFMX%1yQ^0NeFDteN-~1is+Qo5FZLsI&L^t@6Rv;$^O))w;Jq%>C`)yR==toA=a&5l zFzf-qFFw|ftnL}{{`bbs8@}yU`&aL1!FucJt_^kYwGMrBb>D^<=^XXMlmgs024rj{ zUYK1Y-tE|NPKUNrpt%Bt)E{Foep<5pI^SW~_E@+-TUm?m0!A%MYz(noVK8k6%0uE+ zy^fu^V8!>KI~mlfdpO;!P)7{JGQKC4;kehmBh5vgZ}j4ID6E0#EZCrjMF- zSJoWWIaih_r*v|j8FVEV;dYI1SZDG0rEm1(5HWVP;y%h}p3{h*__(}$#qYVDzwQ(I z5oGERAtE8h-bNe+z)2k_GTMje(AYiWPP0IOU8A1M=n zex!FjPMoK({VF*%W!MmicNPQ9phZBk8W6Vrv~T7iN!mBHSdhX1==F$oCa`f z*^WV98D7@lNX0Y+SRgMFgv_D`19J77UhF%@dCz;^^VgpJ>}P*}f%K{_CS5{Kard(H zo+yNSHfpzW(UudZ9DF}u)LBoR8FhVrq?-Fk zG3(UFpG+!H--)+7_inOI%c@sUrr9yU3Mh@pxRyJ&ZCtJZUaiDC%kP!8#^0*O$%-~gj@PGxxE06jSAo`wnnE0MOTC{|} z^~^rUj>)WNV2^Kp{IA}MH$Hd^uG;EfBLj1A#a3l1;vO+-Brl9R1K%poF$1miEG=qfYgOSK!lGjA!x^c?(j#VKg}mh=#t#_js!sXE1JK6wXNi*m_Qz zg9~$?TF2t%a~$il9c7NuP8E{}%5QUB3Ygm+X4yzn$UPg$gp@MRuFFG39Sv{Xv86OL zq^dv&WTa)k^ZSIYy$951I%5)Xy9TyAXDp)b6_XLUo+$5XAdd35Zr5DHXx!}+q{nI5 zeE@RS4R0(CTlw+i;AZVWS=}FNs@$}j3^FHxb~WJ5>Ug{1owGZxz{@h|TO(h4z2n^U zxk?n?jj^%JzGdD40v2_53(Bdjh`M{G9qApY?#30{PXAojs%6H9`j$aAz z*WdJO5z)Cn0q`yU5l=cOQxW%==?bkFbg~5BP7LzCr#$q(-XV5yyY$b#c7T4KV@Sh0O}Q> zug)jW3U-}a31KKxSa*)MOg8eaAJ!t}c5T|vqQaOGQwfNB%chk z|IRR0;dL&gwM^&SGw+@ux4?Qgj&XgrW8FFILAw{R=OBIc`=Zs(S82eIQ)B>6K({=g zMOx1WfH(VK$&ak(6(c`J2hh)ciIHR-_aj-qY|eX!O1gRLkKDu$zxG!Avg)l9(|#Zp z(fCS9pW`6Fo`HS>%x7O^ut|hojD`^ls!Vue0B70uOsr!Gx_OO8zj*_=jnDUm?zyob zZ{ifj+FL#6Ic2?9G2KX;cALKG#>pk9&m%?B!s)WJ8i7biSXX*_u#j|cgCb?`St>&P zuIJ*ud48$WdLCl7ODGa3gU9lh@jDwW6!y{lz?TT#@b+i8j=#gN~VfYey<_yHA zemc2et6ZLOoOu>`fBvUlE?@Myx08Su?j9_78*vN107WfV4C2kGyoHvHT8~+*+Ri;0ode-+=h#xTB3wI{i>qJ#l>%%%JWznYDmVAp4j)!} z_73Db;AqQDK34IjEJ)m|%{1yPfOfvvH=ugAK5myNbL_X4RkUZ%_i59A|16YLzZ`LM zw4+C#zns*PhgzccH|H03os6l9m=k==sdg2N@d`~6rJ@$lWiM@HxQ-> zLsYtaMMXskksN6NDk(~j6p%(>3>Yy$=}?hQ2|;RfkM8asAT?mXh{0eZzI{LKA9$W; z_kCT*d7Ny%;SA-w^b=A#kbLlRk%-e;!jHMAwb;|K1zxGYKT84aIttipPkwddP>~j)ZGN&4zTU# z8)qwDq>lkV%8(0naoN`qsn<@8~#?eesfp7lW7F%_bB50% zm2NWk87yTMWq!32XYoh5VJ7mha)2iS-?ER$Y;hou$Nja>bj{%6J~n5P`sCJ0Z1eBR*N*# zUa~sv>P^z7b;_p?;JXJ!#@*)QGjETc--T0hA}NrKd$tum}XMNmv_cM5}-gZK=_*T+g$NR&QN?!?KPW%_iT3Ev}djgm( zx#HQrTG|&$=mB@KAklvEZfR;4Q36-GqbjX?xYcM3oVE73A*hm{JkH}E(0{7!Rfc00 zq?A*!rF-gql_ z74x~jJdX@=g%6C1w@;V55OfIj+WwhH*hoxgeg9Kk)p6^@klJucAgCcwL>|!C<0L&_ zXqmwaJT*at)oEOAXhrgCT0M|W3UI4=C$OXtw=hu#ky~CJylLsZ5w&kvk${!8_Cs9? zD5$ZI;n>`q2IeBNd}Lh(_a|sqRUwngs$-4<2afBt$))otkM2}(?t4oa37cF@RtO`1SmBsrbN zIeDUPSeg#c93W!v_crIhR7O+o4ZkSMkf8#4D67?b-mS3&BLxD65)OJ3yaucA5uzxQytSmu30c8cD>tkVjPe5YkE;A9;oq5hyTvm zosv_UjQ`d%cBf8$(U@{GawtF(-6pkk`|!dKx}>+Rw&sH(x(8U*_-YzY~+dqVvx}!|W zcwWh*wMTW-ZXdwBF1|GziTmIUowJc1nXJ@0|3V${lFgbX6W~7LW=!Y6ZxvNh{PFb; zN`#KTP!3U*ZiwgsFY@_i*6Gidkh?)GFrLlo!F>U^4GyCiZF_Iuy{?GQfUJS=BS z98d6Xza{p-f8uFXKiR+Jqb}@QWN+EmvPBlK!F-;Lwg1emfxfH{vu^a8%!LNwDx*&~ zM7})tNcmK7B=?T~`%iOuS7#FAteYLsCm*YF)#_}$IavHNP z4_Ve<`s`0vPhM^@vkhfrQWE9mKJq{>|KQFYglFaB`PR69X0M{yVP%BI+kPOuU^bZ& z_HY8L6$Ew&xmD8ayM0t(-uk0C;t>m``aMR#JJ7!Ia0bA67-y0v=N+e`^m>X(9NeqFX2*2 zr5BDS9h>950FU|TF-2X42=9uw`-eX&AD1Was6Xy}WbMlyZi~oJ^E)4xRITQwNZ6SkT|}3pfUX_YaDfn^Ut0mP?3*1@{gRP_ zyioo781)Re)t?!kNA5FrsrBw16LLoDtT9pkKt-W^i__-hJbo6h077rT{-?izpAVfB z`h6?3OXQ|F{KebSrr03gi<&IOz1q8&J=+Z@p)G&ya(8@cov;bG)FwB}ksrtQ=NHb3 z$K7$FdvOgEcMJX{-5va{=i`z|FRRKms1~wtN%oaXvj}byGWxOm!xjqmLliv`tiqQMgt?BPvX+kT> zcJt-HcONw^TCiCRukIb&i9(gLgY%?VFMg|vDSA~g7qWU0nX zMfn~8lsPG@H+Ys!l%(^GW>x8J%^Q*lo;Q1<{}vptEcyxfp83_Txs6LPvO3bYcVz&! zC!$`@kI>TF4qgBP0f)aG2Sff^FD{LI;P--+f~!h1cAMA#ofU8%^R$e6{_vdqJz2^5 z0ApxBG^@;`jdYrHZ7Hb*&L}p|hYlPXg$$4)=Bg*qy(!m`>UO`=f?{Yv{jin7Tdz!wJj<>X?S!&xjikc56w+mDMkgI1^SZ~-7yWtGV?R?M9ty?<9g3#i0E zMt2&lUe+P*al|pW*}jcinr^{T4G--du}z}VX>0_ z7E($q)|rjWlfvb!NI6e$Ch}E*Bs!7LVm<_Ka&{-f!JfpT)mfUVm8BBoolWb=i4zWh<(ix*eYLO+BwitZbjLVfZ$OW_RXSMZ!vBVUzccwvJI zYE-_`_nE8Qqvbu3s%9AEjl1`E4?hc@HxjlqmzLQ4bHRruXQu;%sQ4)$y7BC0hL z;nsVfkJf-1uiSY3cL)40+c@oBdim8fpA+=CI)9?4gm{17BK$-*dn{iJ){cqF*B-t)y&3ef9#q?%u33qX2I zdnrP+^ji`YiXZZwd|mk0`Z;oy%an^uUxLXuZl47<7pO)3$3frw!w*J#a3ZOu@V+*g zuM7AYMD2&giw3%3;T$cvXvF%QzYl0nqHd=S94b3J8A$!YKB`D*mdc)Qx!AR&hH_$) z$UI6z6N!ZioDM(PE-c2tu(x;2A|GnM!!gqz?zEtPz8#el_dQ`Gj4bx4v+^jyhINnI;OpRHWqEx6ytOWsxvMz$F35=z9dW9uDylxDbek*nnldj&dQmNkWJT>QQC^e| zy%bhcTcqbp|878Dpr>yjwY*8E;gb1w%>sJC8wiz~U8Vc4_)KH%>I#T$U>f)si$MQild((+EjW2xmI(J3(5 zeA<|shdeu78h$vxOQ-oTTH5mn>dth>b)`@mkCGj_Y3q7@B?TjY8IuhG*1)})Ft<($ zbK`T|re|jz=ZiI0qo0`Z?Yxv)pCfjV&Lkf-LvaZ?;+>Np+<02*KDT+?b*}g{QWWda zmUoN1L)tQ-s}YKDCT*X1MArWLX0lP6Tll)UwtB1h2{|p}od{cQ+#Vfj*}6n~fObX~azymk_YJ~A-ovQRIeM_u*TAA$DCfK4ZbWE@r>c)Xp&<{m>xU=Xn`TYPz@;R)us| zHYQ?D1#N&ukI29a7j0neQ9!oi$|tUSGv^#w!%9#L<$5_>728ejGr2g>J>O|Mua?io zO9Du}vYh2O%Tvm?o!N)N?AacrLMxcc8{LM2F8JGwm$$j~uEi~0PYG00HoTEdq1_SY zg-Um1oAX=&oAuA`Vr9Zw)RW?bUdY*=X5^oym#69#*tm9Gl~le) zY37vt>z`IvbFCRfm&tWh_8+GQd#slHifK6hM7U{F&B?3f+GJC)f)v0WTG&LLVQfiP z^O3&midh%DQB`&crh3+CLi*UU0q0vNe$#kjBzzRaMBIs;ATGHL$rQ-dB^!<*+Ir@mgG&fGcSUhE0*M!BP_dv*RW<`Bzx z(rIR>Eh5>1?e2p8e(jR%F58uV>`Iqz-USP7OX>UPo@sfSn~CtWm(DfV(5#hn$8K8u z^=f!v`m5Z9wx*A*6I5?8TzxS?v&XT=rlwFWysUU$_|a`dtc%MWGmo<~#O)cNR4Z?> zO3+IoEB?xlay`2jKF0aLSKAtVgTB6)c2TnlKt!7Z*Uh}sTwB7P;3RL~Fu}3`m&L>U z6QuB;5uop^A)!Cg#NxwRQZ)7kI#={qW#6!-&KJw>vB&peWym){K!&PS^<_N!ok6}i zH8*dk51!%Tqz^otJg?bCE?&>Bs`%OXl`tQn3TuSDY7jauh*_p@l2I#ySQ3Ve>zx zc*q0jx=Ta@;jnW>QhrR)hoi%EY{JRw!+AS{-xmD5c=4zy-cgzgmlyu~HNcIZF8x+U z*R?@NME8G6exAjaS0EayfmlO&KamZ6`?{LX0%sQYwe{qE)_xX+jEE|6%edUXs#AG3 zXim7{Bfs*={_1wk9$0H9CA z6-AQFNQtt6<7#wh%!|LY{X(pptUDIGyjrKhZ9<#`;=Hfi#jxDK11oq70`vnxIN!(t z%#iXl$Jx=^RKdLy?}dHGCu^!0MS8iLiC2Z!{nt$=5E?4IQOBO_4*5Gk2}da)MCAn% zHenLY>$BH*+{s%b1|VLU$nFw+B2C#v&2o(vQ&n^BC$L!3M^R@IsG3Wc^ZA@pI-Qe^ zJ~%2^X^YPxVZdTh9!vB4X}zE23dYol2sT|iNVRiqowb-V_6Y{Bx5r$P3+p@iZSmmm zlLXSLC+tZ`C#8FYp)bl9svWvqn z(G9up#vzn;U#w(*HiavH!s4@$+0`Zc+oBWL%;&5t*<28I#T`%p4*m8P1dzTrXU)N5 zmC>ZG>ZMfD6sChSL=v(y*x}Ve6Y;iZa8143N*j_j#NW1q&q9DEfqIr)_ZbRZvB-V;vd*Bg1 zo#RU8+$(iAIKM}`hz1jT)&R+%Q^Xe%4*8F#hV%PH2!NdS+HVzkPWM2jzj0QMOlb72 zOn+!CFq+rHR<|Zi>o%k;!%R!Pl=t#6iegq2k~*#9@nrWY*x3Ml@haqtj@akzaa}oN z=_qbm*YF+xQih9cpX`ussIlerd=%U#<_NmOV`N;ql^+9|M~z}GTu z0)%)=x{6(QsJfqe`1wj_(nw4duQK0FJW?jo=4eyfjo6VZ830{r$W2?tF96(CyKXWh zJYg7?Xr>fOg>OQTk<#2RiSMwwvTkPMweNQW<7PAqEOWrKPh3^A>YBBK>RiNKQ@7e^8{0-W38(t?{}ew zO<9IfuXrSi?x(3m94lh)1Gb)oj*0}AtV=bqKDnW6yw$)YWvRe`b7)>P*2PrlE_p;Y zxn;xXQzc+B0Hcv3+PoKj_|}1XQ^WO0h@@I@jHGZJCiM!SbJ^?mvxiE)G)autbX1MO z9&8Dq%F2sL&m!Hk_0{8+ZLc5puV=5BI>qAA6rMenNP9y=XTk4ZK@+K<=^y?a-9-E^ z4MiC5$(UDJk&5y4+Cu*qE#~6em6uLztI0mN7I5?Tf$&2R@0RTmH|-kRhZsavsZklh zS5x|WO56Rp&_lWkJ}AteZFJJ>-)jkT{I6qo1|Emb)%Sm4nCG;+?b$fyJkcPU$GqDT zsZY@_`Kf5>A4m>)*--AT7A_vA4?Z&m2Q%lQK#smV+8 zai=1qNK?y&qBN2f!<<;+h2wnZN^PmG>C|?rLz8~_sv4Q$>Jf6fYYO0A6#V<Em@IFF}bj7dB(zrAA)Q1(lo|SiT<<1UUeN>ejpBRT2X;by%kGhIBAk?%2 z_(CLSkPm44X?cOGV1iXB3f>X)Tx89sbMTqBAjzrkm5dBb)jWae7mY6ZkZ$w9bPl`r zfr0G_-{Nh}EjBI>`lwjs`)I`^9e_YF5)$3BWO$$9mn^&qtJAsiy%?Q*dgI55%gWh+ z$+4x$anxh!7EcOR&&*qLJgAWRYC&0+GuA>uuz03-ww~ixpN)#3l~KP`AY9Lr_asOW zqVI!xUT<9>`8Z91dlCcceC*CGNv|qh+h--V=D$+H!;G*VGD(~S+&pDq z2Bk3D!!zsP6L;yl_dop$6JWM(#PfBg3_*Yeezw0-`LwZ#Q7=7l2bQ%o!h^dWB^r} z?$QBaZrhRr!oqV9gI?cj_;-&f=`L;d4HXg4 zS=*JLG6HSlHFuS65^S`dFcZ2wCKGAlJU+i%RrL4dv}v#Fi7wj=W1uxgN;c1vLKUUV$IPah^wl~2yb}sy zB$}<)kTUCrNH_@FPW2%M=DFojipZnXf>@~_xUI2*15@*f;j_*=Z-g*L8IP{&%I{7J z)w_pdL*B?Q6y}l7w%PeCF~rv;r8(%*Bb{{Tq*5}{235|cx$yXniCHgsK+hexRL-Sq zC(t~&*sZNx>wg?mGutqANLuOg9*2;~O-ou+GJpyI;cb>O*i>jq*1#ZbPT7#Y6Ihb0 zf&d)Y!HS~=6=XN5Aqpn34s(XSG!+&LV~|uUnpm@lyl|fO4t8yvE{x6{YH!qX{(hTB z(+>H3GnrpWKEM;W`9n=&1m61zv(FRh1?nlbbNrdUd6D~!>Zrag>+P$i3q{Kce%6** z^;tU(VdKr5A-7rdE(}GUWjt$UJe#G$K2c5}gcaClcTjl)UGH5B`~HN_1xloT>9R_l zRUEfSX0Cp$E6)A?eIm@qk)m+1%9n5XKr;0$s<;dtX*>r3HgqtTT4icPtJ<(fZDOHW;p(e1P@4o2P9lo8&2^yxuz3H8>0^CIf?Sx|alyE4l; z&jX)$Fn_6G2}0;3t?rG0X(S`DidO*DQV5mlgGQv1x#*wQGA_}_{2#%@OHL&^XpZf?DSmCpt6h5P4wsijPv zUVenh7YpB6t;Nl(ZOI5pOBPO*DN-@Z0u#c7EgEsG-L__M*$oe?y^fHxXkQQj-cZi_ zZ9mIZQ7hF4b-;dXtJ7wbOlO~VJrKRgNhPyOWQ*uqk_~TxBpe)v!g8K$7K0Rc6AvS& z>+8*3t0XzHsHbWBcAo@&(OnUq0{4s3TaJ87sn^V6SZg%ybt^2H8o=iMHAv97*hNPM zdep{m3O@jr7N2_;+Aub^R1f|h@^h7FX!qDlG;L`8-OPoO2e>Jm(Ks&dQQ(6mjgP_q zhfUDt({5Bq*-&&$r&zLqkFqj)+O3jQEI9i>Pw2aE;K#1fdeKd0(=kl6^v!OX@$2uH zQuKrGJ+};M+S~Jmt3Ucq9@{gvqwMsqW@G|F`Z0qC`o*x*m1(k*w`dONL_O8s-hcaN8}Y;4O0Ej^y4a?d|*oFv}eHs&cz z}_$9Uuy7kafoKq0R6ERuz27rr^0|HXo5m90RRo8rFut zblJ!ikP1UoeUo+pT#n!BIh`s_TG-> zd6r*9v>@rcunx;G?4;;enJf5g+Y_qDdnWgV-2c-W{ROp{14p2a1WHNVS&d^hpc055 z=n!fty!uObqxTQ%M$jX9ngs31p+wN0ZaoK^dnzI-*5m5X8@{GfhMj+q#(#g0^PRlt zg1F+)?~(AMB6Z`j8rv*}Bj$Nx9m#U)Sns2?$^3v}6~VWETm(1;^awPA|M1}LQ$*=| z=^}@w>(Z`Nsa4AY9Z-BI2d)r1`aMU*53(FV<)tepoyX*(Bhihr7%*vjbns~W?~2?3 zDzx(*XQJ05#y{WaN;WO;%s1Dr+<084@65Uf6)|lbwW2Q;xM32v0z#A8^4iMMnJ-C1 zIbL~E+v14HNk9z#I#Lo6=2#bCs};?OLrC%rCizP@lk>3d6Ml>gkDaS*_*gbIFi*$F zZQ2xje#SP$OA(h$<=EKYnL@(;fZi=>$@p4-Ww=uV>=yU^G{jxPZ{f;<74OGSS>a*t zxM7n@vy<4)-EM6x?dDYsaiOErC!M>-@N$*a4=#KgT74>NgBYPt$O_i*d0)<_2Gx$Y z`=~yvK)T`I^n$$4d%2Gy4>O(*qt*<}CKB@j^@iROep{>8I{q-~KyfM>I%w))lwQX| zY)^3lP8UrmmB{|M4Fy83;&XH za*VBN@%_@x)CqJLhMUt*7$-W(X}x_aZK?vMcCj5sN^a&|*K{h`_VbZoK^UnH7%@Q~8P8vG z+ZfvArdM}B+1Uc6eLMgw0LNXr%ssXr)bc66%4o~L)xnBHVSQ2 z*UVX?a;^jn2kVb&7M1taEIT@M0^FM=70R{w`z6iy&b?FO-8y1()g;ia3bo<2V}G=* z&wY=EU;2@Af_<6k6o7>Vuh(@n7y;1sX={4ZF3OtZiPt98L%NI=McO64U;(Rq{6niI zonbb~z+{GysOHCjy(`-ius(P2%^i-a475{3_lWxpct{Q$(D9lDqr2pV6NT#g%wflf zy~|@jN!)XV;?7-zo34yrXCmCj617nt!!P9>u&Ankm5DCQEBtCp=jxHmzx{?PQO)%~ zwcfJTRICD3OxV9u*XVltZ{pFf7EE+7Y2EE7z`5&UqtRCi>n&Xt#*&zx>5?bd&lZw&(_GSc9)k>hfEF^nW2u z+C0sqTYz!Q=M6Ep4cgOY3>L>O zFdN9pUj=yFIu53)t8&gWT{?u0edKpC>HqDcIyN-pB6r5rpgf#B22S}K9Z|mkx#`Gm zJO(Kx11eU4fuXH|pYNWipU#U1dQBBra|?DZl5Y!q(?<!AWC_+h`w0^O3tfqFtlbnzB)BU=~noo`ES>ceuy#4dx7-)Wyb zIY-FHrYD{l{<=v@4<6|9_(Be?|53mG02^y9Flau;Pl>N4`*sWzHD;yzZsu2qnT_X0 zc?pT5TkmswvrGc>aQ?KD3uYtw6$S_moy7^UWUqCvCL*Fq8(R7(CMpO1qi~mbC6j^K z*4bk1Ji|IL-LK_T3){AtTL2z+7RRgB1rpXt*M6^a`?3Z)7dzD;xVW1w6q0 zUOG>&S_D=7_7N6V{%p0w23i(2qp2|Kx0&w_xAd6MM?GUo6cWehour`lZK1{tgNm9O z@VAs<4mI3&_0r3O@?82&?u$A(0yeGcu8*lvtjX2+xxrGc$RKTXYy7+XcZlA5yZkDECo;Avvb>f)W$Ud0i%IM;xOH5L z%Ntg*x-mm%q?inLbHNV_l9#(EX7p`{trrtSJz8SFc7Swz6)o^|ck(mi4J?jUNRIkmC#@ zx2fFm;?-bv`r%2UoLJazbG$UF>GE2M27fkyz){|kVWQ|yR(rkFv`GQP%Z0WNk_4at z^sy!K;3iD>yJOu}jrqz_rUJQJpdPG(RiG}3Qk25HyZZVVZ$3TpMtqy_l*W51a4M0Fk72XZO}G`vdNzl$vsdIr5lly*ODNkifS)X!C;#<~A0qcDXEcu}nPcjy~&d z`EzJ_&z_S0E(*$*wm9ONrdqCALgWsi0nbIgR@DUoD&Q;III6?hpTMisL^-SbV4BG* z&DJ-Za*h0#%bxB~iTZuYs0Crjax?s==-n|%=7mDO&Qihh1#dWT5^vS6&D#l<-Yy+{ z*+s>XLC!1wGaYV8?7DSZ!kEzz_OFcpGLawEfqj%K>f3U!_L2Jgo>I@(8x13W;}+srF; zlxANzbqnOBD_zRWi(bdZxFSXAUfrOLhC#WDuS&8LoRd)UlZf(KCl9BX$ezD)@?x2k zChJmlT%3LE=#Qlx5B90Pe2|e)`bD$y#lBZW>&Q)6+i3@8J3KS>1#`AQpl^~R$s)%( zF|1AWL18U`Y)jt0o^cs^8w-ovLIn|w}$H0ZQrIgPJ z{a>H$NWf9+R<}%dMl6T$crSwoYP_!t?O}uM%26!O7{5DYNTf z1?li{$aE~*-b2@W&S>~yP&8h1F)!Y3uTm>b&kgB_x`8~{HTFO_DHkd&CB+J*^HQ%4 zdWfO|9jJCP{Vi#Ze{C8?*k$gMj7J|k+})9&*;G-#OJ(99sB51Se*r6GQi2wtSN;@@ zwq~zr%Z4^u(D2~!`N@rhqTR^_eMSKHi-VsJck6N=9Dp^2G>u7{MZKnzOn$*?%JSSs zI!~mwbBlaRWoxh*L{PebttYtXZwMSKXEW^uP!9FO5>FAqqGaKt@Fc;vV%U z&%IN90UK4r{?7c+x|Er0Ut9y+>1lM5as=3`1UZBL0_| z(G#Hk@w3g9>>F%b4l1sw!nX1thVP4IggSYf^L7^ZpHzgo@E)v-{BJbVz4YE$=tV0r zXf}Ibp{`-TQ&J%oW)Fl+J-BOn?M(Um&eUyy$EWr~xpkSQEVJS;wCV>p&|&^k<_{hR zE8d7s5iX07;;9~K`W1ak?ClukYVMSAL;s6~O}qm7#aO8!M2~uEL9qG4;r|aM zWXlb+r+Zb;s&jPwvcaT!nT;w-_17aE0WwG*g(Ki>j_f|o@DOkq6lg39Xezs==Zr50 z?-}8#%-Nz2%;v0C>ldnnv;OJbZF)QG48kFu8Qz#h-slE4nT|1j%ExPW9`UPul{ zT@2`R?hray3J!{u1?$$p?WtN$Im-OkX#ufQmTKmOC!* z7NY3;D)ZYeSTpf4ubr3!2L!jpN4MlL(6U-ZDHUkfeN&3F_DXjk`uUdXz;X}u)_?}z zJPl_qcalU&+N%Y`8gt&QtiBA1Ix71*&L0JLq;JxV7W6W7x8zfQ2vh44GXxcy#4VT* zS}7i;p^2A~``@Y7+7GGcY1$ztg=gRrN24klXNPT==?)&;sFW`6zkWtr1LwxBiTk;v zGwGl8K4BxUeF(-V)`m^s6ZRPdxPY}&MF3)Fm*->tKR-en#}+ ziC8({ad);h%j;#&mis1cDxR#RaO+h>td{OcMxls2i-nx6Vbf?t->nP1W&{s~soB2e zUQ&JHiL#^0b@>cvJ4P>FjJF9io&KG}`7KNKCn4;>{gqJ_tM!0GEA!pn8VROA{>9|Y z+VVKcU5HfP38*m?SH&*H8Mf+me(0MgE0;)2Z&1h}qJyG+nzQC#J7tDul9Zm@a}<45 z>4pB1ZL9LYeIz*Kfw8X|aqtWz>E9@d`S{#-^}JO{YV0aRT{s=LBV2OPqT%K9f>%EUG?jmhusqto_OIZUsVw-|5H)ntts$PO`{+2+ z*WyM_ytu18&56Kp{(07(KFIK^T3DGV0$2s! za~EY{ZSm0K#O;eI5$$`t(ClCNPDtOuX=x&sJWw z0MFV+-rPk?F{>U57UxTP7_5aVd&kS|ywH9CA^#8W@*mZ)-VRqT_=0kH5?68!$!;2&ic6P!(!CFDWMX`s62^J%5o z&XIlSyb;#M%xJ;{@B1{3_~jT05#4{p&9?t*Hv+f#IyAQ0lq@^86fwyR$kYbc*tW7n zRX#*4I2J8)Xh3UQl61#Sr-AsIFJ?0g^4l>c7R8+2b7q|z#$-Qv8E@Pof^?#LS@s#m z8XR9?5X#SxAZ7(6tEB2jrmqp2Y|xa>WbeHK$HsXzzxftKQ9YcssJfAZm~Y!Dh)CLp zxIS_=q53kLRi1WUMl2~Fyr7Za2yuDX$2%ly^Uf2zkpR!3Neu9Me690W8v)2yFW6Pe z;!wF55~wJ7@j(XNT3fQ@sI=yl^IjFlh4RZ`k~`Sz~Nv1 zy$vGOJap#o93j~^q(*wF{%sT$eUU4ET#qaG0_n@-F9Fv9f6f0|%0cde^r#?&Z@ils zOsWrW;zxFRyxWUM$~oLMOq;uj-x8mkuQp!=H$Ku>#?A27R}Xk??Pg&P!R;7kLz#@` zx!v9ZRG?>(hI+6HKip4naF854ag;IGT`9f{I1t;t@madDX-c1c|96`AxGC3@IwYYt zx{L$Q*|g6okG&=L@0KyO3jgs?^OX%OO+OQyw@?}Y_36=Y1}hNH@AD}7*S)*(YZ~z* zU99+f#6ZU)zkX=3G(E|oqma6gJB4qbo?`W=>okg?8~{h4v$H@_Z+Su!?i3#6$&V;I z%LDx>7vEB$d=Yt;uiK2Bww*QBe>SA_Ve(_P6z?Rg5o2q}9@F=DRO^DCWe8(#R!{`4 zL(0aCL_eXr5$?b*j)jVfZS{A{)X_u{tKk{wR-J#kdXmhMco*8NFO9!x|JVIJ-iUFU z#}DzjyUey%2dh9@cy7+Yc(`)34{;5ZIji{P$XH;TvxIBL=eX}_yK*Vf`C%q*3vR^t zPjEQ>xEa?fs`T1h9cfzqpmW*LXS~Ios&VEe;Gu%gcq){A%vL@vwoA$ieVmSImhpl4 zk<|_Wx#f|H8r?c0mVRTLb8XdUnA|ye@?fsfDyHKfMQ#2l!{fPQ`f-fhG9RW>-_Q?q z+b-206`RDh)XP?K&@U=T+IHEeGiFx?2;Q&2!RtSdiMK#gfZKWU>gmQiB?~4k{^-;H zh@KVEtCjP?I|tjYB}<@W?xp^P+Sf!u2h}%8G=i{ENYA}Vb+5rLuB6>T?`SEnz{q&7 zsdxCX*PQmX)kR5tj9!}==}o8KqP0J9VPa~vSNENK-HK~rUuwm9-w}p6$*0Z@F%C{@ zGmaz5PMFn!k_UoyxT563uK_;R$=tt`)5ra%TR&3~fVkD?j zIAw}g0$0HZq~KN~x%? z>wJ)a`}5A|{C3k**f{WfsiyU-_akO;?+yXuFFLbtG|$!f>=OvbuS6+_!d<}`qvt#Y z-uBNVT43Wv5uDH0U93_$Ba5Xeu;$~t z$r{_Q=(Ychy}~#pm`rk;0_@G?q6&DMeV*@BbbL2ra@0Ltc?e zxuSbvf5OJ*FlTAM`!89jnG&(_o?kfo^D^-;U!oJ!fdL-Tb^~+A9SBpKD~|KsV@;gS zsOdxp5@5c-hdEhAcJotv(KWtZn4Ee?+nuCHhvR#Jk}_UIpOLAlob!4VXx(6T?CqR2 zw@aGMmhi*Qi@C$1p!NQ{kR1x;koGpaZLig;Z&t&#ns&*`8~NN*jPL-EEWYzqR+rk< zu4x0dvynNKGC`a|51pQj@Loh+w6cEqMs-k3sppxk-F~Yv10FuT{RLZH7U^-D8>IBY zwlWeepPwQZG4_fv&GS_ilKi%c+nabt?2mmeC!yDLeolXb(|Pw^bFt9cn^)3BY8DER zmX0p%ZBzI)H_UX(Kxz3XPy0X7JvfvwQ5WH_URYOLN`pI8o0Xhfu#bGBs>fS4_Nqq< zv_s-qvnaV-4Q#2tvSl+=^9G*!Ma2%nU9DL6B%;gnLQ~g)IlhTNXzf(s$%Z5- z5mG&VnJGlM*I4ou1+5s#b5pwrRNLR*1&8Y4o&>t&F%O;mVOi8VPyOBg@5_je`7Y+_ z&|^a?nC0YtvT+SVG?YaDFO%{TSPr&)%TG668y|DO|8GDh40^8bcNbYlCF3Uo3Msc4 zFZEQcM#}gJ<6&7!P1)(iL916gGQ$OsL3T*oKXzKMu$OZVa80dff1gMSw+F3Owp*E#-zZL6?M#cvxtt<^fL6>9>`)GqiI>ex39q_eyCZg2wN zzxMRFg=^HC;&My%HwH7fa!bMHfyVV@3&zHtEHf$Kw4n}WD{^G$+$K<6e(EjP42hlS zrZ#RU_iRHJmt>a$xI9R5Z-yq?%`jU|QOdgns&=-mMK(fyk0E^HE4KQ$^t_v3g^l5Y zLh0$gQKi^EWJs65&287q$D#et?UbMMwCvz1RV=dJeexc4^h2TH;`=ST#S8sil%bXO zB49f|MvniJIrXX4$21v4js^A49yo|hfGzc(^?Fr*`ZRc!g`0n{JSbUp9uf|c{elKd za49cL3axM$I`HSs%+t`zl&A8c>Q)AnvgT*tH_+9xXhwReQ67Sbsjq8i%FQW>_~#Kd zp|yv(#be`nTu(f2Iw@azbA;Fp`3!C%=Vhk$*MKy}8ZIC%SMIL1k%AQE%9cEBzpGbS zVNxw~)T`QXyNtaYO%BjwOrI`js)he_vKlsgeUT%4tjd_Bz4P17LcRU=_P0F?u2dE$X+MI=cGJ;`cCoNA>jME51=hEk?Vp~B%uf}6a zs~sn&W2PIZy&P5)yKDvbYfQ(P3wZ&qmZQ*dbg$5p{Xcz_Vk_?jtj_}HmF$8XB3(#p zI;(=yMvg!X_#xbfP|%3HV~B>Ud~GO}(@x_Y9SZjOh( zQ3v7lJcU?iGNTI3@fLFT^+oPs0o+Cj7wrXlEB`P|9qjN1-0ySHCrQC)?IG3ttcE?z zoHi4NU0e0@HJy>MF$sSd{mV9yt`Xh;<`^ZYt|*YMY;9OR-!dC*BD`xN$okDWksVB& zEB&gdTooWDRN&EDD=EI)0c;+CBSNiV69#J%`RK6ISB{Z44wDcYVqX)D%?NWd?{Q6j zLfTf)n95g$Y_>0XEi~7bZ&#;Y9(;&9`%4xBVUF63d71gS-9;;27zaC}X+IS5r; z+}gxXyA;ixelm%C!?AQdB=EPvsRgK8s4HW4yV`XU*h>b?t##(m0au?*R@@jeFzdh3 zhTpb4wbjj7P?@@H?Y!(}l$G(u!)0LPM(Z?N+Z-rtw7z85;r^B{=XvMqW9gysh^z>Y zDazf}{l=gNbgMk7J}Sls6(eobBvUa}dOMF3QuG8JHSrCnes}wqC>5JQr`7G*>DuhF zquYj3Uz~d;s^vN9a^Dzgi?Jxbb8yy)EnQ&_@PIVey(nm;TIM{;bd`%Uz7O8ZU35JN z>52CshZ~=qoctE@YG^s`H{M8+(B*weOyT?fS4jQ*Ios4538NAQ7VjN*hIl&}Ha7n( zAppkl7nR%72te`yV6ckRVX%voUab~vy!p-!Fwzxn(Np<)J=s0q#;()bO zo6O^WB%0Cs05@2lIBYyzi9_d;}58NGt8 z&88tm2hLOh%HL( z)vD2IL`!Rny%T%ymDr;cNf0YSBK&-x=YKe_` zG}x`pq`R`l*f&&!W@j#1`g?MEV!r!Xq3GE0!SiLibk&az(2OX{n1UfIWaEcUd!qZ` zueAE$ckV7(<51BjTWXwqVU?${{5}r~#Et17>~rVgE;+#4p3S#aeItFn}-D%eHd$6-!B7Blsl*ws+?tOG(?)3*K;QT=yarczrtA0ZG~p6K zHvh_enAWK-IMm1%lr!;GOvt5wE1(6jhqj`tFtqvw_>To=k_bDbp6>(k-tgk9jT)#Q zrv$7_#*%}9<^4T~ z&s)ZZbKWztTmD>OM)CYEQHw=O`{h<3s8hMb-V8M@Xd*uvY{28?Ma9*<1%UC|NXT5~Y%jT8YnzsXnk3O{PnURS3CbGSFEu-w9;T=t>5XnXz z3Y<)7lEE~<15D)s537im>=NrUJU`Ncq+CMG_oQ{B%5|vhdKu(m|v#9;5Rc6OV z8(40g#k-D3wwo#0I+(RiI2jwUX7A?8(rgZSfgNMBGhXrcR7g%VTO5~lAGP%q;6PGk zwkK>`^_cjNu>^glUw4!_%h6~P2ol$Y)FyH%XcJL zgOe%@|M0klNmwty01EPO{5=}Rf3_YACp=k>vk>Xw^5s}*!>a7|&2O8Dk@r>MA=ne7 znJUQmoi(Kd&(b@7JJfqzfG95{#r}`!V}CGrZvB(ykslR%!_US`lKCS*1<5|dX@gWG zcDHn*OQ?P4zW=KrpGzzl^{%@}yn)fNN`t9#@ zo)_#oQbU8qursQSivBT}FBz2E5I;UcNF1Nh@GtLMs_nUr1|DY_iRCvt2is3SlkX6R z3%I`w| z1gK4A0F|$To4y4>#H^}e!e(Ql#gNwse9iu&M{G4V=lnFW8{-ns74Hh7aeg@q!2K9Y zeSTz}$obW<-O)*%O_NdO{nqbA8dm?cQ?{LC4RfMaVCb%!OdK;#V z9{KgHGH_APfvh?gA-0C8l2u*?sPd!;2VvbEvfFbFA1W|7U;fYb zD23)0XS1bPp7XC7iD@gGZLudUT)Dw(JSoR{ms9v;$vH=a)6>LWP$j-jfo_QquDxtM z*C613V9KuZ4!H4_0P#UPp=kh&ZwMtzQY`4H|CD{3|EO1;?FLQ+8L4|MZ@RR6BX}bu zL2QzbcC}1vu4iTjeJc85LeKx0Nw(aI7cr#s!!k%)i|>Ke+$8L{X4)L5IvOCgKPs_! z$Q~FHa*^;P{6dBeJsbSMX`Z%%W+nJH?e?jsiW_mf#6tP@3+B#W%H^mU*c(6XPhSX$ zr0KkSs0I58P;E`>Sh~>_hQ;xTV5!@g=7r7eN|cTtx?(Evl?)JW&=^%_oCetXFi8h7 zib4C|4%qcKs&YE*xBLN6@+G=G3inL)tZhC2g$2wGaA~&hTe#SF3cVLDd)X|rUINyI z&L8iN$=A`(HLY5OvOmS2wecGz746OejyTEIJ!{B@cu5GbnChQ{PI=>8+@}2$ydj}P zJ`3cY5JuAPg_tlDnl&H5au%vjDH6G1tI%s|l%vTY^NGq;IwNfmm(0zI9II-oDU-Xm zJAELa^gquj7T<#C{#`x=KIO_L-cWT`{icKk@HoR3ShIPdYj!#Q!=Q0de|hL6ht}O< zH@YcNJ1GOuq^M%HjZ68nBtfGdm(LX#LItWt;MMMo);i+v9E3xMoir8kX^D1z`5v-d z{qI6-EsxIc#bYT*7?JG(WbnB`9&dIT7ASqy&U4>;lVA>4)3Z&j#f&Va7_9)gcF(k%GMQ0ExlN|R=*>@yB z&vr{DI>NRmuT1u=rqcm96Qf+CrRxb=RZ*{<*Es>ulF43$h7j6y|!t?14 zgS=$$+wi-4e^N#QOhSP^r|%x`vb6E~FKVr)N3>2%HJX#N)(${n$Sum~p{C8zjM@ra zN?A3f$_aA4GDuC`ixY?4NaGsBmAwM{@l)t?!&f9~`lAQ$TyuS|zq!4Yr~K9qUR073 zyLQ7oTxuyJ|FCA5%}&{Te(NWLlqaUI$GLK63aybq$kPsk#_@D0v9 zPM2V^VEOPA(+UYeKIwqO0h2(BZ~?N?te@8ZGpM(=1!0P+dq)ao{UeT&ZIu4)M(aOY zsx5~oxm_+b%0)6mOucqV*mTeQk}_(l_DsD#~Zz zvNNinuXFg>Vx;j|PfKjun(XCLU^QchdTSvysA8;SD?Qqz^?>ejHASGI=)#V4Tes5G zPSb3IczX|NW6{eNc8K!0Ngpi`_2KQ%Zw%u{fREJ;PbzWs%IXE#k-^y|4E1i~KFZz> z8!iH+<(_bp$P(Ha-dCQBm%OGK)9K0#+`+MxHg$9i#(-cKqR;K8FT9~h?d6!86gNWJ z8IZF4{w{|o<2;-9DZm*$8Sn^UA%vkFa4F!>3#QAKm(4>jU6nNRcB&oo7-b$s2i91C zoIC`t$nk17B^`@=P{T54+Rhi_NoMxVR82~20BB68n^L0Bwa;qB)T*azwVZ?Js@+u= zeSU|itw&8*dK6-)dxNh<^eVBkiTj(ZU~5kAoZp5zJC~G!YVC01u#4i4(N)WN@@ht+ zw{(V0`^IDS(Y>!%yhc1`zUNQ7ePsJ%*Lo>#a?|a<#Rwh3YCZ#n;j?pLJxoKQLHV3v zBfrI6Xbwbfr5FT791?>@&ril%DSY+BQ;yA@Sv>00V{@*l(x_sYb^y@BF=<*Oc54Lz zXlk;_i#aQ|AAFa8`+|KuaI{xo=d&YB|+DaJVi@&j?&obk5tb+UPoS`E1 zFK*F#xcU&D1CR&=SZ^D0?_cJPn@3R{&6d7~J=-lWyW1kQG$brBeI;0Xc_p-6`yXSNbzoJ`TFs1ZXxsu`)uA|0EShb@qC`p1g)<8I2-%(T@qu=K4qw^m zFe4R}7wM_-mC~vUm&@FFI&Rzx4ic>>k8TxZ&9H2op|5Zx@I7BVntYXMLpo!CeJ2sF z&#MHgTYseX9niXHNi&d`i!~!*Xpg3J%~;ZsL155ebeNK`65T}QNe)Y2H2WVTS%$6Q;m8VYvZwOgXe2Ldjby8EP%UI|I!taxXu6gso+>(u=Q4+An;7*t-mtF zx5sZ^)%l}b*`0_A4|j|Dk!so`AULb{+aZ)(-x+SVeRJm9zq;#AX!$qV6h4Q8^-#BA zZEFxmM8U`*kL&T2J7F?XME&iI>JGyTRQ;z&!5TyT)*6#4edyeyc;Kn~>+jyhHf?0S zP_~EP5#~W~>a3HIA5vJnVsU>UP=0Rw6QPm0UL-QmSx$e>V50sN>inM*WH;EB@R~f1 z=0{gjezr9fDJr=zY%R7mRjn~am#7WiT=UzY9_hyNz1C}t2r2??AasMG+#n;Ad{@3h zsp$G|ffoax@0Qy}9%F2xUk5R&yk^3>{Y7qTHCAsGG`{Q1%H~PXOtk*CZ)rE|9KyUp zx$Y|IwIsQ5c4nw%_r@I$@%^l|r6U}h;<1}gy?OjgVkL}vn`HP#CK-|OL z6`q~Xh2xrlxfE&hl500Ywa?OHCYa^= zz>~B%zTA+4S66CCOvso{huTC0scG#YR{=joAF`E#Yf=pt|G@CAtJp3J!a%%XYi7LYlcpz7V!L5m$WD(NWNJK_S^(;Y&}&=F0trN~$U5 zm&|#|9!<+>-l~@XQ#8MI&6C`pFHPXO*>f)it#TfY6Djr)vRk48GZJ;VE}(8xs%wmd z%;f5j+T(H(recdja-r%J=p*x52^-r?bP@Xr2_j#r{}GZ!o9}PO8eg(#Y;#QB2`Dm5 z>LG9d`8>5FT9y|551w`kQI`$e4ZjyyQ)<>Xp;z;1CcI!A~o1$=brLfH-Bccdt5hOIWeRBDyTLUfBw%5El9Au*j0XYPRA&$ zWygPy{wb_e-gSXUHZAAUq8 z26RFdhYDQiQ;y8>JEdBoP7b!Kl3P5x+VyN)e=(OX7heiCyt~U`C6hkW>%HdPPENiZ zFBIDF2~34ypF$kh!YSCn4zdCUDM-3JR2AC;IVG1Fp8emuJ>)YB^>su@QN4()D}IjM z6<^N{?hygI(X#Xxu@iLPWIPP97b0o+iDWNvGsL|cH|%u|>itWO*N0ch+|zy{GX9a3 z02#r96ykQH^g4+%mzKN3zdQ8ne|sL1C4~70>dfW{)6#ciBs6LA=Zjuq$_4&X=ic$B zvb%)kU?riCd#)XIu`M25W;WobwVB?rndLKnR9GQ=qT572%8Q{-9vwh0LX*0bADI(e zbv>J%;ftOE`{ja$jh%O>`Da7LtvPtl8-m(f)~VjjOz)4)lI5C?$8QHang?a|OKdnR zai)!Ofk#EAN((ldIB=&2koZSJdUkk2Pb|pt>t|Huah=pqBBPBGl=H%HJ zm`MH{vzbTk0OFd=n@Jn8q~x{J1b4}m^1pb<0idqzB|rg0^JUy7sf}uY>H<3-Do&CR z%n6%%wGoC6#+Qnl$EDmoIFI$_OLT##C>9H9Egs#*Yf-Cn?a+*|_JMN^mPn_qa&;(I zKD*#4`{5jK^xy4+3gL~GRx)9Nq#>|(5^$=l7EU4iNw@WkRjd}$4Yhc3weh*`&ZN6o zv+UnN0tf77L@fo+72oc@^O)F}evy4;MB6C)lis+0k6ryeE#imvP}ie4en?{sl4_U8 z6EiQ1z4rFDxi^I0e71CQ2VFllX|OvDV8m3cvcxc9Rs^ZRkg6Aqw9vyYQhnF262_K$ zKN^vAna=*K7B;<$WiD#p3MNNLr8(2j5musN0qiGvvvV0Dfg-zMbhXF3f}vk0&#U&% ziIU9taJX1=rs(CM>;iy3R%0$f8h=ILHO)@Oe4zcGzzpL{*MA%?)zc`{t-HaozmJ@&2GsyQaF}>OUnk3VU2*L0HapGRq?kU|Eq)ukFXA^7-QUXho?U5+etq}tNSb-gXsz_adQk22 z7M{%H@BU+3VZD+3L5c2pRJ*G2{m$*Zkqlo4NJYmNY9l1`fkk&i~aae|-f<{C8P1p~rcF>Dr+lyx0z1CWf|V zoigX%psgqol}{XaHV)3=X4vAOEp6@wlK>Nq!Xy^o3&56vfBPTD9lqy$Xg3{!6q%NH z8XvzeAJLl&+ZnMvhdw5JP-nXoLXXRT9_-q?ebP+IO61y%qr)QEXwqswGh{y?=u!M2 zO$-R{*k|VN!(K#gDbxS6Vt^jKo#>4u*3~0+9?~NcALytYP1;hhX5lW}pPQRjl__1? zN`Jhjsn6J0lmrELc8bskeARsB-p;d;jDhdw7XSOZOPY{vmcCoC_S`@G&O~|eSc5Rz zZW^v`eQgmwSX8zmldR+kcir<5>rH#d`hc3Bby&Q6Lx^VwB8$rs_V%s+2rDgdrtupo ztsX%rhHz-BUVIt}eLIyF6s4N^N(`u@b3&zkj9?4iP%3Oo?~ts(b)Z(CmcjDu-h^^q zvE1ec%xtEaK%Y%QXp`vb zft~pshk1dpJq7`Z#p9Uy(Q_+HDNH_VmvK4fllPNHzPW~8(FnO~*tLWPy}csC7|&)e?cR@;w*oy19= zlHeDlddA4G1D>WTQn_tSM%Pcr;m zJ9J8$;$)yz5D={34>eve6`*E_iMx}mu2TP7tO~uZKw2!XZa$-~k?RIYCeDouQ<*am z&sAP6ak7w#^<^B;XJZ;<;Xex&7Zt#XejWau8=bdFv{KCgct7G>B=H&ScUWV-b%5BX%3Cd{lVV4usfuNTMqezjm1~P2#;vRUUyiH2sC-TeoWnm2plsagjFUfx+B@ zCt07J4F_v``}DSIFm<;ezP`K?geOut*j(SaJW6xbI>eK^{n<>>PjNRT)5XF1(;WnC znH)@sKWyom9efeo>NiaJbxj{0CA+~<{Jd<6rs96qPc(`<&G{F}%kV?82+hkqvbW01 zcQswFwek@pku8Z_&y-6_2=81UU2J>9XVfxGmF(Wm+!E?mk~=;B0Z4T*6zj|vz_7J6 zGXFY)ct<`X)6a~u#8iH}t;OEDpED3$Q%&g?2pUt%BozwdPjg`-)s6)D@|{=J6wA0W z;k|OSrZxTA^|y;GA*AMX+stUzY6IYHJP6@Ox0QN76(}1z1Y}+F=By9%FTYd;` z?|FBB5btph(t<545dbMXEGTViau#YUcEl{Q_UYrIFP#CO*`4;({+^D8pTioyFbye( zO|cdViF^^O5`H(MF%+)~-p{3dQJYc;3AWuxgoTtiKbNjv<;hqKMGC`WGBQ&)kM=JY z(4~*KD-Kn4qPweh{ph!q>Z*{yWSqA!+WG6F$z4I%6KZpP$aFU*?^UB;K;>9uiYaDs z00BiA3xJLzPgGCezx8nBhFxt7@nG(v8)J($$i-Ep%ph3$+`6&kj= zaa3+jQlI^N-dZBwv|W2uY{1u!Hcst<$pxPG-pd~;c@hyy)oWHXzYZ$4^{L!$3?8Tv zn%0h+(5k%f&>c;35lm^$kkT4Mjd6N&&f;Z>ApF#)-%_#>QCRb+!6Z6SSZA7td~KU7 z4mGJ|UZc;&yEm;%p{Gj8ByIz#EAER`%4>UefvS%b9$qfhNpP~9=2yzubZeCYF#%M2h1V;d}dsD!b=WxtGN^+w37!V)Z?9{wy_{W-IgU zAqRRnGuRi0VOx-ewkE2EhachpZgWWy?o`y}SHtz^td#K_-vUPo`V;g>-&~;78p)=3 zt=7aAH_63NvMY)>Dt^kFBNImcU8+vXX3h^E=L{X^c_xXFeRBC)KYD~UKzuxsSP3ab z`%OZ1CGE;V-Iq}0{`3M7zad@{X&VJl+9te_!PmqrNfvc<QPcRLPhr5m>>9x&8u~aH8$%DoY;O{jCcel%*2BF~Old-hW8eK1i_LNN*1IGYx? zlFF^=m)-N9Gjf%anpVH}L4+6g7())Z*!;UvT>$u=nj69yKPnZX*RRhkVqwMx+?7c% zt??U?;qL@?f@%Cdh>4%RCn{oc;Ty+tkC>syw?2~_wZ~U%W;WYmEqR^-{)txTBmTrG zOeFLJxX91HIi_v6DOGYO(o~3+T)IByFFO>D*F;+nL@yScLwcOgqLvJg#&>+FbcmGl z17oe_%7a~4f~lL5n9@Hf3|9GaysgS2CO-V*OD%uCelx!_-|=O$deF{iS*+c|a^~D! zw;;MoQaK3=(!T^3vKo0bjC$t>v*1LBw1R2yR1InoiA~knU%xH;$z$ms_Vxkp?uzsP z>|%7$*|BVz-4z%%)Mn@S`s+>lEtopOtn~RhQ@$CCT{}2PwUiDeNjb{2DR|!cE{qD+ z^}+6K1|ccM=l*ujW>@@xd57@WUl;vQ9H2U zXzh#@|Jj=_4{LC>_a4lWmGqx)x1p>j?ho#%VuGt52*luo+{rKJs163gpAanbyqc2u zafG?{DA~#OGH6dAFZl59P_vcu&1}Q}u0>6~%gn4Arb?<$__bFi%JW8Ieq%>OupAE2 zW=;OjEp7+o7pX59b~c@wazl`LOk!*)de@`*{KZRT(N`p{hGvdxb2E{yi>GthgvoA* zeN1f+ZNO)L(?IU1{${{Mzuwz02ysw-ag=vQSaJhluftZ{Q1lE;DA-+@8${7>22JRB zG7Vg>Fw;9xsicEDI55`5k^-aarsh^Qc!Ppnp1tp;yPok@MduMS?1P| zGrct$Nxf@xi>0=R?l|AMTsrj5{R|c*Jh=U$adZ=1-9i*O$KRSU+iqzQJp!QQ@Mu16 zkH)&LujFbizF^Qy4nNA-g?2`V`hXR+02*8TMdo;3x=kWpew!#s%arDDqS`ZIsCo`4 z-msuWht{+^H?4l6#x*!zRz`4`OTIL5kJs&;AhX0G(B_{~ z3_yHVg4VI4_p{lKzu#JEe+vP1yE-N_ z%SbgvU`9tst3MsMYb^e<`d1@Uk+?mBDYWrj8QZP{cYA1fGc)^+W8hO?=Devi(>;v) zqv}dtDN*I{|9X(O&F{9pT`F_`;vbu_ai8zms+pT>#YB$!uaBt0R(JBumvf?nbxgBo z+L&ADn_K1^dycoYux3hUrOfh+Iiu?%9#N%egFe!Z?!WfIkR6_)ln1%j5KGT(1#iAD zm}2l0xfd!Tf7`B+qvU*sOxprDKcwgK?A25g=ysn^Jy4~e5BOyU{PQHRvFPnTHuXoO z%^K%8_Zs6rh%Ys2<=;y(EFa}F|JqOeuAdb+9a)eqv)|psNfX|3tSvB?9rlNn`B058 z399taw~0_R)*D{^g{o6Im}G-$UFDp2yJ@#YUnuJE>}O=`!Nx$08`KSf;9^5h>+^1Iyf z(|--j^wR=~L%I0w*5-_a&n@^<& zNxjV`55Sctl&eE|lxK-efic*;X5$dhcu$bsByN2}#M5I3F>_Q>+IAL!_p5Z6F-0x? z57tNuwLSX98EVtoF}+#o03p%tKrS0)hj_1Ia~`Gb0F(eW;A9J=GQ2hb>+mwOqA+h~63}2?YSn`2 zlfY&EVB8IhFKAbhjaJA-!!2`Nl7DHCndST;*3%XEeV0S4<@9&Vvs=)U>(EvNVe<_+ zF5#2FHW`&@k0_6)+tnV?%}cH^mW{LkiUThomz524A)L4uEUe=B_;4P8_*C#n%0 zfJ{*rRkjnOwqbE5NFqF>#VT)c&KK61*H!byj{DXKrmn>SvXk_fVj>*cPH$ZP@`>2V zkHepQ6z}-T>T(|o#XsS~mcXMC3YqU3(Z6Bv@h!{1YwR#eBUcZq%sr-4u>8qw2;CN! zUGh(cd6|;)YgWb?fS?xz=Ut@WZC_=@R?b|8&!Bh|NjPwipq2yeyKcQC>a+)u9v10dRhYObwj*<%aAT1!Q;@{45%vnLMt zbkmBZOIg)jRNflSu#C~+=adb*<=-L41K=ycinSv4a*|F9}u4atl@0ZdP;_ ziW(7v*}2RB{EUy`IK!{gV2(hr_Mry`dV^K|cn!F_mOSUDy1MeGfgSO}ur0Y<>F%}S z$+6dl)?$c~k*@azC?@aWKfz2o1$*i6kbexwy-$*7FWVZZ2qVE>5doyE(V@EQSHNxC z5X)!H)RATFRE4dpuXYm&E=`DQb~vk%_dHufTrB!UAa?Nk6WaaS8N&6WCd~ZD(b0yB z_4MD?x?1L>nUj6L`e@{?Bi+Oe;}XaN%$KzN;Jc%!I}BooN2@w7*QMvOCCADz_-d zvj+oeiD!YxHO0-9ip?Q*E@8EYE7h#IiLb=eji!Z;Uy`GPpO_{~24)35hq(b4et)xe z9O`c~riuMRNpm$4J)-HS%s+a5fzrE+OUm@lfguhu+0F>y-^-}EYEDbi%qNgo8ZiOd7LTP@Q=%2j7Ri|X|n8_~ns7TnF(tn+)Q~}kyxoJOQ zJ?hDNmfKnYIKDl|GOybvK=`?Gw!&H?+)Wbk|<$@?V|o#rjE@oSMU zSG6wbd{i^&5tgk&-(#{*Ss8Klnjr)Y@d0d6_e2iLVKackp{!r$-%zWw2fGY4z`;)5 zInMWLj2FXuJVNRBuW+{ye_RBz-h9P*IK3bhqlVKDH;hc;qg!LWwRm~@BzA4;N|68R z&oY(|m^67WXiix9-UO&sE zQ;K{eqhU^RP*}hJ##lFthv)@n>v#4kB|9D=+AN{K2`TrKGU>U(hFZc=eb{;(`w!fu z^gSNwxR2mpK(}njec0!_z~nb?y&^m;|H~rd^eqB;Ux9Oqu17@%$97&Ot_$rncUESc z;eBAF-9nAih=&{_gbJy3B|7}qYRB4K|azSJGhD?`%3r%e(1~9NCv})6asGr++^MV z!#s4cP^M0{TU$Or>GkX_^E#d>AWyl(mq~RzxnVV@v#`+3zsm={?o)EXiIG%|6BkJJ z^?jEC|2H@Uz-+OQX zyZQuoF^8^r?`qWRyUWuRya6%{PnZW7`x_jW{>y&GyI59YmPfx*cy?8)-TlqKtL1G$ z!7vQXV3JHOV`hy!ju7;~Zmm(MrvHradrXs^K};Fd0zY8uQUENGe^)-5toyubX4m2{ zRWN6#D$3@U_Rp(+o1cp8z_%{M!O=JP~_)Yq5K!+S1B#>Qo#aAccYW%{9RMCZ?=&iDXI0$?w%U z-olbg*hz`dE>dOXzeT^c(hO$x%k$e{pI3JK^WyP1!c-qK zTwu7qL=Ls=8Kb+4AF9-~0k(AH$Z`-gQ@&$$?ZqpxSX|s)h&J*8a!k+64eM)BD`^iER(zT zYhkeR@{P1y-(IDG%cjCpq#}fQS7#xW8vw?nENT$g7TQOYvpqaR{PXe{18Q!;Z%w#d zS1;Kx%SGN#EyBG}!ZWMy6s#$Q6Mg!@YPo$wM4OvS)~&P7kK;yU;q&4;xh?l~6B`O* z*PhCna|)@UOVAi$U`?E3c8T% z4rwY&@I&%5bOKiP+$s#4{KH_pKMEd)f=AO3!5@ z1z^&9q!cw+dhaZipHv!e4k-z$G&-Zuv~c1cEj3{~EqTRZ-{X`|71XALI`?y&o={dW zqu}jT44#Qk9prK+tx>s3kZjoh(c+|VpovK&t{I72D{nlPvg55_GlID|PvIn`mZils ze$sdiyXpjO-}<%G%)qcXH`?x5C?p2&Bn%hniSSP;-|w;->0rR+BhfDGYY|^l^t_}B zP6acjt$Beb@yi%>o)tOyC%C{8OMW_>xD+PG-!u&KSDYJm)O2sI+_eBWbZud`yECDz zuZhn{oERktI$xl#2-UWDi`M_16_~zAYxRTac5vS5+UffM$MX6Y1)s3Jv;2^e6BFBD zRzc*)2AYy8aD20n|8mzw5J;824Ifj1=x{R&Z@t)!;oEZ#!(5TVN$n*eeY~orMy?on zOj_q8x*&&lSPx(8Q|{YG{quZ2)k(w7ZB#N6sTm}bMQz9awi56f5^DS?jJ{xjcEfJP z1$~wZ;k&hGrLU||B5$JpWYn}%ir2EVIbi6Z@g(p1TIFup1cV&<+_(HT80D?*fLb;= z;*UlY3-1|og7w=dPq;&vdjf*3(aQq;*@Z#$8^PiKh&+jo2zxhiFmCbN&hW};#9P2% zMja^X?AA&%Td4Fv`Z8CXviq>Ig6q48Fy@)r$i5-Zqti+L|LPgGW>@UoT3>aViyU(G z`74|Kgq1xbZxV$<8R9)=rqr5F_0=TK_Wp3To^-=Z1VeA9xXQkJ(Y1U<RTxb}ow zXxj6q3#yF{CAq*!I`Zf3m8T}3*=#y8dSI$$(wBVY)Qc^@smNysWp^6WlY`_Qdw-Ir z{F57)>Q_ZGJ~T^BzD7-jy_V}3VPqkyuEf4PzjQ3WsTiP>g3}PoJ@@|P1lXiT#a$M@ zEDBmVM5*5MSFODQsb*-q-92i(@WcNKSAnZ^<$l|QwihqFA7YnTH=GqVFuT01Aepk^tNn7{ zQ~Iq0%W`vWY&x^y*At%vir;XFg7Sw1(VLR+gRaSxA1u}u8W8(%71wDeh+cQEE)zuM zP5bs`&Z9HgB@p5`dFm3W@g#L}CqKDPM&R$E2`+4Q*37C(zoXFpt>>@TS)TM59i!vl zUz>TK^KP%H#HyP|j2a#D#pP-d9HS#EUH9hnkcDbD_s?mvfV|Et0MFo!fZ=+LjBXYN*@+IG#+bK0Z4H*`?V z7O2ebeW#IirG|XNn?j?wH?>b@Ggm%}Q-`%Rk!}z)92rEVYrffIo&EYnZ1eGw^NPl-J~!hKeE^52rD`Z5ZvK{ zDHeS&ifo_KUC);Ncy{F2{X@8enxHJjUs zJW9Azn*HRz#>hP$Z>cxh$l0chAi{=Th-esoyD)>y|6rQRng^3UpzbAIC6=ec`{P?I z_;^`A5P%rD$760yb;L3!WSp~DVFB|}uN=1!ZcJ03p}MFz|DAr~wOlkxspg{@n5Z>b zB6U=Bo>OHtdeII!)7XC;jqe64K}kEjUhm>l1wQQY$k&_${7k^WDgbIkyAu1b(`2E- zW>hXVnY>1I*nbDGR)PeoBbaTmN|Vk4yS4kiY(cCG3T*-w1n#^7Nn+&bc;}@8|KSEB zqijMf;cZaL+-9=*6HSMkaF`iOK-uvNC`}U%DD6D>m`Z&%r1@3UeL}p`eTP*mRF75jt^$LTxS^x(gCXuUUk#wbz> z**x1&D~dUS1mKfU0>V%i#J8{ z3ZjGg!)9vT4_0?`XH>`yqo9MZl+a|idlHdu%Q!J?j)s>ZYCf=4R|3sg)cU8MB5S{j zy}_a2iQjeL1V9_Gu_dJj&J_T-I?}5v+xFl~W=MNxNPp%-9f3uWoza1tf%qzRsnVT!P$Zlq`9eR6$9~L)Z()!d`o{xBWfY~bO!QVWT zZY^=-E70K}zAT>-G`vx45BwzU%bzbQFsOfJ==^3#Zfrj zAeOCsc7c$tLwh4m_mR?)li-M>puO)%t>z0qKY8S7cI&B7_%xhk!j(poyH@P(r+q|W zDyV4ka1(EG5ZY!Le3JG0*7uN!&}Heph$6#4M05yWW61OGJ&t!&X0m4L1g#FWytEc$OK8Dc2`>_S1#~X;aASi#@?X1774f$O=HTco&&Hlb*^knVF)_1dQC|f{ zfe&}rNbr*=n&aR%aRP#;(eKGms7M@FfQ$bVIy}V>zK-MIWx! zd#1KTJ&5$So)CC9Z%D5r6kR5Eaot%O$(3r^PI~YUvN+AtE(K7{sisz8N5tE2^!oa; zw5bftgma3by{1&|UNee|x$OzbearI(K#Xt+@X!+=)@Km{+tk&n61D8gemoi01)Y9hrOx$nz>RNY61;<_ul{5;ohuwWxB36tQF^9KuWZN`hDmFP>Ayyzt z@Qhzbp|TO!CsQUV{S|oV5$K)zuCC)$b-6_LtJA}|tMYcWk60J|>Zb}I)?xs8zX__1 zUwKRBF*!4_`_vPmzw>qj`ClAKkG;Aill81G5hHfZ1ERWF?Yx0X^3W0>Xf83dv#+w+47WFrzeig&zs zu@IMj45C-Ci!#pv!HFcRB1A@!U&p{+j%5s*;>U-77@t%A4F05$u}kwxJ?m9K`I~-x z3E`6}F07)`i(>{a{aU-CML;TbDSnb>iG8Rk4(Jmh?)J7mW^FrV(|h8|;!!!5Zu=os zMJe~~Up>zF57(AZ+8?0b{^#a@<(N-spRp=vtoNTSxFe_Sc-KIDl`1b%%*ZHKgBri$_Tr^ zu{cJdYlH`dkG1&zm1e>da3F1lgDhy4<`GXr0l3}bbHUB;O*7gA`YBWz(NjNQDIMKt+V9rbe;nCHXo$DI(W7VP1w7rjb)L2W?xoS~E< z1GhSx{DI^B7vmZgd2$UKMjZ>@6oiZ3kGl5Pt-oL5ix zw5ekR`QI(fu!+Pj8&HWU#Bo2AzjV2{6l=9W>7Z4+oJAM^5|CR7OAlWvp;=v*9`$-z z5f~~)AX+}E~h3o`5)>}u|{@uO2x|N%@6wLk3{ushA3LQgW^?fE|J1w^7 zeDl`7Bo@3~L%aFL!L=v5=M(Mk&d&--RaLmu+CnXOX^ZBuQnH`firFkGuP2ReN>w_Ma=R0yjVY<+!vY& za5;}MJc+VBa)BMYTx7>KsZn>7ynMEt{;FQm>nS^FwF~2QuU3zR^pk<6$a<_6^lrA- zylj-4?BTeZ&-gHbMRBbu9K_Jc_Jw+nSPK9adBzoGO2?&nfahd8n1+N=%K)>t(M2?h z|7B{eo3N{VT<;1~1~?;hALkqcW2ld5;}!m%?Mro794Czg>sT%US`&UXJAq-#cuB&R z|HSj0(;eTnuvtD*WA_QlD(14AJ*$;at))5dEr19ub+`H2ZwyEn@#o3|HB@Tpdc=h*(Y%9Pi^ zI!qA>paLpK4)jbsgw)usXZ#m+^C}jJsZ-^Iwv4V`@X&hZ42_wPSLvb?aLwdv_MIQQ z=vKaA{Qt9yhfn;ZHa-~2e(n1mMRt9wOd7%qJS`PsXub^-iW;ko>D63wei4tDrdmaA zD50NJweECyuCZ124S0X^N}}r2mhNpuvW5iYdD#F5#99qcJ7@1Lb0=J}SRJxR5ODg} zxSR))Drm9|?qzY1$m^pxGa2vz)pKB7+5ISvc*E-XkTD};HrF$~j+Hn% zJcS9wI1&Ht-!4SCyfWyd?E3FXu8O`UO7pUf(**nQVf{2Wc_x*hoaB_L3n?j9)Vd$j zXG(}=lBZZF$lS_wV(!B@m4UjpNICH|JI5WB{pzs88`}`;@q1%O%~9NQ3w}Y}t@a)j zN*l}{Evg%|ZJp_2wBM;rFTT$d&>QlsJuzgh=yAIo9#F12OwtRi@zYlXVp{8}B+On! z8>B91Mui@i2h2RVWkbjB6QPg0%_&}U%7mUbo+RtlUCJ5*0#vNc%EJ6yE2WLc<=H_# zy%{bJj)o)Cyc-`VV%`KBa(lC1KGdGOT}JTr zwkJ-XaRI#Z$nWTwFu1fUp_r%A#8r+j*twGH)+*%$708gz;8n7ss6se4_#n1$Lo=ceke_q_x9j}J!fxq->xyl zOVJMZeDVf3sr-nxGs@EI#=7!0!To7{gAP!q&U4%~_13tWfb*?mSsi>9x<3EzU<*TH z$Pwpq%fbO&Iea@=IIW|q%j|j+b(vvx=6E%fb55keZNys$QZEGbm<* z2qyua6RPak6luwyu{{M6L%j_&Ee)Uw)?-ctsNc#W{$%*wb`Sgd8VJZu5cro3z>R3P zh28&~ju#}Mq;PN@qlNcKa6LS)XG3CDiu`HwLekB?MK>IZ z*1S_XHnKF27)E>FK=Np)tDV9k+G2A37;5PDC}o1MYnh%HkMSSqwoD%{GS=06PK1TF zV&odd0$r}-to`IcM(svWq>Y+fbZyI*5Q?)vxrDw`+5WwBe~NEZxQW)1VeZk-Dt@lg zzGKpmaW@Ww;N))e(8GE?!gSGl79qRzDD`guAzcyDM2p z4QxeaIX;i?0Qn+x!AxfxAfW`k;?{i~DYSa}TQc~g-77uCt^i}OOY*%V*bn49xJz5p zH#@RfXPYrE$-$V^oJp0bSm<31t~Yw;Uw=wc(X&NhKX}Rlys}*>ulHY2?&=(pH6CD= z&5280#fzJ=qHW%J=9h?PNl8D+PX@ZB&6J-wl&^Fu@Ux<9CQ~$Lf$up$&r&*$QYtSi zVGKRdX3jnF0eu*qETwlEG()rTp*x{q=E|8%CXN0iJ-?|odYEFGyHGo6z@L~~)Eaou z^30o6p@j_BFjYU)>4?rFw_UA2Pp2NKA_P^KTuJKLzBH|+fG`Xn5B)TKtA2~afcee2 z4Pfpm%S%1Yo$cYmzh}S<`e%8Z<0vCIgm(L)n`q!;#zMDZN zV7KZ@FnGS7@vcg|gMWwR1p)djIH338MI66^qi)NYXJr7{!X&A1){-%nAf}dWEk5zZ zbfC#9YJ#R(8B47g^1R7S0*{lMe_)J!M=1 zC*;9@+G+obqKni%JS9PIia_Yk7ph=dCF^X^{lq|U*N?!2h(Mpi5Bcgs5|CEqJr7Xd zI-7EVzZR_@`b1h;V}7^9F=Qm|%gwP^dusk9iPJ#d(`-*s(qD8{)}}1C^%Y<4mEUxk zxsg6SQpncn_USE*{0EX{_K=qU0%pyP+993drB6EBtoW@B z{~CSAM9sW*-^_V568%+qxXe#+6$=aQ9PUcXJfAn?_z@LF#YteDChFHLCJzH@W8fFzsjwjr;=?cHQEG z#t%YOwg+ijOhp?#vHIA~0IJ1i_Xpn4qk~IQ%$(afX(C;Jqx8-@hrij1&$ZV{SxZ*L zi=@g6n*8eun*5IM(v_D5%r`n)7(gbt&C&HKZ6V)O7er?OZX)-P<(HuSAEh^)#7Kz=4cz@tt^R-q5kH2$rNw@Hq6`f6C z@6VMVeRK3n&yRHWewd{3;jP+e7p9+}#xwe>i<`+Sf)ak-j5eMCh?jZ0I7DGom|9Pf!${DlPw9XxAxUSfK3| z_cmmyQT*f)8Uwu1{=+A7Om~=%!yH#t4Ouq(_TV=cpNgfYBGr#@x{lR6+|1#hIFA}~ za>PHgt%dkiOi`!`1_IhM4!Hkcpme8g0dzhR9=}Rah5bGH(QFc2SLF3%4`#W-3F^G~ zcllV_e5QU)iJ)kgPYHi~7cQYKM%&*io9aElo?)fG^)|G6e(??ko6VU%o7LJ9b5@}S zqR(T=#VY13G#(69cpDyJBx7D^L=D)ho(Vl6cU*Sw=8KAi3jF*XEpQE>HhC*O4PgV| zvu$2Exb;qI`ZH7TkOhturCwNv#65cUBrQC2rYiepX&yV|!Lf7!J9=)X^C?QodWynY z-`l=Qx0Try57mnQx~?34d72BY1oO0#i|w(^r&E~m(5rj`xa+rVG{Uz@AS+-;yrQ_% zdFdOkq20TCOeK5JC5D2+fjHzJF~i{t(+U>UzP;0EW2^_=Z_)sL_xVN z9{DoM^KYXxs2G_hdl3=rbmL_qNu!wYm3)UrhPw^7IfI23Ped$gYaoO!oefi|LN%c@+H?3WE%?p4}e9D$3 zG@CUuskLZzLO(?q1ujgdW$qN#BFWKJI~$gO!&uuHCa6Re(vcMM>iG_X^o)SYO(f=Vd3rD(;%Ym zZm;S*b8=2tSp|nkJNK@ceA0;QUGNer*r#Cu9dgvsTcI}s+ z>NDsZnEc$*9MPySg_|Wi-Ul`U^-zU_T}CRle^m8&4=2^`i1&qq2u>Tw%aVH^6AE0#FJf+Cp# z7fhGMjnnmCF7jD-Y_XCX(+#U(GVE~~d~Led+YEk%CAk07v%wqM!8q+;q1?d!#$;{n zEJ*wyq;ET)Kq!IRs;^t9`B%U%#^-@yB)5dl9u!{b8aPtOHdu{og=FLk_+6j@8 zn80`~I}rvz5hy}O>>ca4S4?WPs(K)NVajhC z{`hZ-tR73zmaNU-)99?xO-Tpi-1C=pU5u&?wVY-Z{d?i#@Y;oz=kjvzaP=)t`ugJs z5$Zl$^_2T>O@3*0eS?zdNp}^OCivQ2)z=5Dumx@N4DO`Iiq2y{2aH>S?^RRuzhgVo zatUtWK3^DxYb_i#cR5`e4ef4({H7UXRllwvc+Hgyeuet>d06B)?Wwo`{A&H|7{%ji zAH+Lvhqj@%jx(Xa22r;^-2d%D%ggu=EFsj1Br z#3pr~*Nafyv{TtsBwKxqG4Tf$>qs^zn*|6-xXp3dGgMiU1wi_Fzbu) z+)xysm*g=f9%hV~usyHz1Au0}1S!~+WGgf{S(6@VfthR4|3XPI8e6H&-E3LDlXNM0{^`?!1O=XsFGXLF&D6+jz z&*{f7?NdfL3yZq0e31vnf)-Yi2r)CtIiK;Z2Tb38p%00uYa*{_?BT$92&I%CPl)&D z^K^ZElNUg|pg2!*SPo2Ktj@>BXt_L_Fy&+Yd=YR3vG2!QohF`~dI7H*PFa2EDJEe6 zGeo}Mpi7{f&$dd@&R-zjtwEx0(d4K8d2VX!lV@9iN7aLh%6nhOQ}x=EqlZr)!bWE? zF_qZr*N!Pit z{a9P|Lg`6!vZ?z=WG9wURdsYg1dTZ?EoZSWxW1}0WOpYmdD>zoX(A10A|yb#Nn(qE z=GNAlYynX1Qo0DPraqnf`CAh+mpSV6*Wz)`LX-4vo2rxJe_}4ZyN~@whd$oedZ9G` zFUwO;r5-!3%m8gZ6=QFdgGE70 z6h58jlz&0`|0Ph2kyOK&xlCQ{A@0|B7|J^piwhzHIZXWXFvnv#@W1c#kznyZ%P&b} z`oArf7xpavY%ezA$mB&^*RrCozHLl}a9MM8!qlKYN(Nh!Iz-45ayRJ5a?G`$hw4c( zUz!T&-u;8FpJ-L!*v>ldUc_a?!+cD9=t6v$5PVj-P^G2a^yPi`V8X8uYMrS1?Arn* zHM|$UcrRAVu9UZc>;H{eI+@^hD+ACMb`zAX=+x7fOVGY#0+KVbfi1?q^S^~ReLL-?V(pH%48`t?H>5U$7E5?7cBC}pp+?>9 z99(PagC~!-OGY#|xFgL_a&faQX<1eoi!>iYsbRcsGn?T%?0C07?2P&Zu^eb6-6^2na@tchNg9D0y3R#q4tr0M^8(y;B;Q z82ZgMH!b|E@_h3-zp#M8WyqE%&MS+LzY3EFD;COj2r3)pE325P7laVPgXBfbTfz#4 zf;s8?sG;?IyZ{9A6m>TJuH_ny4kk#*$d_i{8&+^VEnvTk!%0gMD5b%qCTsnu) zzb4zCTC>`l3r5?{f<7#O-B5up((HR{Z`y69>C}3aPn6B3P{Wx64f(PlvDh|Y=XV(3 ze!_!Z_ITyn&y77sgg;C}XTi`}<0~YJ0LihrOB%Q|u~)PY&~e*}3OiB%hh!-G^>L(~ z*sZDaRwK)b_S7a4qA224niCX?`!=pK@c(19Z#b}!Ew0ojV+TX4@TNC)zk-=!kv+Zz z5U^6P7(>8i7R(@|cxHv){&9p+D@Bl=R3Jhy>m7PaQhm$rlz1QUc&8MYslH0>Gy0?N zmeRf&_ca)9-3h5T3ig-WrW3*1CWpYb)#^kx|0}ZDu|QO>3kABf))3P9g7&G~?tdyS8k079CRgpYP^;P)LJ{ey- z&(U=9zkFASB$dcm0xW!Uwy|lZ*L-aDX-D-U{tNB01M)s7fjxMim+U*aJ}_rJ>`bbf z^1>l!Ka`u!<>Fnk60Wwc{-nC8@z1Q^=^q$Bx~uC{Q9YBM^ryA8&%?NHjTo4&0c>*{ zXuk#@ePPL10eFFyH8LQV&4wxr&)>GUa5qv<4d+O!`F-gR8U(Yf9txINxPElNM}By} z0=y}Ca;o&s9K<}-m{*g+I`pSJBp5K+6WdBagR27>mF?bfJwqFwx6O}#4RwbEtW=lP z!Hy4K^7;(SDnLF@lnf6MU0!*fXb-ek@1^&_OG-nKcRTHVFx?7K3$O=_Yza!u<>#?u zEpdT)$FO7h10?zv=fO8p1PTAX<^-I*3;w;_#gaksXR`8NXpMtgmr=$WyJWH6;|-N1 zY|+x}CV({Dxr!%QCqO-BChy~a+sN&P-Qg#uIsWr=WV)d3#fPZIr(E0-p57yBPS0!Y z%^-}75xoe8zXvzHck-`YS-{37WoYndOP{;u8_7wyIyOM1n+8@D^d9`+Y@W(RHAVRY!4ph z+jxbcn)ui_UA>5QMcIiVwgh;~gWPDl=FK@hkdCdCgF#UG?4(<2jP7;LOM8YNF3_fL zn0y=;pu~5qv@;xKB_t2Zg4t1jfHT#55k}6_qVx8}Adm4Oe}-Iq%|_QVz){3(K@at* z>sF7&sUi$oQaioNQu}u!q|PA?TZaqUrw~U%|YnEHEG2hwdL+$GXg_qYO_J5hjPH}8zehW4E&|iy{kA88I1!&1EuFGiLEqhJkym% zMrQPuIe~Z=m(G!R_+QF3SZwE}_vhcg58Te%!n>=us-acRqRKbeUtRn+$!@J2>O+Z6 zCtW*uvxG177T5moj?5u3@E0Y)hqkLZ18l*R6a7`j=hl<*!h-q)pQDNZ(@+iuiJM3n zbkt&nm*hC}*hf=+C1?o>2*Wa%N~nzXod^|P-K2d*KBIib>+b2wk-V>w_xNn2f9)gq zY~ck~bW-Q1VOL>4V`z!*Nll$bW?+ht;AIjS^~M3}RTbwC6>? zw{&)-8~@x|=d>alj;6tj?K?I2j0S`W1k>G$Bk=-$lA@!4&Wt!rHYd#qkqNa@wI-h8 zF(k&K=$eK^R){#+6l|ZaEt80>^MV(&pon&3>b0a zVlsj$?JTXjl4?Z`u{PZo9H_q(I*_fskak|^J4T}c|% zQ|Mp-;G<6u+&+7o;B(s2aFrqn*((2>P&UY{7qbl_vM#$qh)#4#N0{Lf^yFN$4OdYA ze-Nn}|EDxB6?yp1$n&r&Bj#{SG(0}h z*mTS%Ue{>On#k5CL3d;RY=n^ouiOCOOt;%fee)XgAlb=L-TIIRO21j?LgF$)&|%)z zKdUm2qum0cT4f!$nfxPO3t7K%Q@PY-KkSXT!lv) z@GHA}n0nd3)SpuVc^nsv1IwNM_%(JDhgs@8_oL=YCp@;zWoy|2G;B;@wOpD^OP-Xg zY4ysY&Mbo8^fL;KCocb@?_V(o=OlU|0VwWJxN^IGTIB<6F04mwg%f{XBLu;_J$gS&vo+g@?S{Ykf{7z>|?z z;19dr2Zx{AFOZu0@7SV)W@Oh)O+O6F-tCh@oGzQ6Z0b+6R42JzAioiadv%qNf>~%p zFfMD<_w&mK2e;jStb<_X`f**>N00_p3SX-X(BEa1_*C`wO5kkrWesZQYXlySo=Z?-+bqNv!WU)nua*Hgz+Hi7`kO7tv?oZrN(kSJ8X$uI^vD$v+1R zk|KCa{yCnG-cx>==mNv|0`O8ziQWYZKFWBE4yeEKILmp!&)nvBqO1tvEsvG{I;A%P64YvQ(3Nb7dvITp4H%qPwdt% zI8pbdSCr@*T$^jzy5;RcsV?j>&JOIqY~>gY@YjXcr{nSqdU;6mE&Je z-&D%QU9_8{XY#_4;qC|2uYhZTGSX@hevl7`@zfqt6XCx^`fY6bybQIPVa^{MT+6N= z6pS{ZL7u-D$g4&_h3j~>sF$t;0lKx{o9@XtdP{*mA!g;NFz+3eXm&f=grdl=b#I0* zzw>3<&M-Mp^l^F2$MAa~*2kdjTTDQ#L)TlXM<#l|_nykO%CqN>EFdwptJz{LK!cC7 zyhaxa=X4(W{;25wv>$k1>KkZCYUpwk6}NL41MeO~A9|jHaVh$@<8*85m12dVEzV!L8?kC2Qa{b@$k*Wqg-GI!GtXBUGmQEP!{C3= zx8n_N>%UmZ9nuR16<1jz+ghfd!gnhPAO^hGdS%cjsrD%Mvo)F73H{C2sou-WPrc&q z$8@|JX3jNUaURIc-b+4Hb!0gzzt^qzkruVDSZT3yr*K+&3S=xV>i@_$)hfX&Y+Fx= z`;pmsoWqj?XeAHE@|8TG)Q4L{MmeNX**Uw*#f^Dp3wYO&JlxIBaSJXA&`j^PL@!ye zZb7V%u?6IR9n2`}ALbwi5ZF(yC=?l#rz2y;%(5t=b>u#g>;cls>`Lhz?C!yv5oGm( zH<@f=nwU-w!Y9J0HmxUAiIb{C&@K229m_S6XWRT%-NM`3*E^AI|4zDpQw$2$sL$7Rh`t@$XEA(MXV#w6!KupOQs<5 z40l<5&;%LU}S(P%c<#lQNx}DB+0L?xhk( zrN(W$3|8d?-gH&DEM8v9-9T(~0-M=#c)J8`i` z2w~TT)h33Wi_e578LCa4lV~I_;Kn;iOLrPV1t1VTBQmtM`eEMB90NJ~BWm}hb(BUV z8vvq8H;rjG)Z)_kOdCigb)H+MFKPF2XCCBH&NqQ3$>5tHsi2Y1XDj`pjl?$mve08R z70*Qbn4G2A$kx%Y&0AbO)^*y0J76zzobZtn!aF+f8ndf37?T^Yc25P3LI z<}swnXbpYmCXnTAAiqle+nuNcYfn)v{WK-rui}_}=XBb_y@9!g7G5;Q)Mom2+3SwkTitX$2)fndr#g!zz=J@qv=yLY!)75Y z7%vJDl$leta{o?ybM0m7P@ZD=XOP*eOL+V3xhAQ4$6~7??ng55hr}bxM zq&LIxAu5yL?+(Ac)^`cm7=Nv;+Q#m)V0r$s?52_(n;MuTWJb~?gQq+w9hW*q)j8WzZyKxc`+!g=8K^$3Gm-VX6KWv zXkF_+vBM?P_!|?51TI;F*a4=4VQrp78uk7w-T{eGU<7w? z-8?yonJCrZa^dQoKfj%Jg&>ZdGKI`%+hnga5yxY`|6G`XGvio`Rv1Ulb|j~tZ=*2T znRb|SP=)qfi#oEK#iX!J(RX=t;J_KLaQZjo|nEENJ054WD}epLWy{}h@bJ9Y@T`YG^x-_&83O~Qj328)F0|SD zQ3(D{N2iZ~;8_FYIfKVJb@FV35BGj_Li*dFN#84%S7r}mP`4j{a9RHGYo>jopWs>P zyCzICkC|>noKS<965QvG#0F=o$)6)@4bnSDQt z3nYv8A^TwE-_VQiM*afw7@P5*B+N)4g-p5k5N+$)72@$0GB|1)bo$d9lICC~;$&%2 zRQVMy?^v_wZLn=^j#LC}p5Xah8ARJDCPdLWYPgtQ8*+F4?AaSL_QP_^ho@QaBc1b_a) zQIsc9nHib;l?{%=d1^Uksxfim@ZEy7Vf0JwGHe_hi*^9KzCqwF#ZyBE{W}JuGbrlP zsZlNXYk+H5cX2Z`+AydA(3vL=wNqo|zYkSMMMI1@AVK&dUet!26pH^K+TCeTv-f zn+*1?SfWx%s@)xJcKVGI($NM+ljp zFzOCmC#G_Q*F}H*#QiPT+W=IXMoIt+~mKXpjh-r6<=zTY)26f#VKK=ds5A8_pJ0D!R;bW^Mlbjq1-KV=!Z7QyBr5&TShifxLN4+3Vbn|X4juow0jW2M44vWz`kfCQ zGh)m08eZaj;t3r)C?F|4C_4l8d#tX}!9+^ls}1!g;nTwS4Ytpa;dx-DLNR zPe_5B1*-c%sp*^DPU@Kp^E?B~YcXjxgbo58u%tQd(4n+s*x=ZMw+^tQR;fCR_PV%t z1+$C5M-_*SgmjT#uyzLcN9L7DE#XW69cw0Mn z3@}_M=jLbNVk`+ZAr`@A6YgXza=_CtdjuWVD#+;$ef=mSK4q048Ue4zb z=dDN!_>)nxHDkCA$Ij+m%m+le}>&w9dK1v~jtAR?pk@5I>NT55Me z>tuoyDY<$2)$|VDb2p!DlPnl8W;3jR^OeW#YSSQIDHnzN!l&IsCkyQQb0?f? zb~{6o5jKdd(U^gi$443zC!%i7)(JJ6;`39HT)#0q0BmG>27hz7n}U?{D%suSXc?U7 zI)Cr2)|ySgi40kQvPooXPoc4*d9CNSX~7Jn$mT`$gh`Q2&WUNxN%n00rT%Q#l@bdC zThll=Vy(}fV4lU%-AaF zN9AQGg&tH)A=vhb^tNM|@72*qsBDbMO~UDV*alYegKx!czz}zcWW(q(@U?&KSYT76 z*j~p=@j;yz_CDCEH`y1*S?u?vYo6n|A!KV@fY#7q0tnS+iI=Xfn2~rqi{JhOEcrPn zQ37vL^A;gvdlV;f^YPBWcq_Xcp z`XYg6+XrcEVMNK~`*|^e8UV3-CB81ac=kAc6-(3EaKwVqnuRxdDjzS;5?-O%4E#Q2 zd+N@@2~R&4zxGL0Fh6%+6!llG*w-t*tR0S&Mt>(CK2Kpbu+7ekf@_geZ!5*SvFfRUTyyMapb?WLajK*BVh~p-;1AZ`Z zjg@iA02GJdcM?U2I2k))c8JjcyoKB+&1b3V^^>J)&otkai1!kAvG8{!Lrqd#Bt+tK zwQZ+=N?wk*_`HXOa#iAPfpIyh%=kWcn@=xt{KddMwu@~;1$Y(8zfZej@??^1%c!CD zlKxz0fy0-Mrsho#N%Ymzv=hD^-n;>J9H+*}W~Bj=;vRfeVq~$NJp47{2mRK8x*6$S zj+?B$h-2xcwC3GU)-|rOTos}mJ9Lr*O-H|WbYGZ~a#F>qtKzDSuT>x9W}Y83!Z3!n zd+1GOk2r}=MV$yhV-@;vKoMOTVZEl2dUpi(R^BPd8Z7$y#*&mhcWKsO{kf-HtPyg= zlOehgNV7ueSHP2idA%5#x)d}Dk1+GdR|e%KigpO*l%}6{Doe3>I90TR#Y|ZkhE1iK#TryV{3P&>9x1a>mcA+JdtQoS+Drf1iBXpU4sVb%Ag;Lo4i66T0 zAqvqjS!~8pf4c7ph{rrz3wmO)*js2Qad8$<5i&fTX(od}x-Yz}T(T(kf3@hLKfw=Z zACuR}y`o)~uA2T%KbuC^?ND;-f)eZmJr-xhc(amzWH`Dsyvo9ifW@61`+r(({p=aY zx0x2@%@WZ3S>+q8R%&PUq|%-o91H3!7IpUSFc`K>_z~0ldaE}HfO}oI7cgxnsYe!2 z(5DX%l8_72qm8zN%|!|iEI>A*Z``mFwW!(gCP`5)xn-0~032$Sep4-qBns>>0~Q1S z)@G+qI~G?DCL@d#gU!9lsy%Jh0<4D-xB<7Vk;xQ6{hzM_kXx zr%}yzE5wJJp-!LFNFy@!_WQ`_hTA|6&}1MX}vH!zYEV%FGZn_g)@HBDrR?#4)aCAE4l+kuK!GV&XtCn9Z{ntXvbc_!J?kQU+-*Or zkmKBI9=s<5Eg1M8Rn4R2*u4{;-Yryz@&o#8-U-Jlv3AFvUpls1$NarKN{(1&nfQ$g zViv#D^r@)kCd!;&;cU}V@mOT{Ml?zT(L(Fn_yZ}NN@(U9K+9T-@|ZW#JemnZXls{Bv2pk%)YCrD@m(?npO5EPyyy6u z2$pqQKx!;U zCQ!gK(;1R1``MMsK|MMnw0Rb5G3vw($$P^!h5FMim}NC_r?(O!Gr}6O{06AFZMnao zW%N0=$@a0j$B1YK(uNBA3%S@lVo!7T)N{1?9k5+^`$|#nN3+>3+IGfNNHuI5v4D}a zPAy0*eD2h-MG${+TdsxcRI+sKjQ#z8Vi>IEJmf;pXxwG7r~Ei0DoS~IWZxCp5(*L3 zflX(ou#)(wljX&w8G*eHvR%cwgLb>~Eq;pQbN*-Ev+hq4doof^&aA&GR|+li9#EXL z0YegeJ+d!$w%#+;j@V-d`YBr%e|?gXOXWDfJg0Zs?63jQt66T)Pi!^B)reGzLfTrc zlSl+h@G;8fGfh6548Vsb$*oHbjxny+$5+gO016-g7@!a|`64O8{?!iz!V3Nwv~W(4 z9jz+f@~z(!--#1x_|B)bWI+aRu?buE$@2M}kSC5%I-T~J9)FD$$LPhF$+QL=z2Eg1 zX6=X3=h`5OM;@>oHBoG2edTKcC1WT#@bXFZW7-p+Byb{anV17cC2Rl53%CXlfFEgM(uwPFJ^Oj>IT5#7Ux;!z73jP zSZ?yjSfkU$rH-Ar&%Kk33|ZfuPU7>W2=l?skFWBL9eixX7bs~k_ps#~yK3@H2j=Em zR3F`G!BW0}mP988S9ZhM;0d87D)gUtcvweD%Hi@wnfr{EKdQ;l@XuG4=r$8?Ut&Ye zm+anawwPo8h=27r%jcUuVZXUcJds6-jLuvnwJmQ9Q*+6_GYQPog>+~&b!AG^G#Q*)eJ72G15b6 zVm#}@;`iVmF{I=E-P1TT3e{m_tFLDU1G|b0rjYyRmDdJD_IaES zn=N3+C(Vo1*&M#WILLCVS@2xWx;Ihf0JB5r+sLSI1Zgs&)e_)he)+^W;ZrZgmZhv3T0%3IdO3;Dtt2rdTCPLRaGe?c13SvkKx}g}18OA^qpd@T$A1*nx#q zp^_&QAbizfz;H~516VBX_gqH%7j6Sb3rpj3@1Qt&^KJ3nY=ENM@x-sEUf9j^UR>oS zl47+-6EoQ!1J8X_?J&XeoS8f=9zCVLx-8A#acs{Dtp-CD`7f!Sa|i4^?A9}PsM^}U z6;Xu=xd^C~NVp2t+gXsRGV_roIoK4PT(u1lKGfnzDu3S!WrdK*lP@dzgB~2>y}Gn` z@Xl@weT3@kmpq?16n6Zo;Q&H$5Ai)EvwwsUHX-9n_nYSu%m(tXxxq_Yw{ktA__n!%oL?7Z&9&)quNE;bI>Ei2A;x~|hF)NKV{4g_BY z`=47}ORyGwv4|ZJ6NNe7S$kYf7jAS(g9Nll?QjeV2|hz}I1iL#du72vYFNk3TC=7U z%vlGGYPV^ibeJ#O&eEes4h=0p3a)dleT%@T=F;$A<^Hg-^Nr0AksbZ#T3tgPX6=H; z61i>#WprcZ@&X;LdX_KgGPO_y3@{trx_DxKx&>R_9&4vT;P%v2(UXo8zCUf;@jSE} z)u*H7DTE6=3~}EDwEx)eG-%xp6`12a=IbkF)|tJyUm1%-NW6wL+$WhoKlCp|mD8v! zmPJrH?@dXVzT;I5oxerZ%Qd9Q`d+M-lfQG0`afBIpgGs<>~4eC+tdJ1g`4)k?9>GO zvpA88%h3b#9bn${JhVyGZ-%2l?7M!j4UMWfKxF)$Ak} zR~M|`G^qFkSwhMM_(+7n+-5szjF&3?sK-2ew8j+7tGkLR#$sOs(dF5XHBc!pDt*eH zxYA}y#13{9j@GDj`Ij|w6}RNquk^xJ(Y;jNjg}?s|L*Tz1yk>%gpy!GFRRk_ig?QP z-Cu>hm<2qS4c<{#wIJEJdDM<{JIo~?0{KWR?ya%uBSn>|vZ3-e)PKdKVmQ$bHiH~n ztZQ}x|4}>nDMb}qo;WWq0FZ+pxSHc7P|ULP_Gcg6TmQWWKy_AuvnGP=!}MBqmB&ZB zn&Wn?gOY*N(Ry}l&EN!|EvHJe6yMjqV$2;GkI@B~0pz#TjrxYH=a;|t%%f5&dvRG7 zv(-!2UN!mclqepqC79{Oy%2EFfqvQIPMadSX)?ZJ(ILI1q9*m%m~7a`XSUkLwkr+U z5V{i0HJ@7}KZQ_hdqP(2&UZlcL5>zmYBg*FemS@Aom`V@<*M-xpr2c&MdYkmF>j0z zuPh95tBSwpcQM|$(aYX-gL14aZ3(|CdeRW5mnQ11#~Y{mbZVCGDeubyj;y~j zN>j3~Y-%-%Ih%cJIG4Jkhybv#1ninKknK)(!PDBkucDG*A291laAiIC!`7U|y7F$%v^E;PS%=`6 z_hj<_A*ECP5aJ#SYVhY;ger7#s4;18>l zQe$EUmWad>VXj0WN%YK5*v?+FNEGJ#ZP2!>w86fPZXpniU~&8XR?8>xmIizo1J$gRbF<0P)V#I z>9LPlx72wdz^-U-v~@k%td0lf9aak2hBe!o*;-D!;{5o#Qg_V0FjIj!*-W?>lJ4}q zmf6Th6Te(46Z0Bx;QYm`ggYaGaRJdu;S+anncTtPh)RO7Hljdl)tV?1W9 zs62#YqdWoT3f4Lo0wh zefd(>JoH-#Car;aR#op06~n0y*-AeP4m_mW9`GLK=ZpUS31j7Dxd6a&|o* zZvgwt^$ei2=RPpczV9A=U@1;VKR;)T>F=V%#xq8mv{g?&>_htV*PrpS>w0Gw2s_gEj?&gQC zWn!d{&wcK5fAiYczV^*q`Wx|&29yKRITq?YG#mL;zjb`l_1p9`mmh!cbc2K6N;_+V=b)rd)1-(&b~ru5-Xjgz?__Q zh?5RjM+6+7bXao*?ou}moHvtZIZirx_&tr2tY0=jJxb%}lSZ9`_quS=M;){G&Nscv zfb>rBU6=7o*V&}{qr9`}t@$jBIzXM|VsZ}BQEUXHtMYpRb7tLO)@vP1-wUKWSf}q6 zgZ&A2-g)PDmgt-TVFlF)&iN1Q)V?g^({6rs8N5phV+Nmf9-c5xNa;tE<7?D|<4`(t z9e_UL$MLLBNXs8U_C2;=EFk9$dS>>Mn*p=ma%|Zreic(bp}z$q9$EJUtebet%W;f> zShD5xVO#EZEi7pagBAMZ^OKd}{n*=k31#{K%$-@CyMT`aZ3BM_8uPoq(*j_>gFxMk z;CwA~*Y;(<{QQzT=&qE({d&UW0uz0#Me@Cx+39g%IJL-L+x_IrBaG+onpSB&f#WQO zrzs21u@1|L@w*@@iKS@P9szvv5oaF*NKYp0v_)oj;RmfC_Yxp4=Qo&aXW-g?X9Phw zQrNB?YXH}J4vZmIu+08*o+oU+iw&%f4;EkU$5R)@K<_U*H}!YgzBiA~=F^_712PNl z_ZH{8(SwKz?mF4dviMGq1loh!89Ao$!`bm_+gWr4>__bbNZ$bYUu`VVty^aY1G7At@bsd~a%+RfezaBJDYI{xSQ zsV8wbmiIoj&XN93SbaGIb$*SL9KN#bRi0JWi{O3=5-fixqps@cdShS$?*JXOcQ<(l zl*6jK>4%1Bt3yg49UpZSl-teU$NqrTgI7R(Rvo7A9-__t)c1txJD{B#d2BC=vCrh4 z+cq!a}_uiVcGiKPYiz98YFUv4I4(w^y{Yh?G zntU7s)#TIHN2raq!01M6G6$<6Y?{~e9x7o<)s2ZDRF8{scA)1#^JMq_HQ9fT#fgNV z)IJ+gyOzmMEVBD*H|q^|5exuSioL5IV+R*D@6)*HMZfWh1`BcHgT;;k`drVlKN$N2 z@~H_eXG2E8PcN&8Ds|@qtT}bI96|agKl#Z&eZ?zY@oRVAefMWLuQ2HgcM|;32leD` z7T<+mQx=_XZq~1M$|@RnEfaTQrtfaA6Fgvk>G-D8HaA?swlI00TRw=J-hp(Obj;lq z<3Lo3`)})?jvsmDSS~B}A}N!u5xVqu1=Dw!cGT%>Y>oa^b&eIoq-*j^w?1pY{^cMh z*P6p|(vrBG#!{~?TYkPF9H@69ao8!}0q4e4)k|f(<~6VR*H3xMQ|{c3_glAP{c1OB zcQ%>OXckMo$_ccum;hhzkhZV+%C9{vN6@}vzbT|}Z@~{<(@e_qK^*47$$^|0Kjb+i zo0qe29mzrbA%ObJ-$840@HHr1Z)4mGbNB=}u}~;09215M<1T=r<+ff{`gYbMeLVTQ z*N@2nr}xjIY#F?VbG|x9Ym;}YZ^ub0^MUof=)TYo*(xXV_Z;#ppQCub){6D6>o7tn z%y16bP6EE{OLm7?9E0xzITNs#M!v|V^6PQtdOZJ)$%^ZKoZe3uO@NDo9bt2W*j=Nx z?jSY+@P(N>%a-R5b76uaGVFR>u<3dnRmV7McHZk;4_hjnMxy0I)zJPT#ai<;z zox~XAl~nCr#>7&0sq(J($p+Zx&ZJj9?t9<+o_11l7UltzyT5wn0Cj*gZU*XJTsf-m zqWnHZQ;a4L$5!i%DjPXF4XpDw8~muC+!=OFu01lpY#H^+lc~GDm|@jfM^ya=K%E&E znSH^;0>6iWXhGGZ{EkVyg=iZGNwDfL>mHVkR5vvO4Eh2>@x5{Yy2Mhiaxv*+p8*@n zs_Tc1iphze$`JE+P8xt+P2geI&!Amq{RZYam2ctQZhG!>pZnx(Umsbf@eZshckH{H z&bja$lNff=%0xbi+VPqRLV=c+ z=X{cPfWA3j!ACr9cO?!ra@(5S4wg3^Kpp`=$7M+^L*9T|QZ$F}fKocHP?!A3bz*lW zS1e_htGt-}^>dR=-AT)d@HG$)LS*XueR2@lFtIK~>)XzC`S?61v7g>H7cK+wTDz%v zXVRWr?4(ahvtz!4bw<4J(sOB{_y(km{bqL{hULy0rE`6$f7-^oe5gM<5n2qSA2D~Y zHclN3I&AufKm6g}`^Inl#@};OcTE6we9axSD{%L3PR@tg3}J`t71)ZLhL4Z)^BeM&s?2_BK_TEf?rqxL=}{2G4fqv`U8VpPBgBT0ptbPD?R5q&p86lP3>L(W@n|%W3hqu0eDaD*k9H@>IMLZo=kED zP z53$Gt@Vr>1ey)_<^F)A;nN4N$eq=veKjw4{)|LQ#Wd2M9EBI^2@mHb1e4CMd_A%67 zJ%Mkxnn?iYQlEYv$fYm5A8!Vp4l<)@xB<(Q2FJ;+b!zjPBIAB}7_`=3+SuuMe$$v~ zd&>AX6N+?AG5LMMD~j2LxW^;+$&+9gfO&GMi|woN5@1PeHur5;cjDGhVMFcyp2lEC zW%}-S%s_dX^lg>bxu%TKv^!{;HbED|n>qMS0&&jxQfHACDsP#4|HkOp{3MjtFz3FX z!|T;;fQuZ!c?9Ym5ZC$;1~pjHCExyaJoF@7tGWdW#P!|o%JP1%B&!vl6i)9twk!3N z`AaPNalfBYc85W)@y@I|6Wi~5-}~P6>}NmwPYawYb6d+>Dt6uXaf*^V`=fP3SoO+L zbSIwz(ub7sOLu^t;-xDD~+r-@wZ9e29w`6gx+eVatMV*~9s zqV#QyMF*aPNQ?BbdSa<6h)yA0_;EF`A4GFP98*iat;D)F>$@mR2HaVI&eu8MD;R$4 zP242{_LX95*suJ-Z0Eh}>wUCtH0}h+96y^i=j36m_yB9e&QuBlH|bpJ{x4&n)0Ti* zC-#QoZQK{e%I9g-k8xdTw*{fH>>QsbDYUKAz6^* zb5B}ZI>eA@e*V3l9p@aVOM?OO=@UX~it5i8LUk4^x&z=GPYey`$LB2Dy|5|@Z1_@} zL-Vx%dW;t{=Q=s-bR*@)3aka6>31tAn*-|GaAd(Ge&zo@lj?W?vfg)AQov1{o{9{N z*MQr>vGk2%eG4=0aPEzj&qAqlrK&FNF?PBU9-Utk1ZS4Le$y(we%-i2m^RR^HEAG+2#kVh}VL$pyRpx#5a zIR^N$HhuSxC_h=gJUUfB^-ovPfg0K_zM8>nh_;E@J8}P-5N)pfUI|9M^-<>-@&Bk9 zKZG1a?R~I`o^85Y*8UAsUSalQ47?j`m#y3826QO(9 zHuLt#Z6EG1;2izYcb_wUaLDP?f^P-w*2jN)2n#c*NO4hD!gPDywYLwg&qf>Lb;`iq z&DOGSeDZB$y_BrKgK*X#X?tOuC6HhHBd-~8`yf~{@u6-Bjyu*EpwlSYjt+U(`SpG$2Ae~vG+D#-f^oSlS4%4zBya z@0ghl8(4_iDBjjtg&8Be^tpFHOkQAr34l9xoH_@}ZmLfklg=xj_Z4$3LTL_aXE({9 z*N}ce11B>0A%3}<`k%7(2l;veNcYvY(~ac}dGsgem=?M_bLFlg-)9$10@sT6{^n2n z9i1(&Wo6PGuh1>0Ydr_&Q%v3g=qHJhPV;1$dkB!O z=k^+Y=|dQG`Y1@(Fl>m&At+t_0O+Ios>(YSy6&H@3h(}ItZ!_}fOK-ieE%;D{1`n%5T5@_ESabsx}MD1_?`SbijEqcQ^2zmEV&;dL=OD{IXVkPavW* zsnHX=m_-?`_|A=xLbL(c88d}xa|L*ATo2KPVj`@17d}Xo--}%bv~ylCeb*3esJ;W# z)hAuk5Fhnns*;0F=BG|QVI(l;8O`kCpfBM4WiNZ#O;3ON)9-l5Lmu+TZGZ0Aj{SZn z1Q-3$6|_4Gfqxh4O4~GFzSc*L?R4wYr(@t3A2yNwP*BGY>_VM2GVbns==DLcPRCub zb#lL81(xh%0uM*?vkWP+n)n8%uuZ|lFZzThAj;niOzzUZHvrYnH})TB zM!!}?cMFG$#PlpBe{Wq#=JPPU4TxX*$IG)8Eh4;}MBM^NrY@?aM=Oxag$@}5fvxkz zZ}!C=4&axW^v!e-+#Ye4U4`Ok4qDb`cp}ndIjUK=9LVzUx)8H zz#NcX?b|o{U;Xq?|MV~Y?9cw}r#hc>&h-x9C*QH+pC0c~)`d~0jc{w>JWYJklUO67 z&oJqL^SzGsT9x<01em;2=aYYG@?Lf3lTI|MsryDKHW+m^cL(pdy1Q$5A-oN6XY4LC zHpd>^lohO3Sp@1V8Pkq?Gypx2z^(GTk9h^u?c>I0IykAk1k_xC1f$Ny)C6F!q{4FC zB&z(5GT}B;b>!wV(|2ynA^z#a5*&3?myn!-jRNdtUV@mMf8YStRrwu1bzXKR7C~6H zE_74g&*+X6;$c*O#)J=6ib!>PcZ+#(c{$X0!Ozw*}^ez6t*Gne|?q#xM41 zDa^J2iW#^BV0x+Va8#nk-@6;j!=?GSwj89`Ghog>|CuS&xY>+p>m$duezzF`YLNyBzk7brO4P=PvYH{T})CSWkQ;0I!GTDXg>D=Y`;%S-r^i)1-@o zX>VW0FV7*~mTK=w`#^n$!+uo4_uhfNzk3;frC{BPVl6-nxS2U;4pn zs5P+$=&b$HIIeTRvaTDS9nPg&;Ax^gz4JvJRlilMj^|p{-H&SSj-tEAOve|!mbY*0 z|7Q!o_8;H#p7(r4k6l4J;&3h)*TqklzEf1*-Q>N>lX4d$T}9;}%Up*EzjV$U{^|O@ zAXYl-3OJ`;L$N{rO~n^5>0HMG=x+87qYhBFkZY77ETd04Ogg$><(sZ<$Lf!6Nqy1> z=Mpv@JVr=1`_uPIvdWGj+g!WHnDsB`@ND^G#Qg{yxO<2;mFowvWA;u$@qGvE1E3C@ z&W%M(b&t`Gn4GG;3nr;+>5fQ2Y+Cxa0Fd@(=qefL>8vQtfiTlcgGyeEf=gXEyc0K`p2QLn6 z7eZl{B1^Y6sb9zZUI7c3`xx>phltBz(1POp^!-hYxvnE)MZn3VO%}e(3^dmz=m;cJ zZY$@sx-P9cJB@)d*8QfeV`(^|26RU=?Ukvw_YbVGkMD}g?b!4YKYahN=zult5g6M& zy=22ynh=RG4}BL*U38)FW6VNX8==^QQJ*Ngt1Ixez$_qL+6PQJuE=HXF7C<)$8t@bZs(dn zy$eIC_x(ccy%w%a-@^}WtaG(@SAN%vYa!b3Q6J3ep!8ljKI%Ffw1*Zrq!4Xx`Yy_d zfb}waSHXrVkID<6%R^LEV=K?CjM@7TVThqI)C;Uve|%TuJ$~FCcuZ*k>{HC&tIip9 zOmqgJ*(43|$8j_GOMHVB;Gco}^Pcy-2R`aik9x#5>2TY29AE1{6vHxJc+|- zUy_w$?Db@Zs0?HT@QkHkKJ=kXg(lyY1M?MI(gJL@wM{tju{0Q`?rA*pecibWrY{fx)UvYb!a9pEtW-}>bfT_K6rImp)QmE0@b>dDXZ!gXXNI7|Dm zdT-Ca2l@S{Vw6L{In21mKo|3N>YQ00fcZcBvp@TdZ~2yQ`NOU3%I>vHS%GI*M~kcQ&zYFS{9T!Jl3oY(M~5WE6}edTDjPnVsxwB2HV@BMm=9)M zwaTh&LaWNGhu?~z$z;n{K%En@4$;O5A8zu1k2<1q4${@k!oj+!5+ZznsNA7Ze(yrH z4N*tbSn7av{6PTe95cY351wNI^hz!yC;i9%0DDL_6yH&L*VyT!5tEyg_naFTF*%K| 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 n3z6f^E8Km}+1PM7 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 literal 0 HcmV?d00001 diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index ee42fbf0..8a1088b9 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -130,6 +130,30 @@ Observed behavior: | Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. This is safe for same-spec coordinator flows but does not authorize raw Pi session switching. | `src/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | +## Startup/splash logo asset decision + +Brunch should render the startup/splash logo as TUI chrome, not as a session message, so it does not persist in the transcript/log. For the preferred blocky aesthetic, the selected rendering is a pre-generated Chafa Unicode-symbol asset rather than runtime image rendering: + +- Source PNG copied from the legacy Brunch app to `assets/brunch.png`. +- Preferred splash asset: `assets/brunch-logo-quad-56x18.ansi`. +- Lower-color fallback asset: `assets/brunch-logo-quad-56x18-240.ansi`. +- `package.json` includes `assets` in published package files so runtime code can read these files directly. + +The selected generator command for the preferred asset is: + +```sh +chafa -f symbols \ + --symbols=quad \ + --colors=full \ + --color-space=din99d \ + --color-extractor=median \ + --bg=black \ + --size=56x18 \ + assets/brunch.png > assets/brunch-logo-quad-56x18.ansi +``` + +Runtime should **not** invoke Chafa on startup. The logo should be deterministic, cheap to render, and independent of host-installed CLI tools. Chafa is therefore a maintainer/dev tool at most, not a runtime dependency. Startup chrome should choose `brunch-logo-quad-56x18.ansi` when truecolor is available, otherwise `brunch-logo-quad-56x18-240.ansi`; for very limited terminals, a plain `brunch` wordmark is sufficient rather than carrying 16-color or 8-color assets. + ## RPC controllability observations relevant to command containment and chrome Raw Pi RPC success is not Brunch integration proof, but it matters for the fixture-driver oracle: diff --git a/package.json b/package.json index 0a30eb52..6794ca11 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "files": [ "dist", "dist-web", - "bin" + "bin", + "assets" ], "scripts": { "dev": "tsx src/brunch.ts", From b5a0f33a4ca1536d12a17ec74da3f6c3df8f4247 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:14:08 +0200 Subject: [PATCH 007/164] FE-744: Add workspace launch inventory --- memory/CARDS.md | 231 ++++++++++++++++++++++ src/workspace-session-coordinator.test.ts | 141 ++++++++++++- src/workspace-session-coordinator.ts | 126 ++++++++++++ 3 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..db80ee60 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,231 @@ +# FE-744 Scope Cards — Workspace switcher / startup flow + +## Orientation + +- Containing seam: Brunch TUI/workspace-session boot seam over Pi `SessionManager` and `InteractiveMode`; the coordinator owns spec/session effects, while UI/adapters return product decisions. +- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these are slices within the existing frontier, not new Linear issues or branches. +- Volatile state from `HANDOFF.md`: `memory/SPEC.md` now persists D21-L/D22-L/D35-L/D36-L/I22-L; dirty `src/brunch-tui.ts` and `src/brunch-tui.test.ts` suppress generic Pi startup noise but do not solve implicit stale transcript resume. +- Main open risk: Pi session inspection may tempt activation/binding as a side effect; keep inventory/read-model code separate from activation, and prove no prior transcript reaches Pi before explicit resume/open. + +Frontier-level obligations every card must preserve: + +- Preserve workspace hierarchy and startup invariant: `.brunch/state.json` is default acceleration, not an implicit resume instruction; no prior transcript or agent loop before explicit workspace-switch activation (R19 / D11-L / D21-L / D22-L / D36-L / I22-L). +- Preserve the linear transcript policy: no Pi branch creation/navigation as Brunch product behavior, and no transcript flattening to hide branch shape (D24-L / I19-L). +- Keep UI and adapters out of session mutation: only `WorkspaceSessionCoordinator` may create/open Brunch Pi sessions, write `.brunch/state.json`, or write `brunch.session_binding` (D21-L / D36-L). +- Keep chrome product-shaped: when a real session is activated, downstream chrome receives the activated session id rather than fabricating `unbound` (D35-L). + +--- + +## Card 1 — Workspace launch inventory + +- **Status:** done +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +The coordinator can report launch inventory for existing Brunch specs/sessions without activating a session. + +### Boundary Crossings + +```text +→ caller asks WorkspaceSessionCoordinator.inspectWorkspace() +→ .brunch/state.json default-state reader +→ .brunch/sessions/*.jsonl binding/header/message scanner +→ WorkspaceLaunchInventory read model +``` + +### Risks and Assumptions + +- RISK: Inventory scanning accidentally calls existing bind/open helpers and rewrites JSONL/state. → MITIGATION: implement a read-only scanner path and assert file counts/content mtimes or source boundaries in tests. +- RISK: Current spec state is not enough to enumerate historical specs. → MITIGATION: reconstruct spec candidates from `brunch.session_binding` entries and treat state-only current spec as a candidate with zero/unknown sessions. +- RISK: Session labels become a premature UX taxonomy. → MITIGATION: expose minimal stable fields first (`sessionId`, `file`, `spec`, optional `name`/first-message preview/timestamps) and keep rich label formatting in the switcher model. +- ASSUMPTION: Existing linear JSONL headers plus `brunch.session_binding` entries are sufficient for launch inventory. → VALIDATE: inventory tests with current/default session, multiple sessions, missing state, and incompatible bindings. → memory/SPEC.md A1-L, D6-L, D21-L, D36-L + +### Acceptance Criteria + +✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` returns cwd, current spec/session defaults, bound specs, and bound sessions for a seeded `.brunch/state.json` plus multiple JSONL sessions. + +✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` on an empty workspace returns an inventory requiring new-spec creation without creating `.brunch/sessions/*.jsonl`. + +✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` marks unbound or incompatible JSONL sessions unavailable instead of binding, rewriting, or silently selecting them. + +✓ Boundary/source test — inventory code does not call `bindSessionToSpec`, `appendCustomEntry`, `SessionManager.create`, or `writeCurrentWorkspaceState`. + +### Verification Approach + +- Inner: unit + boundary tests — prove the read model shape and read-only behavior. +- Middle: store oracle — compare before/after `.brunch/state.json` and session JSONL content for no activation writes. + +### Cross-cutting obligations + +- Inventory is not activation; it must not mutate `.brunch/state.json`, create sessions, or write `brunch.session_binding`. +- Inventory must preserve Brunch-supported linear-session assumptions and surface invalid sessions honestly. +- Inventory types should be Brunch-owned; Pi types should be imported/projected only where Pi owns the envelope (`SessionHeader`, `CustomEntry`, `SessionInfo`) per `docs/praxis/pi-types.md`. + +--- + +## Card 2 — Workspace decision activation + +- **Status:** next +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +The coordinator can turn an explicit workspace decision into the resulting ready or cancelled workspace state. + +### Boundary Crossings + +```text +→ WorkspaceSwitchDecision from UI/adapter +→ WorkspaceSessionCoordinator.activateWorkspace(decision) +→ session binding/state validation +→ SessionManager.open/create through coordinator-owned helpers +→ .brunch/state.json + binding-only JSONL persistence +→ WorkspaceSessionReadyState or cancellation result +``` + +### Risks and Assumptions + +- RISK: `continue` reintroduces implicit resume semantics. → MITIGATION: only call activation after a caller supplies an explicit `continue` or `openSession` decision; keep `openExisting()` from being the TUI startup path after Card 4. +- RISK: Cancel/quit return shape leaks into durable architecture. → MITIGATION: keep cancellation a small adapter-facing product result with no persistent state mutation; update SPEC only if semantics exceed D36-L. +- RISK: Opening a selected session with stale/mismatched binding corrupts current state. → MITIGATION: validate selected file binding against the decision spec before writing `.brunch/state.json`. +- ASSUMPTION: Existing binding flush helper remains sufficient for newly-created binding-only sessions. → VALIDATE: reload newly-created sessions with `SessionManager.open` and `verifyWorkspaceSessionStores()`. → memory/SPEC.md D21-L, I8-L + +### Acceptance Criteria + +✓ `workspace-session-coordinator.test.ts` — activating `{ action: "openSession" }` or `{ action: "continue" }` opens the selected bound session, writes it as the current workspace default, and returns `WorkspaceSessionReadyState` with the real session id. + +✓ `workspace-session-coordinator.test.ts` — activating `{ action: "newSession" }` creates a binding-only session for the selected spec, writes it as current, and preserves all existing sessions. + +✓ `workspace-session-coordinator.test.ts` — activating `{ action: "newSpec" }` creates a new spec plus binding-only session and makes that pair current. + +✓ `workspace-session-coordinator.test.ts` — activating `{ action: "cancel" }` returns a non-ready cancellation result and leaves `.brunch/state.json` plus session files unchanged. + +✓ `workspace-session-coordinator.test.ts` — activating a mismatched or unavailable session fails with a structured `needs_human`/error result rather than rebinding it. + +### Verification Approach + +- Inner: coordinator contract tests — prove each decision discriminant and returned state shape. +- Middle: store oracle — prove state JSON and session binding postconditions after each activation path. +- Middle: reload round-trip — prove binding-only sessions reopen without duplicate headers/bindings. + +### Cross-cutting obligations + +- Activation is the only place this queue may create/open Brunch Pi sessions or write bindings/state. +- New-session activation must land in a binding-only session for the selected spec; no assistant/user transcript entries are required. +- Returned ready state must carry enough product state for chrome to render the real session id in later cards. + +--- + +## Card 3 — Workspace switcher decision UI + +- **Status:** queued +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +The workspace switcher UI can turn launch inventory into a typed workspace decision with no workspace side effects. + +### Boundary Crossings + +```text +→ WorkspaceLaunchInventory +→ workspace-switcher option/label model +→ pi-tui selection/input component or testable component factory +→ WorkspaceSwitchDecision +``` + +### Risks and Assumptions + +- RISK: UI imports the coordinator and becomes a hidden mutation path. → MITIGATION: keep `workspace-switcher/*` dependent only on inventory/decision types and `@earendil-works/pi-tui`; add a source/boundary test. +- RISK: First-screen choices overfit current fixture data. → MITIGATION: start with stable actions only: continue current session when available, start new session in a spec, choose/open another session, create spec, cancel/quit. +- RISK: Direct `@earendil-works/pi-tui` usage remains transitive. → MITIGATION: add `@earendil-works/pi-tui` as a direct dependency when importing it. +- ASSUMPTION: Pi `SelectList`/`Input` components are sufficient for the first switcher surface. → VALIDATE: component tests or a minimal render/input harness for up/down/enter/escape/name entry. → memory/SPEC.md D22-L, D36-L, A10-L + +### Acceptance Criteria + +✓ `workspace-switcher.test.ts` — option construction from inventory prioritizes explicit resume/new-session/create-spec/cancel choices without inventing a default exhaustive lens/menu surface. + +✓ `workspace-switcher.test.ts` — selecting an existing session returns `{ action: "openSession", specId, sessionFile }` and selecting current resume returns an explicit continue/open decision. + +✓ `workspace-switcher.test.ts` — selecting create-spec plus title entry returns `{ action: "newSpec", title }`; escape/cancel returns `{ action: "cancel" }`. + +✓ Boundary/source test — `workspace-switcher/*` does not import `SessionManager`, `WorkspaceSessionCoordinator`, or session-binding write helpers. + +✓ Dependency check — if the component imports `@earendil-works/pi-tui`, `package.json` declares it directly. + +### Verification Approach + +- Inner: pure model tests — prove inventory-to-option and option-to-decision mappings. +- Inner: component input tests — prove enter/escape/navigation/name entry where feasible without a full terminal. +- Middle: boundary/source test — prove UI cannot mutate workspace/session state directly. + +### Cross-cutting obligations + +- Switcher UI returns decisions only; coordinator activation owns all effects. +- Continue/resume must be an explicit selectable decision, not an automatic consequence of `.brunch/state.json`. +- Keep line widths bounded in custom components; use `truncateToWidth`/`SelectList` patterns from Pi TUI docs. + +--- + +## Card 4 — Pre-Pi startup gate + +- **Status:** queued +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +TUI mode starts Pi `InteractiveMode` only after a workspace switch decision has been activated. + +### Boundary Crossings + +```text +→ runBrunchTui() +→ coordinator.inspectWorkspace() +→ runWorkspaceSwitchPreflight(inventory) +→ coordinator.activateWorkspace(decision) +→ launchPiInteractive({ workspace, coordinator }) +→ Pi InteractiveMode.run() +``` + +### Risks and Assumptions + +- RISK: Existing `openExisting()` call path remains reachable from TUI startup and still renders stale transcript. → MITIGATION: replace TUI boot with inspect → decision → activate; keep `openExisting()` only for print/RPC/headless paths that intentionally project defaults. +- RISK: Pre-Pi TUI lifecycle leaves terminal state dirty before Pi starts. → MITIGATION: isolate terminal lifecycle in `runWorkspaceSwitchPreflight()` and add manual/pty runbook coverage after unit tests land. +- RISK: Dirty Pi startup-noise suppression gets confused with the startup fix. → MITIGATION: keep suppression as product-shell hardening in this adapter, but acceptance must prove no transcript launch before decision independently. +- ASSUMPTION: Injected preflight runner is enough to prove boot ordering before a full pty oracle is added. → VALIDATE: unit test with stale transcript seed and launch spy, then follow with pty/ANSI runbook before tying off FE-744. → memory/SPEC.md I22-L + +### Acceptance Criteria + +✓ `brunch-tui.test.ts` — `runBrunchTui()` calls inspect/preflight/activate before `launchInteractive`, and `launchInteractive` receives the activated ready workspace. + +✓ `brunch-tui.test.ts` — with an existing current session containing transcript entries, TUI startup does not call `launchInteractive` when the preflight returns cancel. + +✓ `brunch-tui.test.ts` — with an existing current session containing transcript entries, choosing `newSession` launches a different binding-only session for the same spec. + +✓ `brunch-tui.test.ts` — chrome setup receives activated chrome/session state sufficient to render the real session id, not `unbound`. + +✓ Existing startup suppression test still passes or is replaced by an equivalent product-shell assertion for quiet Pi resources and `PI_OFFLINE`. + +### Verification Approach + +- Inner: TUI boot unit tests with injected coordinator/preflight/launcher spies — prove ordering and no implicit resume. +- Middle: store oracle after new-session decision — prove binding-only session and preserved prior transcript. +- Middle: pty/ANSI-stripped runbook follow-up — prove prior transcript text is absent before explicit resume/open in an actual TUI launch. + +### Cross-cutting obligations + +- Do not start `InteractiveMode` before decision activation. +- Do not delete or mutate prior transcript when the user chooses a new session. +- Keep generic Pi resource/update suppression separate from the workspace-switch invariant; suppression reduces shell noise but does not prove I22-L. + +--- + +## Not queued yet + +- Product-shell metadata hardening: fold/review the dirty startup-noise suppression, reduce duplicated header/widget/footer/status facts, and decide permanent `PI_OFFLINE` semantics after Card 4 proves the startup gate. +- In-session workspace switcher command: reuse the same decision UI through Pi `ctx.ui.custom()` plus `waitForIdle`/session replacement; scope after the pre-Pi path proves the reusable decision model. diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index 2df1b026..e151e40d 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -260,6 +260,145 @@ describe("WorkspaceSessionCoordinator", () => { ) }) + it("inspects current defaults, bound specs, and sessions without activation writes", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) + first.session.manager.appendMessage({ role: "user", content: "first" }) + const second = await coordinator.startOrCreate({ + specTitle: "Beta", + createNewSpec: true, + }) + const beforeState = await readFile( + join(cwd, ".brunch", "state.json"), + "utf8", + ) + const beforeFirst = await readFile(first.session.file, "utf8") + const beforeSecond = await readFile(second.session.file, "utf8") + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory.cwd).toBe(cwd) + expect(inventory.needsNewSpec).toBe(false) + expect(inventory.currentSpec).toEqual(second.spec) + expect(inventory.currentSessionFile).toBe(second.session.file) + expect(inventory.specs.map(({ spec }) => spec.title)).toEqual([ + "Alpha", + "Beta", + ]) + expect(inventory.specs[0]?.sessions).toEqual([ + expect.objectContaining({ + id: first.session.id, + file: first.session.file, + specId: first.spec.id, + specTitle: "Alpha", + available: true, + }), + ]) + expect(inventory.specs[1]?.sessions).toEqual([ + expect.objectContaining({ + id: second.session.id, + file: second.session.file, + specId: second.spec.id, + specTitle: "Beta", + available: true, + }), + ]) + expect(inventory.unavailableSessions).toEqual([]) + await expect( + readFile(join(cwd, ".brunch", "state.json"), "utf8"), + ).resolves.toBe(beforeState) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + beforeFirst, + ) + await expect(readFile(second.session.file, "utf8")).resolves.toBe( + beforeSecond, + ) + }) + + it("inspects an empty workspace without creating session files", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory).toMatchObject({ + cwd, + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], + }) + await expect( + readFile(join(cwd, ".brunch", "sessions", "missing.jsonl"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }) + }) + + it("marks unbound or incompatible sessions unavailable during inventory", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const unboundFile = join(cwd, ".brunch", "sessions", "unbound.jsonl") + const mismatchedFile = join(cwd, ".brunch", "sessions", "mismatched.jsonl") + await writeFile( + unboundFile, + `${JSON.stringify({ type: "session", id: "unbound-session", cwd })}\n`, + "utf8", + ) + await writeFile( + mismatchedFile, + `${JSON.stringify({ type: "session", id: "header-session", cwd })}\n${JSON.stringify( + { + type: "custom", + customType: SESSION_BINDING_TYPE, + data: { + schemaVersion: 1, + sessionId: "other-session", + specId: ready.spec.id, + specTitle: ready.spec.title, + }, + }, + )}\n`, + "utf8", + ) + const beforeUnbound = await readFile(unboundFile, "utf8") + const beforeMismatched = await readFile(mismatchedFile, "utf8") + + const inventory = await coordinator.inspectWorkspace() + + expect(inventory.specs).toHaveLength(1) + expect(inventory.specs[0]?.sessions).toHaveLength(1) + expect(inventory.unavailableSessions).toEqual([ + expect.objectContaining({ + file: mismatchedFile, + reason: "incompatible_binding", + }), + expect.objectContaining({ file: unboundFile, reason: "missing_binding" }), + ]) + await expect(readFile(unboundFile, "utf8")).resolves.toBe(beforeUnbound) + await expect(readFile(mismatchedFile, "utf8")).resolves.toBe( + beforeMismatched, + ) + }) + + it("keeps inventory scanning out of activation and binding helpers", async () => { + const source = await readFile( + new URL("./workspace-session-coordinator.ts", import.meta.url), + "utf8", + ) + const inspectMethod = source.slice( + source.indexOf("async inspectWorkspace()"), + source.indexOf("async openExisting()"), + ) + + expect(inspectMethod).not.toContain("bindSessionToSpec") + expect(inspectMethod).not.toContain("appendCustomEntry") + expect(inspectMethod).not.toContain("SessionManager.create") + expect(inspectMethod).not.toContain("writeCurrentWorkspaceState") + }) + it("asks for spec selection when no current spec exists and creation is not allowed", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) await mkdir(join(cwd, ".brunch"), { recursive: true }) diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index b2a63588..6b89acf6 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -64,7 +64,39 @@ export interface WorkspaceSessionNeedsHumanState { export type WorkspaceSessionState = WorkspaceSessionReadyState | WorkspaceSessionSelectSpecState | WorkspaceSessionNeedsHumanState +export interface WorkspaceLaunchSession { + id: string + file: string + specId: string + specTitle: string + name?: string + available: true +} + +export interface WorkspaceLaunchSpec { + spec: WorkspaceSpecState + sessions: WorkspaceLaunchSession[] +} + +export type WorkspaceUnavailableSessionReason = "missing_header" | "missing_binding" | "incompatible_binding" + +export interface WorkspaceUnavailableSession { + file: string + reason: WorkspaceUnavailableSessionReason + available: false +} + +export interface WorkspaceLaunchInventory { + cwd: string + currentSpec: WorkspaceSpecState | null + currentSessionFile: string | null + needsNewSpec: boolean + specs: WorkspaceLaunchSpec[] + unavailableSessions: WorkspaceUnavailableSession[] +} + export interface WorkspaceSessionCoordinator { + inspectWorkspace(): Promise openExisting(): Promise startOrCreate(options?: { specTitle?: string @@ -91,6 +123,10 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { this.#cwd = cwd } + async inspectWorkspace(): Promise { + return inspectWorkspaceInventory(this.#cwd) + } + async openExisting(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { @@ -280,6 +316,96 @@ async function readWorkspaceState( } } +async function inspectWorkspaceInventory( + cwd: string, +): Promise { + const state = await readWorkspaceState(cwd) + const files = await listSessionFiles(cwd) + const specsById = new Map() + const unavailableSessions: WorkspaceUnavailableSession[] = [] + + if (state) { + specsById.set(state.currentSpec.id, { + spec: state.currentSpec, + sessions: [], + }) + } + + for (const file of files) { + const session = await inspectSessionFile(file) + if (session.available) { + const spec = getOrCreateLaunchSpec(specsById, { + id: session.specId, + title: session.specTitle, + }) + spec.sessions.push(session) + } else { + unavailableSessions.push(session) + } + } + + const specs = [...specsById.values()] + .map((spec) => ({ + ...spec, + sessions: spec.sessions.sort((left, right) => + left.file.localeCompare(right.file), + ), + })) + .sort((left, right) => left.spec.title.localeCompare(right.spec.title)) + + return { + cwd, + currentSpec: state?.currentSpec ?? null, + currentSessionFile: state?.currentSessionFile ?? null, + needsNewSpec: specs.length === 0, + specs, + unavailableSessions: unavailableSessions.sort((left, right) => + left.file.localeCompare(right.file), + ), + } +} + +type InspectedSessionFile = WorkspaceLaunchSession | WorkspaceUnavailableSession + +async function inspectSessionFile(file: string): Promise { + const entries = await readJsonl(file) + const header = entries.find(isSessionHeader) + if (!header) { + return { file, reason: "missing_header", available: false } + } + + const bindings = entries.filter(isSessionBindingEntry) + if (bindings.length === 0) { + return { file, reason: "missing_binding", available: false } + } + + const binding = bindings[0]! + if (bindings.length !== 1 || binding.data.sessionId !== header.id) { + return { file, reason: "incompatible_binding", available: false } + } + + return { + id: header.id, + file, + specId: binding.data.specId, + specTitle: binding.data.specTitle, + available: true, + } +} + +function getOrCreateLaunchSpec( + specsById: Map, + spec: WorkspaceSpecState, +): WorkspaceLaunchSpec { + const existing = specsById.get(spec.id) + if (existing) { + return existing + } + const created = { spec, sessions: [] } + specsById.set(spec.id, created) + return created +} + async function writeWorkspaceState( cwd: string, state: WorkspaceStateFile, From 29c6d8950578c50bdac266355c8bcc803b7e8ea8 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:15:59 +0200 Subject: [PATCH 008/164] FE-744: Activate workspace switch decisions --- memory/CARDS.md | 4 +- src/workspace-session-coordinator.test.ts | 154 +++++++++++++++++++++- src/workspace-session-coordinator.ts | 111 ++++++++++++++++ 3 files changed, 266 insertions(+), 3 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index db80ee60..a0299555 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -67,7 +67,7 @@ The coordinator can report launch inventory for existing Brunch specs/sessions w ## Card 2 — Workspace decision activation -- **Status:** next +- **Status:** done - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 @@ -121,7 +121,7 @@ The coordinator can turn an explicit workspace decision into the resulting ready ## Card 3 — Workspace switcher decision UI -- **Status:** queued +- **Status:** next - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index e151e40d..4cfff5cf 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -383,6 +383,158 @@ describe("WorkspaceSessionCoordinator", () => { ) }) + it("activates explicit open and continue decisions as the current workspace", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const second = await coordinator.startOrCreate({ + specTitle: "Beta", + createNewSpec: true, + }) + + const opened = await coordinator.activateWorkspace({ + action: "openSession", + specId: first.spec.id, + sessionFile: first.session.file, + }) + + expect(opened.status).toBe("ready") + if (opened.status !== "ready") { + return + } + expect(opened.spec).toEqual(first.spec) + expect(opened.session.id).toBe(first.session.id) + expect(opened.session.file).toBe(first.session.file) + expect(opened.chrome.spec).toEqual(first.spec) + + const continued = await coordinator.activateWorkspace({ + action: "continue", + specId: second.spec.id, + sessionFile: second.session.file, + }) + + expect(continued.status).toBe("ready") + if (continued.status !== "ready") { + return + } + expect(continued.spec).toEqual(second.spec) + expect(continued.session.id).toBe(second.session.id) + expect( + JSON.parse(await readFile(join(cwd, ".brunch", "state.json"), "utf8")), + ).toMatchObject({ + currentSpec: second.spec, + currentSessionFile: second.session.file, + }) + }) + + it("activates a new session decision as a binding-only session for the selected spec", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) + first.session.manager.appendMessage({ + role: "user", + content: "preserve me", + }) + const beforeFirst = await readFile(first.session.file, "utf8") + + const created = await coordinator.activateWorkspace({ + action: "newSession", + specId: first.spec.id, + }) + + expect(created.status).toBe("ready") + if (created.status !== "ready") { + return + } + expect(created.spec).toEqual(first.spec) + expect(created.session.id).not.toBe(first.session.id) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + beforeFirst, + ) + const createdContent = await readFile(created.session.file, "utf8") + expect(createdContent).toContain(SESSION_BINDING_TYPE) + expect(createdContent).not.toContain("preserve me") + const oracle = await verifyWorkspaceSessionStores({ + cwd, + expectedSessionCount: 2, + }) + expect(oracle.ok).toBe(true) + }) + + it("activates a new spec decision by creating a bound current session", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + + const created = await coordinator.activateWorkspace({ + action: "newSpec", + title: "Gamma", + }) + + expect(created.status).toBe("ready") + if (created.status !== "ready") { + return + } + expect(created.spec.title).toBe("Gamma") + expect(created.session.id).toMatch(/[\da-f-]+/iu) + const oracle = await verifyWorkspaceSessionStores({ + cwd, + expectedSessionCount: 1, + }) + expect(oracle.ok).toBe(true) + }) + + it("activates cancel without mutating workspace state or session files", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const beforeState = await readFile( + join(cwd, ".brunch", "state.json"), + "utf8", + ) + const beforeSession = await readFile(ready.session.file, "utf8") + + const result = await coordinator.activateWorkspace({ action: "cancel" }) + + expect(result.status).toBe("cancelled") + await expect( + readFile(join(cwd, ".brunch", "state.json"), "utf8"), + ).resolves.toBe(beforeState) + await expect(readFile(ready.session.file, "utf8")).resolves.toBe( + beforeSession, + ) + }) + + it("refuses to activate mismatched or unavailable sessions", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const unavailableFile = join( + cwd, + ".brunch", + "sessions", + "unavailable.jsonl", + ) + await writeFile( + unavailableFile, + `${JSON.stringify({ type: "session", id: "unavailable-session", cwd })}\n`, + "utf8", + ) + + const unavailable = await coordinator.activateWorkspace({ + action: "openSession", + specId: ready.spec.id, + sessionFile: unavailableFile, + }) + const mismatched = await coordinator.activateWorkspace({ + action: "openSession", + specId: "spec-missing", + sessionFile: ready.session.file, + }) + + expect(unavailable.status).toBe("needs_human") + expect(mismatched.status).toBe("needs_human") + }) + it("keeps inventory scanning out of activation and binding helpers", async () => { const source = await readFile( new URL("./workspace-session-coordinator.ts", import.meta.url), @@ -390,7 +542,7 @@ describe("WorkspaceSessionCoordinator", () => { ) const inspectMethod = source.slice( source.indexOf("async inspectWorkspace()"), - source.indexOf("async openExisting()"), + source.indexOf("async activateWorkspace("), ) expect(inspectMethod).not.toContain("bindSessionToSpec") diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index 6b89acf6..f0976e17 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -62,8 +62,44 @@ export interface WorkspaceSessionNeedsHumanState { chrome: WorkspaceSessionChromeState } +export interface WorkspaceSessionCancelledState { + status: "cancelled" + cwd: string + chrome: WorkspaceSessionChromeState +} + export type WorkspaceSessionState = WorkspaceSessionReadyState | WorkspaceSessionSelectSpecState | WorkspaceSessionNeedsHumanState +export interface WorkspaceContinueDecision { + action: "continue" + specId: string + sessionFile: string +} + +export interface WorkspaceOpenSessionDecision { + action: "openSession" + specId: string + sessionFile: string +} + +export interface WorkspaceNewSessionDecision { + action: "newSession" + specId: string +} + +export interface WorkspaceNewSpecDecision { + action: "newSpec" + title: string +} + +export interface WorkspaceCancelDecision { + action: "cancel" +} + +export type WorkspaceSwitchDecision = WorkspaceContinueDecision | WorkspaceOpenSessionDecision | WorkspaceNewSessionDecision | WorkspaceNewSpecDecision | WorkspaceCancelDecision + +export type WorkspaceActivationState = WorkspaceSessionReadyState | WorkspaceSessionNeedsHumanState | WorkspaceSessionCancelledState + export interface WorkspaceLaunchSession { id: string file: string @@ -97,6 +133,9 @@ export interface WorkspaceLaunchInventory { export interface WorkspaceSessionCoordinator { inspectWorkspace(): Promise + activateWorkspace( + decision: WorkspaceSwitchDecision, + ): Promise openExisting(): Promise startOrCreate(options?: { specTitle?: string @@ -127,6 +166,65 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return inspectWorkspaceInventory(this.#cwd) } + async activateWorkspace( + decision: WorkspaceSwitchDecision, + ): Promise { + if (decision.action === "cancel") { + const state = await readWorkspaceState(this.#cwd) + return { + status: "cancelled", + cwd: this.#cwd, + chrome: chromeState(this.#cwd, state?.currentSpec ?? null), + } + } + + if (decision.action === "newSpec") { + return this.startOrCreate({ + specTitle: decision.title, + createNewSpec: true, + }) + } + + const inventory = await inspectWorkspaceInventory(this.#cwd) + const spec = inventory.specs.find( + (candidate) => candidate.spec.id === decision.specId, + ) + + if (!spec) { + return needsHumanState( + this.#cwd, + inventory.currentSpec, + "Selected spec is not available in this workspace.", + ) + } + + if (decision.action === "newSession") { + const session = await createBoundSession(this.#cwd, spec.spec) + await writeCurrentWorkspaceState(this.#cwd, spec.spec, session.file) + return readyState(this.#cwd, spec.spec, session) + } + + const session = spec.sessions.find( + (candidate) => candidate.file === decision.sessionFile, + ) + if (!session) { + return needsHumanState( + this.#cwd, + inventory.currentSpec, + "Selected session is not available for the selected spec.", + ) + } + + const manager = SessionManager.open( + session.file, + sessionDir(this.#cwd), + this.#cwd, + ) + const opened = bindSessionToSpec(manager, spec.spec) + await writeCurrentWorkspaceState(this.#cwd, spec.spec, opened.file) + return readyState(this.#cwd, spec.spec, opened) + } + async openExisting(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { @@ -440,6 +538,19 @@ function readyState( } } +function needsHumanState( + cwd: string, + spec: WorkspaceSpecState | null, + reason: string, +): WorkspaceSessionNeedsHumanState { + return { + status: "needs_human", + cwd, + reason, + chrome: chromeState(cwd, spec), + } +} + function chromeState( cwd: string, spec: WorkspaceSpecState | null, From b818895cc5581c15f8fdadedf754588c4a27c5d6 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:18:09 +0200 Subject: [PATCH 009/164] FE-744: Add workspace switcher decision UI --- memory/CARDS.md | 4 +- package-lock.json | 13 +- package.json | 1 + src/workspace-switcher.test.ts | 170 +++++++++++++++++++++++++ src/workspace-switcher.ts | 222 +++++++++++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 src/workspace-switcher.test.ts create mode 100644 src/workspace-switcher.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index a0299555..4a4d440f 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -121,7 +121,7 @@ The coordinator can turn an explicit workspace decision into the resulting ready ## Card 3 — Workspace switcher decision UI -- **Status:** next +- **Status:** done - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 @@ -173,7 +173,7 @@ The workspace switcher UI can turn launch inventory into a typed workspace decis ## Card 4 — Pre-Pi startup gate -- **Status:** queued +- **Status:** next - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/package-lock.json b/package-lock.json index 777c4cf7..1b1ea4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.4", "@tanstack/react-query": "^5.100.11", "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", @@ -1059,19 +1060,19 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", - "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "integrity": "sha512-PDhKU7u6fmEcvHUFHzrRwGc/Ytokj/hO+X4RPf+MWKEGpvg3B1vHv88Ee+Dy33004tYkQF5YeXV4btJZcp5x1g==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12" + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" }, "engines": { "node": ">=22.19.0" }, "optionalDependencies": { - "koffi": "^2.9.0" + "koffi": "2.16.2" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 6794ca11..0d549849 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.4", "@tanstack/react-query": "^5.100.11", "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", diff --git a/src/workspace-switcher.test.ts b/src/workspace-switcher.test.ts new file mode 100644 index 00000000..b6c0624e --- /dev/null +++ b/src/workspace-switcher.test.ts @@ -0,0 +1,170 @@ +import { readFile } from "node:fs/promises" + +import { visibleWidth } from "@earendil-works/pi-tui" + +import { describe, expect, it } from "vitest" + +import { + buildWorkspaceSwitchOptions, + createWorkspaceSwitchComponent, +} from "./workspace-switcher.js" +import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" + +describe("workspace switcher", () => { + it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { + const options = buildWorkspaceSwitchOptions(inventory()) + + expect(options.map((option) => option.kind)).toEqual([ + "continue", + "newSession", + "openSession", + "newSession", + "openSession", + "newSpec", + "cancel", + ]) + expect(options[0]).toMatchObject({ + label: "Continue Alpha", + decision: { + action: "continue", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-current.jsonl", + }, + }) + expect(options.at(-2)).toMatchObject({ + label: "Create spec", + }) + expect(options.at(-2)).not.toHaveProperty("decision") + expect(options.at(-1)).toMatchObject({ + label: "Cancel", + decision: { action: "cancel" }, + }) + }) + + it("selects current resume and existing sessions as typed decisions", () => { + const decisions: unknown[] = [] + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput("\r") + component.handleInput("\x1B[B") + component.handleInput("\x1B[B") + component.handleInput("\r") + + expect(decisions).toEqual([ + { + action: "continue", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-current.jsonl", + }, + { + action: "openSession", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-older.jsonl", + }, + ]) + }) + + it("returns new-spec decisions from title entry and cancel on escape", () => { + const decisions: unknown[] = [] + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + for (let index = 0; index < 5; index += 1) { + component.handleInput("\x1B[B") + } + component.handleInput("\r") + for (const char of "Gamma") { + component.handleInput(char) + } + component.handleInput("\r") + const cancelComponent = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + cancelComponent.handleInput("\x1B") + + expect(decisions).toEqual([ + { action: "newSpec", title: "Gamma" }, + { action: "cancel" }, + ]) + }) + + it("keeps rendered lines within the requested width", () => { + const component = createWorkspaceSwitchComponent({ + inventory: inventory(), + onDecision: () => {}, + }) + + expect(component.render(24).every((line) => visibleWidth(line) <= 24)).toBe( + true, + ) + }) + + it("keeps the switcher out of coordinator and session mutation imports", async () => { + const source = await readFile( + new URL("./workspace-switcher.ts", import.meta.url), + "utf8", + ) + + expect(source).not.toContain("WorkspaceSessionCoordinator") + expect(source).not.toContain("SessionManager") + expect(source).not.toContain("bindSessionToSpec") + expect(source).not.toContain("appendCustomEntry") + }) + + it("declares pi-tui as a direct dependency", async () => { + const manifest = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { dependencies?: Record } + + expect(manifest.dependencies).toHaveProperty("@earendil-works/pi-tui") + }) +}) + +function inventory(): WorkspaceLaunchInventory { + return { + cwd: "/project", + currentSpec: { id: "spec-alpha", title: "Alpha" }, + currentSessionFile: "/sessions/alpha-current.jsonl", + needsNewSpec: false, + specs: [ + { + spec: { id: "spec-alpha", title: "Alpha" }, + sessions: [ + { + id: "session-alpha-current", + file: "/sessions/alpha-current.jsonl", + specId: "spec-alpha", + specTitle: "Alpha", + available: true, + }, + { + id: "session-alpha-older", + file: "/sessions/alpha-older.jsonl", + specId: "spec-alpha", + specTitle: "Alpha", + available: true, + }, + ], + }, + { + spec: { id: "spec-beta", title: "Beta" }, + sessions: [ + { + id: "session-beta", + file: "/sessions/beta.jsonl", + specId: "spec-beta", + specTitle: "Beta", + available: true, + }, + ], + }, + ], + unavailableSessions: [], + } +} diff --git a/src/workspace-switcher.ts b/src/workspace-switcher.ts new file mode 100644 index 00000000..c7a4d244 --- /dev/null +++ b/src/workspace-switcher.ts @@ -0,0 +1,222 @@ +import { + Key, + matchesKey, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceLaunchSession, + WorkspaceSwitchDecision, +} from "./workspace-session-coordinator.js" + +export interface WorkspaceSwitchOption { + id: string + label: string + description: string + kind: "continue" | "openSession" | "newSession" | "newSpec" | "cancel" + decision?: WorkspaceSwitchDecision +} + +export interface WorkspaceSwitchComponentOptions { + inventory: WorkspaceLaunchInventory + onDecision: (decision: WorkspaceSwitchDecision) => void +} + +export function buildWorkspaceSwitchOptions( + inventory: WorkspaceLaunchInventory, +): WorkspaceSwitchOption[] { + const options: WorkspaceSwitchOption[] = [] + const currentSession = findCurrentSession(inventory) + + if (currentSession && inventory.currentSpec) { + options.push({ + id: `continue:${currentSession.file}`, + label: `Continue ${inventory.currentSpec.title}`, + description: sessionDescription( + currentSession, + "Resume selected session", + ), + kind: "continue", + decision: { + action: "continue", + specId: inventory.currentSpec.id, + sessionFile: currentSession.file, + }, + }) + } + + for (const { spec, sessions } of inventory.specs) { + options.push({ + id: `new-session:${spec.id}`, + label: `Start new session in ${spec.title}`, + description: "Create a binding-only session before Pi starts", + kind: "newSession", + decision: { action: "newSession", specId: spec.id }, + }) + + for (const session of sessions) { + if (session.file === currentSession?.file) { + continue + } + options.push({ + id: `open:${session.file}`, + label: `Open ${spec.title}`, + description: sessionDescription(session, "Open existing session"), + kind: "openSession", + decision: { + action: "openSession", + specId: spec.id, + sessionFile: session.file, + }, + }) + } + } + + options.push({ + id: "new-spec", + label: "Create spec", + description: "Name a new specification workspace", + kind: "newSpec", + }) + options.push({ + id: "cancel", + label: "Cancel", + description: "Exit without opening a Brunch session", + kind: "cancel", + decision: { action: "cancel" }, + }) + + return options +} + +export function createWorkspaceSwitchComponent( + options: WorkspaceSwitchComponentOptions, +): Component { + return new WorkspaceSwitchComponent(options) +} + +class WorkspaceSwitchComponent implements Component { + #options: WorkspaceSwitchOption[] + #onDecision: (decision: WorkspaceSwitchDecision) => void + #selectedIndex = 0 + #mode: "select" | "newSpecTitle" = "select" + #title = "" + + constructor(options: WorkspaceSwitchComponentOptions) { + this.#options = buildWorkspaceSwitchOptions(options.inventory) + this.#onDecision = options.onDecision + } + + handleInput(data: string): void { + if (this.#mode === "newSpecTitle") { + this.#handleTitleInput(data) + return + } + + if (matchesKey(data, Key.up)) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#selectedIndex = Math.min( + this.#options.length - 1, + this.#selectedIndex + 1, + ) + return + } + if (matchesKey(data, Key.escape)) { + this.#onDecision({ action: "cancel" }) + return + } + if (matchesKey(data, Key.enter)) { + this.#selectCurrentOption() + } + } + + render(width: number): string[] { + const lines = ["Brunch workspace", "Choose how to start this session:", ""] + + if (this.#mode === "newSpecTitle") { + lines.push("New spec title:", `> ${this.#title}`) + lines.push("enter create • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + for (const [index, option] of this.#options.entries()) { + const prefix = index === this.#selectedIndex ? "› " : " " + lines.push(`${prefix}${option.label}`) + lines.push(` ${option.description}`) + } + lines.push("", "↑↓ navigate • enter select • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + invalidate(): void {} + + #selectCurrentOption(): void { + const option = this.#options[this.#selectedIndex] + if (!option) { + return + } + if (option.kind === "newSpec") { + this.#mode = "newSpecTitle" + this.#title = "" + return + } + if (option.decision) { + this.#onDecision(option.decision) + } + } + + #handleTitleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.#mode = "select" + this.#title = "" + return + } + if (matchesKey(data, Key.backspace)) { + this.#title = this.#title.slice(0, -1) + return + } + if (matchesKey(data, Key.enter)) { + const title = this.#title.trim() + if (title.length > 0) { + this.#onDecision({ action: "newSpec", title }) + } + return + } + if (isPrintableInput(data)) { + this.#title += data + } + } +} + +function findCurrentSession( + inventory: WorkspaceLaunchInventory, +): WorkspaceLaunchSession | undefined { + if (!inventory.currentSessionFile) { + return undefined + } + for (const spec of inventory.specs) { + const session = spec.sessions.find( + (candidate) => candidate.file === inventory.currentSessionFile, + ) + if (session) { + return session + } + } + return undefined +} + +function sessionDescription( + session: WorkspaceLaunchSession, + prefix: string, +): string { + return `${prefix} · ${session.id}` +} + +function isPrintableInput(data: string): boolean { + return data.length === 1 && data >= " " && data !== "\u007f" +} From 79b2e35bcbc47423fae5c8a688f5707d95413070 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:21:17 +0200 Subject: [PATCH 010/164] FE-744: Gate TUI startup on workspace switch --- memory/CARDS.md | 2 +- memory/PLAN.md | 15 ++-- memory/SPEC.md | 27 +++--- src/brunch-tui.test.ts | 196 ++++++++++++++++++++++++++++++++++++++++- src/brunch-tui.ts | 107 ++++++++++++++++++---- 5 files changed, 308 insertions(+), 39 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 4a4d440f..a06f6ef0 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -173,7 +173,7 @@ The workspace switcher UI can turn launch inventory into a typed workspace decis ## Card 4 — Pre-Pi startup gate -- **Status:** next +- **Status:** done - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/memory/PLAN.md b/memory/PLAN.md index 031f7fec..4fcd27bd 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -30,7 +30,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome (color/glyphs), modal/popover overlays, radio/checkbox/select prompts, clickable/navigable action buttons, picker/list-selection modals, and ambient establishment-offer rendering that stays orientation-first rather than becoming a default lens menu. Spike-shaped probe whose output is a feasibility matrix + minimum-viable wrappers that downstream frontiers (M5 lenses/review-sets, M6 authority gates, M7 turn-boundary delivery) can build on. Command-containment evidence has landed: strict exact built-in suppression requires a Pi command-policy API, while POC safety can rely on autocomplete hiding plus branch/session effect blocking if product review accepts residual exposure. Dynamic chrome evidence has also landed: a Brunch wrapper can own header/footer/status/widget projection, with RPC degradation limited to status/widget/title events. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. +- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX and Brunch-owned startup/session selection. Command-containment and dynamic chrome evidence have landed. The live continuation is the workspace-switcher/startup-flow proof: a reusable decision UI over coordinator-provided inventory, coordinator activation for continue/open/new-session/new-spec decisions, a pre-Pi TUI gate that prevents implicit stale transcript resume, product-shell hardening for Pi startup noise/chrome metadata, and later an in-session switcher command via Pi modal/session-replacement seams. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. ### Horizon @@ -221,14 +221,15 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment and dynamic chrome proofs landed; next scoping pass should decide whether to continue into structured prompts/review-set overlays or pause for product-shell review of residual built-in command exposure) -- **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs for elicitation-lens and review-set flows without forking Pi or building a parallel rendering substrate. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec switching and entity selection; ambient rendering of the latest `brunch.establishment_offer`. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. -- **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection). Outer — manual TUI walkthrough validating visual quality and interaction feel; comparative walkthrough between scripted-driver and manual paths to record controllability cost. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). +- **Status:** in-progress (command-containment and dynamic chrome proofs landed; current continuation is the workspace-switcher/startup-flow proof under FE-744) +- **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs without forking Pi or building a parallel rendering substrate, including both downstream elicitation/review affordances and the immediate Brunch-owned startup/session-selection flow. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec/session/entity selection; ambient rendering of the latest `brunch.establishment_offer`; and a reusable workspace switcher whose pure UI returns decisions while the `WorkspaceSessionCoordinator` owns inventory, activation, session binding, and `.brunch/state.json` effects. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. +- **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); workspace switcher implementation supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; generic Pi startup resource/update noise is suppressed or documented as residual product-shell risk; the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R20, R21 / D2-L, D11-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D34-L, D35-L / I18-L, I19-L / A10-L, A14-L, A17-L, A18-L +- **Traceability:** R4, R14, R16, R19, R20, R21 / D2-L, D11-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L / I18-L, I19-L, I22-L / A10-L, A14-L, A17-L, A18-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). +- **Current execution pointer:** Workspace inventory, coordinator activation, pure decision UI, and the pre-Pi startup gate have landed. Next FE-744 slices stay inside this frontier unless `ln-scope` promotes a durable split: product-shell metadata/noise hardening, then in-session switcher command. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index e3dbfed9..154bb56a 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -74,7 +74,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 16. Brunch must keep sessions elicitation-first: at idle, the user is responding to a system/assistant-originated elicitation prompt rather than initiating ambient free chat. 17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as optional typed transcript entries, and must be able to project elicitation exchanges from Pi JSONL for observer extraction. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. -19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec is selected before any agent loop runs, persists across `/new`, and binds each session to exactly one spec. +19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent-mode, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. 21. Brunch must distinguish *extractive* lenses (single-exchange, observer-extracted) from *generative* lenses (batch-proposal, captured at proposal time as structured entity-draft payloads, reviewer-analyzed post-acceptance). 22. Brunch must establish a minimum grounding bundle (domain, protagonist, pain/pull, and constraint anchors) before generative lenses produce non-speculative output; lenses remain always-available with epistemic-status signaling honestly reflecting grounding density. @@ -116,7 +116,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. #### Data model & vocabulary @@ -153,8 +153,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Interaction & UI shape - **D11-L — Workspace state hierarchy `cwd → spec → session`, with spec selection gated before any agent loop.** Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: —. -- **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers receive `ready | select_spec | needs_human` workspace-session state and never mutate a session's bound spec. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model. -- **D22-L — M0 TUI chrome rides pi's extension UI widget seam.** Brunch's initial persistent chrome is mounted by an internal Brunch extension using pi's public `ExtensionUIContext.setWidget(..., { placement: "aboveEditor" })`, while spec selection remains a Brunch-owned boot gate before `InteractiveMode.run()`. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for M0 chrome. Depends on: A10-L, D2-L, D21-L. Supersedes: private-header/monkeypatch approaches for M0 chrome. +- **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. +- **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. - **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (all system/assistant/tool-side entries since the previous user response, including any structured/internal prompt content) plus a response-side span (user text and/or structured action entries). Role/span alternation is the default projection in Brunch-supported linear sessions; typed markers are added only where structure/actions need deterministic replay. Depends on: D12-L, D24-L. Supersedes: —. - **D14-L — `#`-mentions are ID-anchored, with a session-scoped mention ledger.** Autocomplete may resolve by title but insertion always rewrites to ID-anchored. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: —. @@ -163,6 +163,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. +- **D36-L — Workspace switching is a reusable decision UI with coordinator activation adapters.** Brunch owns a pure workspace-switcher surface that renders workspace inventory and returns a product decision (`continue selected session`, `open session`, `new session for spec`, `new spec`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. The same decision UI should be usable by a pre-Pi TUI startup adapter and later by an in-Pi command/modal adapter; adapters differ only in terminal lifecycle and Pi session-replacement mechanics, not in product semantics. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, and one-off startup-only picker implementations. ### Critical Invariants @@ -189,6 +190,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | planned (FE-744 startup-switcher coordinator tests plus pty/ANSI-stripped TUI runbook oracle) | D11-L, D21-L, D22-L, D36-L | ## Future Direction Register @@ -242,8 +244,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Session binding** | The first Brunch custom entry in a session that binds the Pi session id to exactly one spec id and schema version. Makes JSONL self-describing; registry/index state is an acceleration, not the canonical binding. | | **Client attachment** | An ephemeral TUI instance, browser tab, stdio stream, or WebSocket connection attached to one or more Brunch product resources for viewing or driving. Client attachment state may guide subscriptions and UI routing, but it is not durable spec/session truth. | | **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/state.json`, and derives chrome state for callers. | -| **Workspace state hierarchy** | `cwd → spec → session`. Each level scopes the one below it; spec is selected before any agent loop runs and persists across `/new`. | -| **Workspace default state** | Lightweight `.brunch/state.json` acceleration for reopening the last selected spec/session in a cwd. It is a launch/default convenience, not the canonical binding of a session and not a multi-client concurrency authority. | +| **Workspace state hierarchy** | `cwd → spec → session`. Each level scopes the one below it; active spec/session activation is Brunch-owned before any agent loop runs, and spec selection persists across `/new`. | +| **Workspace default state** | Lightweight `.brunch/state.json` acceleration for reopening the last selected spec/session in a cwd. It is a launch/default convenience, not the canonical binding of a session, not an instruction to resume without product flow, and not a multi-client concurrency authority. | +| **Workspace switcher** | Brunch-owned decision UI over workspace inventory. It lets the user continue/open a session, create a new session for a selected spec, create a new spec, or cancel/quit. The switcher returns a decision; the `WorkspaceSessionCoordinator` activates it and owns all Pi session and binding effects. | | **Intent graph** | The canonical specification-meaning plane. Authority over what the system is for. | | **Oracle graph** | Verification-strategy plane accountable to intent. Houses Checks, Validation Methods, Evidence, Obligations. | | **Design graph** | Modules, interfaces, seams, and adapters accountable to intent. Stubbed in POC. | @@ -345,14 +348,14 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, fixture metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | -| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to in-flight reviewer-signal chrome behavior and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D25-L, D29-L; I8-L, I13-L; A10-L. | +| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-switcher startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L; I2-L, I10-L, I11-L, I16-L, I19-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-switcher UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec selector, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | +| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace switcher, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Runbook Oracle Design @@ -364,7 +367,7 @@ A **runbook oracle** is the preferred bridge for seams that require human intera Runbook postconditions should be boring and product-shaped: paths exist, JSON fields match, JSONL entries are present and unique, projections reconstruct the same state, command results carry expected discriminants. Store-only checks are acceptable before projection handlers exist; projection-including checks become the default once `workspace.*`, `session.*`, `graph.*`, or `coherence.*` handlers exist. -The first required runbook is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. +The first required runbook is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. FE-744 extends this with a startup-switcher runbook: launch Brunch against a workspace with an existing selected transcript, assert the pre-Pi switcher appears before transcript rendering, choose new-session vs resume paths explicitly, and pair the visual capture with store/projection checks for activated spec/session state. ### Invariant Oracle Coverage @@ -390,6 +393,8 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I18-L | M5+ inner-loop schema validation on elicitor-emitted custom entries (must declare `lens`); paired with middle-loop property test that generated entries route to the correct observer/reviewer consumer. | | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | +| I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | +| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | ### Design Notes @@ -402,7 +407,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | Blind spot | Reason | Mitigation | Revisit trigger | | --- | --- | --- | --- | -| Full TUI automation | Cost exceeds value before the product state seams are proven. | Manual checklist plus artifact/query runbook oracle. | Manual TUI steps become frequent/flaky or block CI confidence. | +| Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query runbook oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | | LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and M1 scripted exchanges intentionally encode only a thin current exchange model. | Brief library, human-reviewed golden captures, adversarial probes, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated fixture failures where structure passes but elicitation is judged poor, or M2/M3 reveals that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | | Subscription reconnect/resume | POC can prove snapshot + live update without hardening network recovery yet. | Contract tests for initial snapshot and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | | Performance and scale | Local POC graph/session sizes are small; premature budgets may distort design. | Keep exports/checkers text-native and simple; add budgets when slow tests appear. | `npm run verify` or fixture runs exceed acceptable local iteration time. | diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 0b1b061c..cee5aab9 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -11,6 +11,7 @@ import { } from "@earendil-works/pi-coding-agent" import { + chromeStateForWorkspace, createBrunchChromeExtension, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, @@ -18,7 +19,11 @@ import { renderBrunchChrome, runBrunchTui, } from "./brunch-tui.js" -import { verifyWorkspaceSessionStores } from "./workspace-session-coordinator.js" +import { + createWorkspaceSessionCoordinator, + verifyWorkspaceSessionStores, + type WorkspaceSessionReadyState, +} from "./workspace-session-coordinator.js" describe("Brunch TUI boot", () => { it("gates spec selection through the coordinator before launching interactive mode", async () => { @@ -47,6 +52,158 @@ describe("Brunch TUI boot", () => { } }) + it("runs inspect, preflight, and activation before launching interactive mode", async () => { + const events: string[] = [] + const workspace = readyWorkspace("/tmp/project", "session-ready") + + await runBrunchTui({ + cwd: "/tmp/project", + coordinator: { + inspectWorkspace: async () => { + events.push("inspect") + return { + cwd: "/tmp/project", + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + } + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return workspace + }, + openExisting: async () => workspace, + startOrCreate: async () => workspace, + createNewSessionForCurrentSpec: async () => workspace, + bindCurrentSpecToSession: async () => workspace, + deriveChromeState: async () => workspace.chrome, + }, + runWorkspaceSwitchPreflight: async () => { + events.push("preflight") + return { + action: "continue", + specId: workspace.spec.id, + sessionFile: workspace.session.file, + } + }, + launchInteractive: async ({ workspace: launched }) => { + events.push(`launch:${launched.session.id}`) + }, + }) + + expect(events).toEqual([ + "inspect", + "preflight", + "activate:continue", + "launch:session-ready", + ]) + }) + + it("does not launch interactive mode when startup preflight is cancelled", async () => { + const events: string[] = [] + const workspace = readyWorkspace("/tmp/project", "session-ready") + + await runBrunchTui({ + cwd: "/tmp/project", + coordinator: { + inspectWorkspace: async () => { + events.push("inspect") + return { + cwd: "/tmp/project", + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + } + }, + activateWorkspace: async () => { + events.push("activate") + return { + status: "cancelled", + cwd: "/tmp/project", + chrome: workspace.chrome, + } + }, + openExisting: async () => workspace, + startOrCreate: async () => workspace, + createNewSessionForCurrentSpec: async () => workspace, + bindCurrentSpecToSession: async () => workspace, + deriveChromeState: async () => workspace.chrome, + }, + runWorkspaceSwitchPreflight: async () => { + events.push("preflight") + return { action: "cancel" } + }, + launchInteractive: async () => { + events.push("launch") + }, + }) + + expect(events).toEqual(["inspect", "preflight", "activate"]) + }) + + it("chooses a new binding-only session instead of implicitly resuming stale transcript", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const first = await coordinator.startOrCreate({ specTitle: "Spec One" }) + first.session.manager.appendMessage({ + role: "user", + content: "stale transcript", + }) + const firstContent = await readFile(first.session.file, "utf8") + let launchedSessionFile: string | undefined + + await runBrunchTui({ + cwd, + coordinator, + runWorkspaceSwitchPreflight: async () => ({ + action: "newSession", + specId: first.spec.id, + }), + launchInteractive: async ({ workspace }) => { + launchedSessionFile = workspace.session.file + }, + }) + + expect(launchedSessionFile).toBeDefined() + expect(launchedSessionFile).not.toBe(first.session.file) + await expect(readFile(first.session.file, "utf8")).resolves.toBe( + firstContent, + ) + expect(await readFile(launchedSessionFile!, "utf8")).not.toContain( + "stale transcript", + ) + }) + + it("passes activated session state into chrome instead of fabricating unbound", async () => { + const widgets = new Map() + const ui: FakeExtensionUi = { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, + setWidget: (key: string, content: unknown) => { + if (isStringArray(content)) { + widgets.set(key, content) + } + }, + setWorkingIndicator: (_options) => {}, + setTitle: (_title: string) => {}, + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome( + ui, + chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-real")), + ) + + expect(widgets.get("brunch.chrome")?.join("\n")).toContain( + "session: session-real", + ) + }) + it("formats Brunch chrome from one product-state snapshot", async () => { const state = { cwd: "/tmp/project", @@ -293,8 +450,45 @@ describe("Brunch TUI boot", () => { expect(source).not.toContain("appendCustomEntry") expect(source).not.toContain("brunch.session_binding") }) + + it("suppresses generic Pi startup resources for the Brunch shell", async () => { + const source = await readFile( + new URL("./brunch-tui.ts", import.meta.url), + "utf8", + ) + + expect(source).toContain("settingsManager.getQuietStartup = () => true") + expect(source).toContain("noContextFiles: true") + expect(source).toContain("noExtensions: true") + expect(source).toContain("noPromptTemplates: true") + expect(source).toContain("noSkills: true") + expect(source).toContain('process.env.PI_OFFLINE ??= "1"') + }) }) +function readyWorkspace( + cwd: string, + sessionId: string, +): WorkspaceSessionReadyState { + const spec = { id: "spec-1", title: "Spec One" } + return { + status: "ready", + cwd, + spec, + session: { + id: sessionId, + file: `/sessions/${sessionId}.jsonl`, + manager: {} as WorkspaceSessionReadyState["session"]["manager"], + }, + chrome: { + cwd, + spec, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }, + } +} + interface FakeUiCall { method: string args: unknown[] diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index ec40dc31..4197d396 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -1,6 +1,7 @@ -import { createInterface } from "node:readline/promises" import process from "node:process" +import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" + import { createAgentSessionFromServices, createAgentSessionRuntime, @@ -8,6 +9,7 @@ import { getAgentDir, InteractiveMode, SessionManager, + SettingsManager, type CreateAgentSessionRuntimeFactory, type ExtensionFactory, type ExtensionUIContext, @@ -15,10 +17,13 @@ import { import { createWorkspaceSessionCoordinator, + type WorkspaceLaunchInventory, type WorkspaceSessionChromeState, type WorkspaceSessionCoordinator, type WorkspaceSessionReadyState, + type WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "./workspace-switcher.js" export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState @@ -29,6 +34,9 @@ export interface BrunchTuiOptions { cwd?: string coordinator?: WorkspaceSessionCoordinator selectSpecTitle?: () => Promise + runWorkspaceSwitchPreflight?: ( + inventory: WorkspaceLaunchInventory, + ) => Promise launchInteractive?: (context: BrunchTuiLaunchContext) => Promise } @@ -64,15 +72,13 @@ export async function runBrunchTui( const coordinator = options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }) - let workspaceState = await coordinator.openExisting() - if (workspaceState.status === "select_spec") { - const title = await (options.selectSpecTitle ?? promptForSpecTitle)() - if (!title) { - return - } - workspaceState = await coordinator.startOrCreate({ specTitle: title }) - } + const inventory = await coordinator.inspectWorkspace() + const decision = await chooseWorkspaceSwitchDecision(inventory, options) + const workspaceState = await coordinator.activateWorkspace(decision) + if (workspaceState.status === "cancelled") { + return + } if (workspaceState.status === "needs_human") { throw new Error(workspaceState.reason) } @@ -118,6 +124,27 @@ export function formatBrunchChromeFooterLines( ] } +export function chromeStateForWorkspace( + workspace: WorkspaceSessionReadyState, +): BrunchChromeState { + return { + ...workspace.chrome, + session: { + id: workspace.session.id, + label: workspace.session.id, + }, + stage: "idle", + activeLens: null, + coherenceVerdict: "unknown", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + streaming: false, + } +} + export function renderBrunchChrome( ui: Pick, state: BrunchChromeInputState, @@ -174,7 +201,7 @@ function formatSession(chrome: BrunchChromeState): string { } export function createBrunchChromeExtension( - chrome: WorkspaceSessionChromeState, + chrome: BrunchChromeInputState, onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, ): ExtensionFactory { return (pi) => { @@ -201,15 +228,40 @@ export function createBrunchChromeExtension( } } -async function promptForSpecTitle(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }) - try { - const answer = await rl.question("Create/select Brunch spec title: ") - const title = answer.trim() - return title.length > 0 ? title : undefined - } finally { - rl.close() +async function chooseWorkspaceSwitchDecision( + inventory: WorkspaceLaunchInventory, + options: BrunchTuiOptions, +): Promise { + if (options.runWorkspaceSwitchPreflight) { + return options.runWorkspaceSwitchPreflight(inventory) } + if (options.selectSpecTitle && inventory.needsNewSpec) { + const title = await options.selectSpecTitle() + return title ? { action: "newSpec", title } : { action: "cancel" } + } + return runWorkspaceSwitchPreflight(inventory) +} + +export async function runWorkspaceSwitchPreflight( + inventory: WorkspaceLaunchInventory, +): Promise { + const terminal = new ProcessTerminal() + const tui = new TUI(terminal) + + return await new Promise((resolve) => { + const finish = (decision: WorkspaceSwitchDecision) => { + tui.stop() + resolve(decision) + } + const component = createWorkspaceSwitchComponent({ + inventory, + onDecision: finish, + }) + tui.addChild(component) + tui.setFocus(component) + terminal.clearScreen() + tui.start() + }) } async function launchPiInteractive({ @@ -222,13 +274,20 @@ async function launchPiInteractive({ agentDir: runtimeAgentDir, sessionManager, }) => { + const settingsManager = createBrunchSettingsManager(cwd, runtimeAgentDir) const services = await createAgentSessionServices({ cwd, agentDir: runtimeAgentDir, + settingsManager, resourceLoaderOptions: { + noContextFiles: true, + noExtensions: true, + noPromptTemplates: true, + noSkills: true, + noThemes: true, extensionFactories: [ createBrunchChromeExtension( - workspace.chrome, + chromeStateForWorkspace(workspace), async (sessionManager) => { await coordinator.bindCurrentSpecToSession(sessionManager) }, @@ -253,5 +312,15 @@ async function launchPiInteractive({ sessionManager: workspace.session.manager, }) + process.env.PI_OFFLINE ??= "1" await new InteractiveMode(runtime).run() } + +function createBrunchSettingsManager( + cwd: string, + agentDir: string, +): SettingsManager { + const settingsManager = SettingsManager.create(cwd, agentDir) + settingsManager.getQuietStartup = () => true + return settingsManager +} From 3b766ccce9f5cd49a21255631dc936e400d6c041 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:28:53 +0200 Subject: [PATCH 011/164] FE-744: Rename implicit coordinator operations --- src/brunch-tui.test.ts | 24 ++++++----- src/brunch-tui.ts | 4 +- src/brunch.test.ts | 12 +++--- src/brunch.ts | 2 +- src/fixture-capture.test.ts | 16 ++++---- src/fixture-capture.ts | 6 ++- src/rpc.test.ts | 26 ++++++------ src/rpc.ts | 8 ++-- src/web-host.test.ts | 22 +++++----- src/workspace-session-coordinator.test.ts | 50 +++++++++++++---------- src/workspace-session-coordinator.ts | 22 +++++----- 11 files changed, 104 insertions(+), 88 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index cee5aab9..eab76705 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -74,11 +74,11 @@ describe("Brunch TUI boot", () => { events.push(`activate:${decision.action}`) return workspace }, - openExisting: async () => workspace, - startOrCreate: async () => workspace, - createNewSessionForCurrentSpec: async () => workspace, - bindCurrentSpecToSession: async () => workspace, - deriveChromeState: async () => workspace.chrome, + openDefaultWorkspace: async () => workspace, + createSetupSession: async () => workspace, + createSetupSessionForCurrentSpec: async () => workspace, + bindCurrentSpecToReplacementSession: async () => workspace, + deriveDefaultChromeState: async () => workspace.chrome, }, runWorkspaceSwitchPreflight: async () => { events.push("preflight") @@ -127,11 +127,11 @@ describe("Brunch TUI boot", () => { chrome: workspace.chrome, } }, - openExisting: async () => workspace, - startOrCreate: async () => workspace, - createNewSessionForCurrentSpec: async () => workspace, - bindCurrentSpecToSession: async () => workspace, - deriveChromeState: async () => workspace.chrome, + openDefaultWorkspace: async () => workspace, + createSetupSession: async () => workspace, + createSetupSessionForCurrentSpec: async () => workspace, + bindCurrentSpecToReplacementSession: async () => workspace, + deriveDefaultChromeState: async () => workspace.chrome, }, runWorkspaceSwitchPreflight: async () => { events.push("preflight") @@ -148,7 +148,9 @@ describe("Brunch TUI boot", () => { it("chooses a new binding-only session instead of implicitly resuming stale transcript", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Spec One" }) + const first = await coordinator.createSetupSession({ + specTitle: "Spec One", + }) first.session.manager.appendMessage({ role: "user", content: "stale transcript", diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 4197d396..504d7c0e 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -289,7 +289,9 @@ async function launchPiInteractive({ createBrunchChromeExtension( chromeStateForWorkspace(workspace), async (sessionManager) => { - await coordinator.bindCurrentSpecToSession(sessionManager) + await coordinator.bindCurrentSpecToReplacementSession( + sessionManager, + ) }, ), ], diff --git a/src/brunch.test.ts b/src/brunch.test.ts index 4cd19672..a31f79c1 100644 --- a/src/brunch.test.ts +++ b/src/brunch.test.ts @@ -16,7 +16,7 @@ import { function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { return { ...(sessionFile ? { @@ -46,16 +46,16 @@ function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { cwd: "/tmp/brunch-project", } }, - async startOrCreate() { + async createSetupSession() { throw new Error("print must not create a session") }, - async createNewSessionForCurrentSpec() { + async createSetupSessionForCurrentSpec() { throw new Error("not used") }, - async bindCurrentSpecToSession() { + async bindCurrentSpecToReplacementSession() { throw new Error("not used") }, - async deriveChromeState() { + async deriveDefaultChromeState() { throw new Error("not used") }, } @@ -167,7 +167,7 @@ describe("Brunch CLI dispatch", () => { it("exposes matching print and RPC workspace snapshots from a real coordinator store", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-parity-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Parity spec", }) let printOutput = "" diff --git a/src/brunch.ts b/src/brunch.ts index c858e834..7e69439a 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -38,7 +38,7 @@ export async function runBrunchCli( options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }) if (mode === "print") { - const state = await coordinator.openExisting() + const state = await coordinator.openDefaultWorkspace() const snapshot = workspaceSnapshotFromState(state) writeStdout(options.stdout, renderWorkspaceSnapshot(snapshot)) return 0 diff --git a/src/fixture-capture.test.ts b/src/fixture-capture.test.ts index b0e37b2f..db094382 100644 --- a/src/fixture-capture.test.ts +++ b/src/fixture-capture.test.ts @@ -16,7 +16,7 @@ describe("fixture capture", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-real-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -65,7 +65,7 @@ describe("fixture capture", () => { ) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -95,7 +95,7 @@ describe("fixture capture", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Fixture spec", }) workspace.session.manager.appendMessage({ @@ -105,19 +105,19 @@ describe("fixture capture", () => { workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) const coordinator: WorkspaceSessionCoordinator = { - async openExisting() { + async openDefaultWorkspace() { return workspace }, - async startOrCreate() { + async createSetupSession() { return workspace }, - async createNewSessionForCurrentSpec() { + async createSetupSessionForCurrentSpec() { return workspace }, - async bindCurrentSpecToSession() { + async bindCurrentSpecToReplacementSession() { return workspace }, - async deriveChromeState() { + async deriveDefaultChromeState() { return workspace.chrome }, } diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index 154e4499..1ce23088 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -117,7 +117,9 @@ export async function captureDeterministicBriefRuns( content: brief.scriptedUserNotes.join("\n"), timestamp: Date.parse(options.timestamp ?? new Date().toISOString()), }) - await coordinator.bindCurrentSpecToSession(workspace.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + workspace.session.manager, + ) results.push( await captureFixtureRun({ @@ -136,7 +138,7 @@ async function openScriptedBriefSession( coordinator: WorkspaceSessionCoordinator, brief: FixtureBrief, ) { - return coordinator.startOrCreate({ + return coordinator.createSetupSession({ specTitle: brief.title, createNewSpec: true, }) diff --git a/src/rpc.test.ts b/src/rpc.test.ts index a59cc4a7..ca998f33 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -20,19 +20,19 @@ function coordinator( ), ): WorkspaceSessionCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { return state }, - async startOrCreate() { + async createSetupSession() { throw new Error("not used") }, - async createNewSessionForCurrentSpec() { + async createSetupSessionForCurrentSpec() { throw new Error("not used") }, - async bindCurrentSpecToSession() { + async bindCurrentSpecToReplacementSession() { throw new Error("not used") }, - async deriveChromeState() { + async deriveDefaultChromeState() { throw new Error("not used") }, } @@ -201,7 +201,7 @@ describe("JSON-RPC handlers", () => { it("serves session elicitation exchanges by durable session id without opening the selected workspace session", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-session-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinatorInstance.startOrCreate({ + const first = await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec", }) first.session.manager.appendMessage({ @@ -212,14 +212,14 @@ describe("JSON-RPC handlers", () => { role: "user", content: "First answer", }) - const second = await coordinatorInstance.createNewSessionForCurrentSpec() + const second = await coordinatorInstance.createSetupSessionForCurrentSpec() if (second.status !== "ready") { throw new Error("expected a ready second session") } const handlers = createRpcHandlers({ coordinator: { ...coordinatorInstance, - async openExisting() { + async openDefaultWorkspace() { throw new Error("explicit reads must not open selected session") }, }, @@ -246,7 +246,7 @@ describe("JSON-RPC handlers", () => { it("serves transcript display rows by durable session id without opening the selected workspace session", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-display-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Display spec", }) workspace.session.manager.appendMessage({ @@ -260,7 +260,7 @@ describe("JSON-RPC handlers", () => { const handlers = createRpcHandlers({ coordinator: { ...coordinatorInstance, - async openExisting() { + async openDefaultWorkspace() { throw new Error("explicit reads must not open selected session") }, }, @@ -296,7 +296,7 @@ describe("JSON-RPC handlers", () => { it("validates explicit session projection against a requested spec id", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-spec-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec", }) const handlers = createRpcHandlers({ @@ -435,7 +435,7 @@ describe("JSON-RPC handlers", () => { it("returns a product-shaped error for unknown explicit sessions", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-missing-session-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - await coordinatorInstance.startOrCreate({ specTitle: "Explicit spec" }) + await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec" }) const handlers = createRpcHandlers({ coordinator: coordinatorInstance, cwd, @@ -461,7 +461,7 @@ describe("JSON-RPC handlers", () => { it("returns a product-shaped error for non-linear explicit sessions", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-branch-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) - const workspace = await coordinatorInstance.startOrCreate({ + const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Explicit branch spec", }) const manager = SessionManager.open(workspace.session.file) diff --git a/src/rpc.ts b/src/rpc.ts index 03c69059..d9819dc7 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -47,7 +47,7 @@ export function createRpcHandlers(options: { if (request.params !== undefined) { return createJsonRpcFailure(requestId, -32602, "Invalid params") } - const state = await options.coordinator.openExisting() + const state = await options.coordinator.openDefaultWorkspace() return createJsonRpcSuccess( requestId, workspaceSnapshotFromState(state), @@ -93,7 +93,9 @@ async function handleSessionProjection( const target = params.value ? await resolveExplicitSessionProjectionTarget(options.cwd, params.value) - : await selectedSessionFile(await options.coordinator.openExisting()) + : await selectedSessionFile( + await options.coordinator.openDefaultWorkspace(), + ) if (!target.ok) { return createJsonRpcFailure(requestId, target.code, target.message) } @@ -146,7 +148,7 @@ function parseSessionProjectionParams( } async function selectedSessionFile( - state: Awaited>, + state: Awaited>, ): Promise { if (state.status !== "ready") { return { ok: false, code: -32001, message: "No selected Brunch session" } diff --git a/src/web-host.test.ts b/src/web-host.test.ts index a9b8f32b..f6684c9d 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -170,7 +170,7 @@ describe("web host", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Web spec", }) workspace.session.manager.appendMessage({ @@ -216,7 +216,7 @@ describe("web host", () => { it("serves explicit session projection over WebSocket", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-explicit-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ + const first = await coordinator.createSetupSession({ specTitle: "Explicit web spec", }) first.session.manager.appendMessage({ @@ -232,7 +232,7 @@ describe("web host", () => { role: "user", content: "First answer", }) - await coordinator.createNewSessionForCurrentSpec() + await coordinator.createSetupSessionForCurrentSpec() const host = await startWebHost({ cwd, port: 0, @@ -280,7 +280,7 @@ describe("web host", () => { it("multiplexes two JSON-RPC requests over one WebSocket", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-multiplex-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Multiplex spec", }) const host = await startWebHost({ @@ -308,7 +308,7 @@ describe("web host", () => { it("returns a parse error for malformed WebSocket JSON without killing the host", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-malformed-")) - await createWorkspaceSessionCoordinator({ cwd }).startOrCreate({ + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: "Malformed spec", }) const host = await startWebHost({ @@ -378,7 +378,7 @@ describe("web host", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-branch-")) const workspace = await createWorkspaceSessionCoordinator({ cwd, - }).startOrCreate({ + }).createSetupSession({ specTitle: "Branch spec", }) const manager = SessionManager.open(workspace.session.file) @@ -493,19 +493,19 @@ function openWebSocket(url: string): Promise { function throwingCoordinator(): WorkspaceSessionCoordinator { return { - async openExisting() { + async openDefaultWorkspace() { throw new Error("boom") }, - async startOrCreate() { + async createSetupSession() { throw new Error("not used") }, - async createNewSessionForCurrentSpec() { + async createSetupSessionForCurrentSpec() { throw new Error("not used") }, - async bindCurrentSpecToSession() { + async bindCurrentSpecToReplacementSession() { throw new Error("not used") }, - async deriveChromeState() { + async deriveDefaultChromeState() { throw new Error("not used") }, } diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index 4cfff5cf..bb17c28d 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -23,7 +23,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) @@ -52,8 +52,10 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Scratch spec" }) - const second = await coordinator.createNewSessionForCurrentSpec() + const first = await coordinator.createSetupSession({ + specTitle: "Scratch spec", + }) + const second = await coordinator.createSetupSessionForCurrentSpec() expect(second.status).toBe("ready") if (second.status !== "ready") { @@ -108,7 +110,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) const reloaded = SessionManager.open(result.session.file, undefined, cwd) @@ -131,7 +133,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) const reloaded = SessionManager.open(result.session.file, undefined, cwd) @@ -154,7 +156,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendMessage({ @@ -182,14 +184,18 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendModelChange("test-provider", "test-model") result.session.manager.appendThinkingLevelChange("high") - await coordinator.bindCurrentSpecToSession(result.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + result.session.manager, + ) result.session.manager.appendMessage({ role: "user", content: "hello" }) - await coordinator.bindCurrentSpecToSession(result.session.manager) + await coordinator.bindCurrentSpecToReplacementSession( + result.session.manager, + ) result.session.manager.appendMessage({ role: "assistant", content: "hi" }) const content = await readFile(result.session.file, "utf8") @@ -212,7 +218,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.startOrCreate({ + const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) result.session.manager.appendMessage({ @@ -236,9 +242,11 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Scratch spec" }) + const first = await coordinator.createSetupSession({ + specTitle: "Scratch spec", + }) const replacementFile = first.session.manager.newSession() - await coordinator.bindCurrentSpecToSession(first.session.manager) + await coordinator.bindCurrentSpecToReplacementSession(first.session.manager) expect(replacementFile).toBeDefined() const oracle = await verifyWorkspaceSessionStores({ @@ -264,9 +272,9 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) first.session.manager.appendMessage({ role: "user", content: "first" }) - const second = await coordinator.startOrCreate({ + const second = await coordinator.createSetupSession({ specTitle: "Beta", createNewSpec: true, }) @@ -339,7 +347,7 @@ describe("WorkspaceSessionCoordinator", () => { it("marks unbound or incompatible sessions unavailable during inventory", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) const unboundFile = join(cwd, ".brunch", "sessions", "unbound.jsonl") const mismatchedFile = join(cwd, ".brunch", "sessions", "mismatched.jsonl") await writeFile( @@ -386,8 +394,8 @@ describe("WorkspaceSessionCoordinator", () => { it("activates explicit open and continue decisions as the current workspace", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) - const second = await coordinator.startOrCreate({ + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) + const second = await coordinator.createSetupSession({ specTitle: "Beta", createNewSpec: true, }) @@ -430,7 +438,7 @@ describe("WorkspaceSessionCoordinator", () => { it("activates a new session decision as a binding-only session for the selected spec", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const first = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) first.session.manager.appendMessage({ role: "user", content: "preserve me", @@ -486,7 +494,7 @@ describe("WorkspaceSessionCoordinator", () => { it("activates cancel without mutating workspace state or session files", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) const beforeState = await readFile( join(cwd, ".brunch", "state.json"), "utf8", @@ -507,7 +515,7 @@ describe("WorkspaceSessionCoordinator", () => { it("refuses to activate mismatched or unavailable sessions", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const ready = await coordinator.startOrCreate({ specTitle: "Alpha" }) + const ready = await coordinator.createSetupSession({ specTitle: "Alpha" }) const unavailableFile = join( cwd, ".brunch", @@ -556,7 +564,7 @@ describe("WorkspaceSessionCoordinator", () => { await mkdir(join(cwd, ".brunch"), { recursive: true }) const coordinator = createWorkspaceSessionCoordinator({ cwd }) - const result = await coordinator.openExisting() + const result = await coordinator.openDefaultWorkspace() expect(result.status).toBe("select_spec") expect(result.chrome.cwd).toBe(cwd) diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index f0976e17..9165ea59 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -136,16 +136,16 @@ export interface WorkspaceSessionCoordinator { activateWorkspace( decision: WorkspaceSwitchDecision, ): Promise - openExisting(): Promise - startOrCreate(options?: { + openDefaultWorkspace(): Promise + createSetupSession(options?: { specTitle?: string createNewSpec?: boolean }): Promise - createNewSessionForCurrentSpec(): Promise - bindCurrentSpecToSession( + createSetupSessionForCurrentSpec(): Promise + bindCurrentSpecToReplacementSession( manager: SessionManager, ): Promise - deriveChromeState(): Promise + deriveDefaultChromeState(): Promise } export function createWorkspaceSessionCoordinator(options?: { @@ -179,7 +179,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { } if (decision.action === "newSpec") { - return this.startOrCreate({ + return this.createSetupSession({ specTitle: decision.title, createNewSpec: true, }) @@ -225,7 +225,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, spec.spec, opened) } - async openExisting(): Promise { + async openDefaultWorkspace(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { return { @@ -244,7 +244,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async startOrCreate(options?: { + async createSetupSession(options?: { specTitle?: string createNewSpec?: boolean }): Promise { @@ -259,7 +259,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, spec, session) } - async createNewSessionForCurrentSpec(): Promise { + async createSetupSessionForCurrentSpec(): Promise { const state = await readWorkspaceState(this.#cwd) if (!state) { return { @@ -275,7 +275,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async bindCurrentSpecToSession( + async bindCurrentSpecToReplacementSession( manager: SessionManager, ): Promise { const state = await readWorkspaceState(this.#cwd) @@ -288,7 +288,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { return readyState(this.#cwd, state.currentSpec, session) } - async deriveChromeState(): Promise { + async deriveDefaultChromeState(): Promise { const state = await readWorkspaceState(this.#cwd) return chromeState(this.#cwd, state?.currentSpec ?? null) } From bdcee9259d653741e5d88dfb17938cbef08ff614 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:30:30 +0200 Subject: [PATCH 012/164] FE-744: Split coordinator caller interfaces --- src/brunch-tui.ts | 9 ++++++--- src/brunch.ts | 3 ++- src/fixture-capture.ts | 8 +++++--- src/rpc.ts | 11 +++++++---- src/web-host.ts | 4 ++-- src/workspace-session-coordinator.ts | 21 ++++++++++++++++++++- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 504d7c0e..fd112e7a 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -18,21 +18,24 @@ import { import { createWorkspaceSessionCoordinator, type WorkspaceLaunchInventory, + type WorkspaceSessionBoundaryCoordinator, type WorkspaceSessionChromeState, - type WorkspaceSessionCoordinator, type WorkspaceSessionReadyState, + type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" import { createWorkspaceSwitchComponent } from "./workspace-switcher.js" export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState - coordinator: WorkspaceSessionCoordinator + coordinator: WorkspaceSessionBoundaryCoordinator } +export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator + export interface BrunchTuiOptions { cwd?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: BrunchTuiCoordinator selectSpecTitle?: () => Promise runWorkspaceSwitchPreflight?: ( inventory: WorkspaceLaunchInventory, diff --git a/src/brunch.ts b/src/brunch.ts index 7e69439a..889c2722 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -11,12 +11,13 @@ import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { startWebHost } from "./web-host.js" import { createWorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, type WorkspaceSessionCoordinator, } from "./workspace-session-coordinator.js" export interface WebHostRunnerOptions { cwd: string - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator } export interface BrunchCliOptions { diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index 1ce23088..611d0fbc 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -10,7 +10,9 @@ import type { WorkspaceSnapshot } from "./print-snapshot.js" import type { JsonRpcResponse } from "./json-rpc-protocol.js" import { createWorkspaceSessionCoordinator, - type WorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, + type WorkspaceSessionBoundaryCoordinator, + type WorkspaceSetupCoordinator, } from "./workspace-session-coordinator.js" export interface FixtureCaptureOptions { @@ -18,7 +20,7 @@ export interface FixtureCaptureOptions { briefId: string runId: string timestamp?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: DefaultWorkspaceCoordinator } export interface FixtureCaptureResult { @@ -135,7 +137,7 @@ export async function captureDeterministicBriefRuns( } async function openScriptedBriefSession( - coordinator: WorkspaceSessionCoordinator, + coordinator: WorkspaceSetupCoordinator & WorkspaceSessionBoundaryCoordinator, brief: FixtureBrief, ) { return coordinator.createSetupSession({ diff --git a/src/rpc.ts b/src/rpc.ts index d9819dc7..dc74ed45 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -25,14 +25,17 @@ import { type ExplicitSessionProjectionParams, type SessionProjectionTarget, } from "./session-projection-reader.js" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { + DefaultWorkspaceCoordinator, + WorkspaceSessionState, +} from "./workspace-session-coordinator.js" export interface RpcHandlers { handle(request: unknown): Promise } export function createRpcHandlers(options: { - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator cwd: string }): RpcHandlers { return { @@ -81,7 +84,7 @@ async function handleSessionProjection( requestId: JsonRpcId, rawParams: unknown, options: { - coordinator: WorkspaceSessionCoordinator + coordinator: DefaultWorkspaceCoordinator cwd: string }, loadProjection: (envelope: BrunchSessionEnvelope) => T, @@ -148,7 +151,7 @@ function parseSessionProjectionParams( } async function selectedSessionFile( - state: Awaited>, + state: WorkspaceSessionState, ): Promise { if (state.status !== "ready") { return { ok: false, code: -32001, message: "No selected Brunch session" } diff --git a/src/web-host.ts b/src/web-host.ts index 8442d57f..1f4ea9fd 100644 --- a/src/web-host.ts +++ b/src/web-host.ts @@ -5,13 +5,13 @@ import { fileURLToPath } from "node:url" import { createRpcHandlers } from "./rpc.js" import { attachWebRpcTransport } from "./web-rpc-transport.js" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" export interface WebHostOptions { cwd: string port?: number hostname?: string - coordinator?: WorkspaceSessionCoordinator + coordinator?: DefaultWorkspaceCoordinator webAssetRoot?: string } diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index 9165ea59..ed848068 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -131,23 +131,42 @@ export interface WorkspaceLaunchInventory { unavailableSessions: WorkspaceUnavailableSession[] } -export interface WorkspaceSessionCoordinator { +export interface WorkspaceSwitchCoordinator { inspectWorkspace(): Promise activateWorkspace( decision: WorkspaceSwitchDecision, ): Promise +} + +export interface DefaultWorkspaceCoordinator { openDefaultWorkspace(): Promise +} + +export interface WorkspaceSetupCoordinator { createSetupSession(options?: { specTitle?: string createNewSpec?: boolean }): Promise createSetupSessionForCurrentSpec(): Promise +} + +export interface WorkspaceSessionBoundaryCoordinator { bindCurrentSpecToReplacementSession( manager: SessionManager, ): Promise +} + +export interface WorkspaceDefaultChromeCoordinator { deriveDefaultChromeState(): Promise } +export interface WorkspaceSessionCoordinator + extends WorkspaceSwitchCoordinator, + DefaultWorkspaceCoordinator, + WorkspaceSetupCoordinator, + WorkspaceSessionBoundaryCoordinator, + WorkspaceDefaultChromeCoordinator {} + export function createWorkspaceSessionCoordinator(options?: { cwd?: string }): WorkspaceSessionCoordinator { From 1e8bc0336128e8bd40a8d2c6ff3b5f2c731f0d59 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:32:00 +0200 Subject: [PATCH 013/164] FE-744: Remove source-string boundary tests --- src/brunch-tui.test.ts | 11 ----------- src/rpc.test.ts | 9 +-------- src/workspace-session-coordinator.test.ts | 16 ---------------- src/workspace-switcher.test.ts | 12 ------------ 4 files changed, 1 insertion(+), 47 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index eab76705..1baabbb9 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -442,17 +442,6 @@ describe("Brunch TUI boot", () => { ]) }) - it("keeps session creation and binding out of the TUI boot adapter", async () => { - const source = await readFile( - new URL("./brunch-tui.ts", import.meta.url), - "utf8", - ) - - expect(source).not.toContain("SessionManager.create") - expect(source).not.toContain("appendCustomEntry") - expect(source).not.toContain("brunch.session_binding") - }) - it("suppresses generic Pi startup resources for the Brunch shell", async () => { const source = await readFile( new URL("./brunch-tui.ts", import.meta.url), diff --git a/src/rpc.test.ts b/src/rpc.test.ts index ca998f33..42d07055 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { PassThrough } from "node:stream" @@ -286,13 +286,6 @@ describe("JSON-RPC handlers", () => { }) }) - it("does not parse durable session bindings inside the RPC handler module", async () => { - const source = await readFile(new URL("./rpc.ts", import.meta.url), "utf8") - - expect(source).not.toContain("brunch.session_binding") - expect(source).not.toContain("customType") - }) - it("validates explicit session projection against a requested spec id", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-spec-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index bb17c28d..63fbafcf 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -543,22 +543,6 @@ describe("WorkspaceSessionCoordinator", () => { expect(mismatched.status).toBe("needs_human") }) - it("keeps inventory scanning out of activation and binding helpers", async () => { - const source = await readFile( - new URL("./workspace-session-coordinator.ts", import.meta.url), - "utf8", - ) - const inspectMethod = source.slice( - source.indexOf("async inspectWorkspace()"), - source.indexOf("async activateWorkspace("), - ) - - expect(inspectMethod).not.toContain("bindSessionToSpec") - expect(inspectMethod).not.toContain("appendCustomEntry") - expect(inspectMethod).not.toContain("SessionManager.create") - expect(inspectMethod).not.toContain("writeCurrentWorkspaceState") - }) - it("asks for spec selection when no current spec exists and creation is not allowed", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) await mkdir(join(cwd, ".brunch"), { recursive: true }) diff --git a/src/workspace-switcher.test.ts b/src/workspace-switcher.test.ts index b6c0624e..309ed431 100644 --- a/src/workspace-switcher.test.ts +++ b/src/workspace-switcher.test.ts @@ -105,18 +105,6 @@ describe("workspace switcher", () => { ) }) - it("keeps the switcher out of coordinator and session mutation imports", async () => { - const source = await readFile( - new URL("./workspace-switcher.ts", import.meta.url), - "utf8", - ) - - expect(source).not.toContain("WorkspaceSessionCoordinator") - expect(source).not.toContain("SessionManager") - expect(source).not.toContain("bindSessionToSpec") - expect(source).not.toContain("appendCustomEntry") - }) - it("declares pi-tui as a direct dependency", async () => { const manifest = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), From c86d601750684710821fbbdf28fe61a05ec6a3bc Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:32:40 +0200 Subject: [PATCH 014/164] FE-744: Require activated chrome session state --- src/brunch-tui.test.ts | 16 ++++------------ src/brunch-tui.ts | 39 +++++---------------------------------- 2 files changed, 9 insertions(+), 46 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 1baabbb9..881d9056 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -332,12 +332,7 @@ describe("Brunch TUI boot", () => { ) => Promise) | undefined createBrunchChromeExtension( - { - cwd, - spec: { id: "spec-1", title: "Spec One" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - }, + chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), (sessionManager) => { boundSessionIds.push(sessionManager.getSessionId()) }, @@ -398,12 +393,9 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => unknown>() - createBrunchChromeExtension({ - cwd, - spec: { id: "spec-1", title: "Spec One" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - })({ + createBrunchChromeExtension( + chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), + )({ on: ( event: string, handler: (event: unknown, ctx: FakeExtensionContext) => unknown, diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index fd112e7a..e6fcc740 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -66,8 +66,6 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { streaming: boolean } -type BrunchChromeInputState = WorkspaceSessionChromeState | BrunchChromeState - export async function runBrunchTui( options: BrunchTuiOptions = {}, ): Promise { @@ -93,19 +91,15 @@ export async function runBrunchTui( } export function formatBrunchChromeHeaderLines( - state: BrunchChromeInputState, + chrome: BrunchChromeState, ): string[] { - const chrome = normalizeBrunchChromeState(state) return [ "brunch specification workspace", `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, ] } -export function formatChromeWidgetLines( - state: BrunchChromeInputState, -): string[] { - const chrome = normalizeBrunchChromeState(state) +export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { return [ `cwd: ${chrome.cwd}`, `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, @@ -115,9 +109,8 @@ export function formatChromeWidgetLines( } export function formatBrunchChromeFooterLines( - state: BrunchChromeInputState, + chrome: BrunchChromeState, ): string[] { - const chrome = normalizeBrunchChromeState(state) const offer = chrome.latestEstablishmentOfferSummary ? `offer: ${chrome.latestEstablishmentOfferSummary}` : "offer: none" @@ -150,9 +143,8 @@ export function chromeStateForWorkspace( export function renderBrunchChrome( ui: Pick, - state: BrunchChromeInputState, + chrome: BrunchChromeState, ): void { - const chrome = normalizeBrunchChromeState(state) ui.setHeader(() => ({ render: () => formatBrunchChromeHeaderLines(chrome), invalidate: () => {}, @@ -174,27 +166,6 @@ export function renderBrunchChrome( ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } -function normalizeBrunchChromeState( - state: BrunchChromeInputState, -): BrunchChromeState { - if ("session" in state) { - return state - } - return { - ...state, - session: { id: "unbound" }, - stage: state.phase === "elicitation" ? "idle" : "idle", - activeLens: null, - coherenceVerdict: "unknown", - observerStatus: "idle", - reviewerStatus: "idle", - reconcilerStatus: "idle", - reconciliationNeedCount: 0, - latestEstablishmentOfferSummary: null, - streaming: false, - } -} - function formatSpec(chrome: BrunchChromeState): string { return chrome.spec?.title ?? "no spec selected" } @@ -204,7 +175,7 @@ function formatSession(chrome: BrunchChromeState): string { } export function createBrunchChromeExtension( - chrome: BrunchChromeInputState, + chrome: BrunchChromeState, onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, ): ExtensionFactory { return (pi) => { From 55070511f38c66cbe1f47314cf623e1df2945350 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:34:10 +0200 Subject: [PATCH 015/164] FE-744: Narrow coordinator test doubles --- src/brunch-tui.test.ts | 8 -------- src/fixture-capture.test.ts | 16 ++-------------- src/rpc.test.ts | 16 ++-------------- src/web-host.test.ts | 16 ++-------------- 4 files changed, 6 insertions(+), 50 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 881d9056..0b5dff82 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -74,11 +74,7 @@ describe("Brunch TUI boot", () => { events.push(`activate:${decision.action}`) return workspace }, - openDefaultWorkspace: async () => workspace, - createSetupSession: async () => workspace, - createSetupSessionForCurrentSpec: async () => workspace, bindCurrentSpecToReplacementSession: async () => workspace, - deriveDefaultChromeState: async () => workspace.chrome, }, runWorkspaceSwitchPreflight: async () => { events.push("preflight") @@ -127,11 +123,7 @@ describe("Brunch TUI boot", () => { chrome: workspace.chrome, } }, - openDefaultWorkspace: async () => workspace, - createSetupSession: async () => workspace, - createSetupSessionForCurrentSpec: async () => workspace, bindCurrentSpecToReplacementSession: async () => workspace, - deriveDefaultChromeState: async () => workspace.chrome, }, runWorkspaceSwitchPreflight: async () => { events.push("preflight") diff --git a/src/fixture-capture.test.ts b/src/fixture-capture.test.ts index db094382..042ce63b 100644 --- a/src/fixture-capture.test.ts +++ b/src/fixture-capture.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" -import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" import { loadLinearElicitationExchangeProjection } from "./elicitation-exchange.js" import { @@ -104,22 +104,10 @@ describe("fixture capture", () => { }) workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) - const coordinator: WorkspaceSessionCoordinator = { + const coordinator: DefaultWorkspaceCoordinator = { async openDefaultWorkspace() { return workspace }, - async createSetupSession() { - return workspace - }, - async createSetupSessionForCurrentSpec() { - return workspace - }, - async bindCurrentSpecToReplacementSession() { - return workspace - }, - async deriveDefaultChromeState() { - return workspace.chrome - }, } const result = await captureFixtureRun({ diff --git a/src/rpc.test.ts b/src/rpc.test.ts index 42d07055..aed98176 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -10,7 +10,7 @@ import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { createSessionBindingData } from "./session-binding.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" import type { - WorkspaceSessionCoordinator, + DefaultWorkspaceCoordinator, WorkspaceSessionState, } from "./workspace-session-coordinator.js" @@ -18,23 +18,11 @@ function coordinator( state: WorkspaceSessionState = readyState( "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", ), -): WorkspaceSessionCoordinator { +): DefaultWorkspaceCoordinator { return { async openDefaultWorkspace() { return state }, - async createSetupSession() { - throw new Error("not used") - }, - async createSetupSessionForCurrentSpec() { - throw new Error("not used") - }, - async bindCurrentSpecToReplacementSession() { - throw new Error("not used") - }, - async deriveDefaultChromeState() { - throw new Error("not used") - }, } } diff --git a/src/web-host.test.ts b/src/web-host.test.ts index f6684c9d..cbf6e24b 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -9,7 +9,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { createWorkspaceSessionCoordinator, - type WorkspaceSessionCoordinator, + type DefaultWorkspaceCoordinator, } from "./workspace-session-coordinator.js" import { startWebHost } from "./web-host.js" @@ -491,22 +491,10 @@ function openWebSocket(url: string): Promise { }) } -function throwingCoordinator(): WorkspaceSessionCoordinator { +function throwingCoordinator(): DefaultWorkspaceCoordinator { return { async openDefaultWorkspace() { throw new Error("boom") }, - async createSetupSession() { - throw new Error("not used") - }, - async createSetupSessionForCurrentSpec() { - throw new Error("not used") - }, - async bindCurrentSpecToReplacementSession() { - throw new Error("not used") - }, - async deriveDefaultChromeState() { - throw new Error("not used") - }, } } From ef77639f84546836f669f4d9bc3dc36a54529e8f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:35:19 +0200 Subject: [PATCH 016/164] FE-744: Route fixture capture through RPC handlers --- runbooks/verify-m1.sh | 4 ++-- src/fixture-capture.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/runbooks/verify-m1.sh b/runbooks/verify-m1.sh index 71e2f279..23f6aa32 100755 --- a/runbooks/verify-m1.sh +++ b/runbooks/verify-m1.sh @@ -100,14 +100,14 @@ import { createWorkspaceSessionCoordinator } from "./src/workspace-session-coord const cwd = process.env.TMP_WORKSPACE const coordinator = createWorkspaceSessionCoordinator({ cwd }) -const workspace = await coordinator.startOrCreate({ specTitle: "M1 runbook smoke" }) +const workspace = await coordinator.createSetupSession({ specTitle: "M1 runbook smoke" }) workspace.session.manager.appendCustomMessageEntry( "brunch.elicitation_prompt", "Runbook prompt: confirm the M1 mode shell is product-shaped.", true, ) workspace.session.manager.appendMessage({ role: "user", content: "Runbook response" }) -await coordinator.bindCurrentSpecToSession(workspace.session.manager) +await coordinator.bindCurrentSpecToReplacementSession(workspace.session.manager) NODE run_check "Print-mode smoke output" \ diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index 611d0fbc..9e1c8db3 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -4,7 +4,7 @@ import { PassThrough } from "node:stream" import { fileURLToPath } from "node:url" import { loadBriefLibrary, type FixtureBrief } from "./brief-library.js" -import { runBrunchCli } from "./brunch.js" +import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import type { ElicitationExchangeProjection } from "./elicitation-exchange.js" import type { WorkspaceSnapshot } from "./print-snapshot.js" import type { JsonRpcResponse } from "./json-rpc-protocol.js" @@ -156,12 +156,15 @@ async function callRpc( stdout.on("data", (chunk) => chunks.push(String(chunk))) stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method })}\n`) - await runBrunchCli({ - argv: ["--mode=rpc"], - cwd: options.cwd, - ...(options.coordinator ? { coordinator: options.coordinator } : {}), - stdin, - stdout, + await runJsonRpcLineServer({ + input: stdin, + output: stdout, + handlers: createRpcHandlers({ + coordinator: + options.coordinator ?? + createWorkspaceSessionCoordinator({ cwd: options.cwd }), + cwd: options.cwd, + }), }) const response = JSON.parse(chunks.join("")) as JsonRpcResponse From 3bc76cf2564f2f05170badd6562668ad01a8b1c4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:42:37 +0200 Subject: [PATCH 017/164] FE-744: Extract Brunch Pi extension entrypoint --- src/brunch-tui.ts | 157 ++++-------------------------- src/pi-extensions/brunch/index.ts | 145 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 139 deletions(-) create mode 100644 src/pi-extensions/brunch/index.ts diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index e6fcc740..bf02613b 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -8,24 +8,38 @@ import { createAgentSessionServices, getAgentDir, InteractiveMode, - SessionManager, SettingsManager, type CreateAgentSessionRuntimeFactory, - type ExtensionFactory, - type ExtensionUIContext, } from "@earendil-works/pi-coding-agent" import { createWorkspaceSessionCoordinator, type WorkspaceLaunchInventory, type WorkspaceSessionBoundaryCoordinator, - type WorkspaceSessionChromeState, type WorkspaceSessionReadyState, type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" +import { + chromeStateForWorkspace, + createBrunchChromeExtension, +} from "./pi-extensions/brunch/index.js" import { createWorkspaceSwitchComponent } from "./workspace-switcher.js" +export { + BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, + chromeStateForWorkspace, + createBrunchChromeExtension, + formatBrunchChromeFooterLines, + formatBrunchChromeHeaderLines, + formatChromeWidgetLines, + renderBrunchChrome, + type BrunchChromeCoherenceVerdict, + type BrunchChromeStage, + type BrunchChromeState, + type BrunchChromeWorkerStatus, +} from "./pi-extensions/brunch/index.js" + export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState coordinator: WorkspaceSessionBoundaryCoordinator @@ -43,29 +57,6 @@ export interface BrunchTuiOptions { launchInteractive?: (context: BrunchTuiLaunchContext) => Promise } -export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = - "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." - -export type BrunchChromeStage = "idle" | "streaming" | "observer-review" -export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" -export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" - -export interface BrunchChromeState extends WorkspaceSessionChromeState { - session: { - id: string - label?: string - } - stage: BrunchChromeStage - activeLens: string | null - coherenceVerdict: BrunchChromeCoherenceVerdict - observerStatus: BrunchChromeWorkerStatus - reviewerStatus: BrunchChromeWorkerStatus - reconcilerStatus: BrunchChromeWorkerStatus - reconciliationNeedCount: number - latestEstablishmentOfferSummary: string | null - streaming: boolean -} - export async function runBrunchTui( options: BrunchTuiOptions = {}, ): Promise { @@ -90,118 +81,6 @@ export async function runBrunchTui( }) } -export function formatBrunchChromeHeaderLines( - chrome: BrunchChromeState, -): string[] { - return [ - "brunch specification workspace", - `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, - ] -} - -export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { - return [ - `cwd: ${chrome.cwd}`, - `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, - `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, - `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, - ] -} - -export function formatBrunchChromeFooterLines( - chrome: BrunchChromeState, -): string[] { - const offer = chrome.latestEstablishmentOfferSummary - ? `offer: ${chrome.latestEstablishmentOfferSummary}` - : "offer: none" - return [ - `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, - offer, - ] -} - -export function chromeStateForWorkspace( - workspace: WorkspaceSessionReadyState, -): BrunchChromeState { - return { - ...workspace.chrome, - session: { - id: workspace.session.id, - label: workspace.session.id, - }, - stage: "idle", - activeLens: null, - coherenceVerdict: "unknown", - observerStatus: "idle", - reviewerStatus: "idle", - reconcilerStatus: "idle", - reconciliationNeedCount: 0, - latestEstablishmentOfferSummary: null, - streaming: false, - } -} - -export function renderBrunchChrome( - ui: Pick, - chrome: BrunchChromeState, -): void { - ui.setHeader(() => ({ - render: () => formatBrunchChromeHeaderLines(chrome), - invalidate: () => {}, - })) - ui.setFooter(() => ({ - render: () => formatBrunchChromeFooterLines(chrome), - invalidate: () => {}, - })) - ui.setStatus( - "brunch.chrome", - `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, - ) - ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { - placement: "aboveEditor", - }) - ui.setWorkingIndicator( - chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, - ) - ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) -} - -function formatSpec(chrome: BrunchChromeState): string { - return chrome.spec?.title ?? "no spec selected" -} - -function formatSession(chrome: BrunchChromeState): string { - return chrome.session.label ?? chrome.session.id -} - -export function createBrunchChromeExtension( - chrome: BrunchChromeState, - onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, -): ExtensionFactory { - return (pi) => { - pi.on("session_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - renderBrunchChrome(ctx.ui, chrome) - }) - pi.on("before_agent_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - }) - pi.on("message_start", async (event, ctx) => { - if (event.message.role === "assistant") { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - } - }) - pi.on("session_before_tree", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) - pi.on("session_before_fork", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) - } -} - async function chooseWorkspaceSwitchDecision( inventory: WorkspaceLaunchInventory, options: BrunchTuiOptions, diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts new file mode 100644 index 00000000..3835d78b --- /dev/null +++ b/src/pi-extensions/brunch/index.ts @@ -0,0 +1,145 @@ +import { + SessionManager, + type ExtensionFactory, + type ExtensionUIContext, +} from "@earendil-works/pi-coding-agent" + +import type { + WorkspaceSessionChromeState, + WorkspaceSessionReadyState, +} from "../../workspace-session-coordinator.js" + +export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = + "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." + +export type BrunchChromeStage = "idle" | "streaming" | "observer-review" +export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" +export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" + +export interface BrunchChromeState extends WorkspaceSessionChromeState { + session: { + id: string + label?: string + } + stage: BrunchChromeStage + activeLens: string | null + coherenceVerdict: BrunchChromeCoherenceVerdict + observerStatus: BrunchChromeWorkerStatus + reviewerStatus: BrunchChromeWorkerStatus + reconcilerStatus: BrunchChromeWorkerStatus + reconciliationNeedCount: number + latestEstablishmentOfferSummary: string | null + streaming: boolean +} + +export function formatBrunchChromeHeaderLines( + chrome: BrunchChromeState, +): string[] { + return [ + "brunch specification workspace", + `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, + ] +} + +export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { + return [ + `cwd: ${chrome.cwd}`, + `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, + `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, + `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, + ] +} + +export function formatBrunchChromeFooterLines( + chrome: BrunchChromeState, +): string[] { + const offer = chrome.latestEstablishmentOfferSummary + ? `offer: ${chrome.latestEstablishmentOfferSummary}` + : "offer: none" + return [ + `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, + offer, + ] +} + +export function chromeStateForWorkspace( + workspace: WorkspaceSessionReadyState, +): BrunchChromeState { + return { + ...workspace.chrome, + session: { + id: workspace.session.id, + label: workspace.session.id, + }, + stage: "idle", + activeLens: null, + coherenceVerdict: "unknown", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + streaming: false, + } +} + +export function renderBrunchChrome( + ui: Pick, + chrome: BrunchChromeState, +): void { + ui.setHeader(() => ({ + render: () => formatBrunchChromeHeaderLines(chrome), + invalidate: () => {}, + })) + ui.setFooter(() => ({ + render: () => formatBrunchChromeFooterLines(chrome), + invalidate: () => {}, + })) + ui.setStatus( + "brunch.chrome", + `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, + ) + ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { + placement: "aboveEditor", + }) + ui.setWorkingIndicator( + chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, + ) + ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) +} + +export function createBrunchChromeExtension( + chrome: BrunchChromeState, + onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, +): ExtensionFactory { + return (pi) => { + pi.on("session_start", async (_event, ctx) => { + await onSessionBoundary?.(ctx.sessionManager as SessionManager) + renderBrunchChrome(ctx.ui, chrome) + }) + pi.on("before_agent_start", async (_event, ctx) => { + await onSessionBoundary?.(ctx.sessionManager as SessionManager) + }) + pi.on("message_start", async (event, ctx) => { + if (event.message.role === "assistant") { + await onSessionBoundary?.(ctx.sessionManager as SessionManager) + } + }) + pi.on("session_before_tree", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) + pi.on("session_before_fork", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) + } +} + +function formatSpec(chrome: BrunchChromeState): string { + return chrome.spec?.title ?? "no spec selected" +} + +function formatSession(chrome: BrunchChromeState): string { + return chrome.session.label ?? chrome.session.id +} From f8ba7b83eea330b5d743807fed2d2f9d716a420e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:43:47 +0200 Subject: [PATCH 018/164] FE-744: Split workspace switcher modules --- src/brunch-tui.ts | 28 +--- src/workspace-switcher.ts | 229 +--------------------------- src/workspace-switcher/component.ts | 126 +++++++++++++++ src/workspace-switcher/index.ts | 9 ++ src/workspace-switcher/model.ts | 104 +++++++++++++ src/workspace-switcher/preflight.ts | 29 ++++ 6 files changed, 277 insertions(+), 248 deletions(-) create mode 100644 src/workspace-switcher/component.ts create mode 100644 src/workspace-switcher/index.ts create mode 100644 src/workspace-switcher/model.ts create mode 100644 src/workspace-switcher/preflight.ts diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index bf02613b..6b8c4e7c 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -1,7 +1,5 @@ import process from "node:process" -import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" - import { createAgentSessionFromServices, createAgentSessionRuntime, @@ -24,8 +22,7 @@ import { chromeStateForWorkspace, createBrunchChromeExtension, } from "./pi-extensions/brunch/index.js" -import { createWorkspaceSwitchComponent } from "./workspace-switcher.js" - +import { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, @@ -39,6 +36,7 @@ export { type BrunchChromeState, type BrunchChromeWorkerStatus, } from "./pi-extensions/brunch/index.js" +export { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState @@ -95,28 +93,6 @@ async function chooseWorkspaceSwitchDecision( return runWorkspaceSwitchPreflight(inventory) } -export async function runWorkspaceSwitchPreflight( - inventory: WorkspaceLaunchInventory, -): Promise { - const terminal = new ProcessTerminal() - const tui = new TUI(terminal) - - return await new Promise((resolve) => { - const finish = (decision: WorkspaceSwitchDecision) => { - tui.stop() - resolve(decision) - } - const component = createWorkspaceSwitchComponent({ - inventory, - onDecision: finish, - }) - tui.addChild(component) - tui.setFocus(component) - terminal.clearScreen() - tui.start() - }) -} - async function launchPiInteractive({ workspace, coordinator, diff --git a/src/workspace-switcher.ts b/src/workspace-switcher.ts index c7a4d244..05c44801 100644 --- a/src/workspace-switcher.ts +++ b/src/workspace-switcher.ts @@ -1,222 +1,7 @@ -import { - Key, - matchesKey, - truncateToWidth, - type Component, -} from "@earendil-works/pi-tui" - -import type { - WorkspaceLaunchInventory, - WorkspaceLaunchSession, - WorkspaceSwitchDecision, -} from "./workspace-session-coordinator.js" - -export interface WorkspaceSwitchOption { - id: string - label: string - description: string - kind: "continue" | "openSession" | "newSession" | "newSpec" | "cancel" - decision?: WorkspaceSwitchDecision -} - -export interface WorkspaceSwitchComponentOptions { - inventory: WorkspaceLaunchInventory - onDecision: (decision: WorkspaceSwitchDecision) => void -} - -export function buildWorkspaceSwitchOptions( - inventory: WorkspaceLaunchInventory, -): WorkspaceSwitchOption[] { - const options: WorkspaceSwitchOption[] = [] - const currentSession = findCurrentSession(inventory) - - if (currentSession && inventory.currentSpec) { - options.push({ - id: `continue:${currentSession.file}`, - label: `Continue ${inventory.currentSpec.title}`, - description: sessionDescription( - currentSession, - "Resume selected session", - ), - kind: "continue", - decision: { - action: "continue", - specId: inventory.currentSpec.id, - sessionFile: currentSession.file, - }, - }) - } - - for (const { spec, sessions } of inventory.specs) { - options.push({ - id: `new-session:${spec.id}`, - label: `Start new session in ${spec.title}`, - description: "Create a binding-only session before Pi starts", - kind: "newSession", - decision: { action: "newSession", specId: spec.id }, - }) - - for (const session of sessions) { - if (session.file === currentSession?.file) { - continue - } - options.push({ - id: `open:${session.file}`, - label: `Open ${spec.title}`, - description: sessionDescription(session, "Open existing session"), - kind: "openSession", - decision: { - action: "openSession", - specId: spec.id, - sessionFile: session.file, - }, - }) - } - } - - options.push({ - id: "new-spec", - label: "Create spec", - description: "Name a new specification workspace", - kind: "newSpec", - }) - options.push({ - id: "cancel", - label: "Cancel", - description: "Exit without opening a Brunch session", - kind: "cancel", - decision: { action: "cancel" }, - }) - - return options -} - -export function createWorkspaceSwitchComponent( - options: WorkspaceSwitchComponentOptions, -): Component { - return new WorkspaceSwitchComponent(options) -} - -class WorkspaceSwitchComponent implements Component { - #options: WorkspaceSwitchOption[] - #onDecision: (decision: WorkspaceSwitchDecision) => void - #selectedIndex = 0 - #mode: "select" | "newSpecTitle" = "select" - #title = "" - - constructor(options: WorkspaceSwitchComponentOptions) { - this.#options = buildWorkspaceSwitchOptions(options.inventory) - this.#onDecision = options.onDecision - } - - handleInput(data: string): void { - if (this.#mode === "newSpecTitle") { - this.#handleTitleInput(data) - return - } - - if (matchesKey(data, Key.up)) { - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) - return - } - if (matchesKey(data, Key.down)) { - this.#selectedIndex = Math.min( - this.#options.length - 1, - this.#selectedIndex + 1, - ) - return - } - if (matchesKey(data, Key.escape)) { - this.#onDecision({ action: "cancel" }) - return - } - if (matchesKey(data, Key.enter)) { - this.#selectCurrentOption() - } - } - - render(width: number): string[] { - const lines = ["Brunch workspace", "Choose how to start this session:", ""] - - if (this.#mode === "newSpecTitle") { - lines.push("New spec title:", `> ${this.#title}`) - lines.push("enter create • esc cancel") - return lines.map((line) => truncateToWidth(line, width)) - } - - for (const [index, option] of this.#options.entries()) { - const prefix = index === this.#selectedIndex ? "› " : " " - lines.push(`${prefix}${option.label}`) - lines.push(` ${option.description}`) - } - lines.push("", "↑↓ navigate • enter select • esc cancel") - return lines.map((line) => truncateToWidth(line, width)) - } - - invalidate(): void {} - - #selectCurrentOption(): void { - const option = this.#options[this.#selectedIndex] - if (!option) { - return - } - if (option.kind === "newSpec") { - this.#mode = "newSpecTitle" - this.#title = "" - return - } - if (option.decision) { - this.#onDecision(option.decision) - } - } - - #handleTitleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.#mode = "select" - this.#title = "" - return - } - if (matchesKey(data, Key.backspace)) { - this.#title = this.#title.slice(0, -1) - return - } - if (matchesKey(data, Key.enter)) { - const title = this.#title.trim() - if (title.length > 0) { - this.#onDecision({ action: "newSpec", title }) - } - return - } - if (isPrintableInput(data)) { - this.#title += data - } - } -} - -function findCurrentSession( - inventory: WorkspaceLaunchInventory, -): WorkspaceLaunchSession | undefined { - if (!inventory.currentSessionFile) { - return undefined - } - for (const spec of inventory.specs) { - const session = spec.sessions.find( - (candidate) => candidate.file === inventory.currentSessionFile, - ) - if (session) { - return session - } - } - return undefined -} - -function sessionDescription( - session: WorkspaceLaunchSession, - prefix: string, -): string { - return `${prefix} · ${session.id}` -} - -function isPrintableInput(data: string): boolean { - return data.length === 1 && data >= " " && data !== "\u007f" -} +export { + buildWorkspaceSwitchOptions, + createWorkspaceSwitchComponent, + runWorkspaceSwitchPreflight, + type WorkspaceSwitchComponentOptions, + type WorkspaceSwitchOption, +} from "./workspace-switcher/index.js" diff --git a/src/workspace-switcher/component.ts b/src/workspace-switcher/component.ts new file mode 100644 index 00000000..5fdb7d48 --- /dev/null +++ b/src/workspace-switcher/component.ts @@ -0,0 +1,126 @@ +import { + Key, + matchesKey, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" +import { + buildWorkspaceSwitchOptions, + type WorkspaceSwitchOption, +} from "./model.js" + +export interface WorkspaceSwitchComponentOptions { + inventory: WorkspaceLaunchInventory + onDecision: (decision: WorkspaceSwitchDecision) => void +} + +export function createWorkspaceSwitchComponent( + options: WorkspaceSwitchComponentOptions, +): Component { + return new WorkspaceSwitchComponent(options) +} + +class WorkspaceSwitchComponent implements Component { + #options: WorkspaceSwitchOption[] + #onDecision: (decision: WorkspaceSwitchDecision) => void + #selectedIndex = 0 + #mode: "select" | "newSpecTitle" = "select" + #title = "" + + constructor(options: WorkspaceSwitchComponentOptions) { + this.#options = buildWorkspaceSwitchOptions(options.inventory) + this.#onDecision = options.onDecision + } + + handleInput(data: string): void { + if (this.#mode === "newSpecTitle") { + this.#handleTitleInput(data) + return + } + + if (matchesKey(data, Key.up)) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#selectedIndex = Math.min( + this.#options.length - 1, + this.#selectedIndex + 1, + ) + return + } + if (matchesKey(data, Key.escape)) { + this.#onDecision({ action: "cancel" }) + return + } + if (matchesKey(data, Key.enter)) { + this.#selectCurrentOption() + } + } + + render(width: number): string[] { + const lines = ["Brunch workspace", "Choose how to start this session:", ""] + + if (this.#mode === "newSpecTitle") { + lines.push("New spec title:", `> ${this.#title}`) + lines.push("enter create • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + for (const [index, option] of this.#options.entries()) { + const prefix = index === this.#selectedIndex ? "› " : " " + lines.push(`${prefix}${option.label}`) + lines.push(` ${option.description}`) + } + lines.push("", "↑↓ navigate • enter select • esc cancel") + return lines.map((line) => truncateToWidth(line, width)) + } + + invalidate(): void {} + + #selectCurrentOption(): void { + const option = this.#options[this.#selectedIndex] + if (!option) { + return + } + if (option.kind === "newSpec") { + this.#mode = "newSpecTitle" + this.#title = "" + return + } + if (option.decision) { + this.#onDecision(option.decision) + } + } + + #handleTitleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.#mode = "select" + this.#title = "" + return + } + if (matchesKey(data, Key.backspace)) { + this.#title = this.#title.slice(0, -1) + return + } + if (matchesKey(data, Key.enter)) { + const title = this.#title.trim() + if (title.length > 0) { + this.#onDecision({ action: "newSpec", title }) + } + return + } + if (isPrintableInput(data)) { + this.#title += data + } + } +} + +function isPrintableInput(data: string): boolean { + return data.length === 1 && data >= " " && data !== "\u007f" +} diff --git a/src/workspace-switcher/index.ts b/src/workspace-switcher/index.ts new file mode 100644 index 00000000..241e8e6e --- /dev/null +++ b/src/workspace-switcher/index.ts @@ -0,0 +1,9 @@ +export { + createWorkspaceSwitchComponent, + type WorkspaceSwitchComponentOptions, +} from "./component.js" +export { + buildWorkspaceSwitchOptions, + type WorkspaceSwitchOption, +} from "./model.js" +export { runWorkspaceSwitchPreflight } from "./preflight.js" diff --git a/src/workspace-switcher/model.ts b/src/workspace-switcher/model.ts new file mode 100644 index 00000000..d7dc7a31 --- /dev/null +++ b/src/workspace-switcher/model.ts @@ -0,0 +1,104 @@ +import type { + WorkspaceLaunchInventory, + WorkspaceLaunchSession, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" + +export interface WorkspaceSwitchOption { + id: string + label: string + description: string + kind: "continue" | "openSession" | "newSession" | "newSpec" | "cancel" + decision?: WorkspaceSwitchDecision +} + +export function buildWorkspaceSwitchOptions( + inventory: WorkspaceLaunchInventory, +): WorkspaceSwitchOption[] { + const options: WorkspaceSwitchOption[] = [] + const currentSession = findCurrentSession(inventory) + + if (currentSession && inventory.currentSpec) { + options.push({ + id: `continue:${currentSession.file}`, + label: `Continue ${inventory.currentSpec.title}`, + description: sessionDescription( + currentSession, + "Resume selected session", + ), + kind: "continue", + decision: { + action: "continue", + specId: inventory.currentSpec.id, + sessionFile: currentSession.file, + }, + }) + } + + for (const { spec, sessions } of inventory.specs) { + options.push({ + id: `new-session:${spec.id}`, + label: `Start new session in ${spec.title}`, + description: "Create a binding-only session before Pi starts", + kind: "newSession", + decision: { action: "newSession", specId: spec.id }, + }) + + for (const session of sessions) { + if (session.file === currentSession?.file) { + continue + } + options.push({ + id: `open:${session.file}`, + label: `Open ${spec.title}`, + description: sessionDescription(session, "Open existing session"), + kind: "openSession", + decision: { + action: "openSession", + specId: spec.id, + sessionFile: session.file, + }, + }) + } + } + + options.push({ + id: "new-spec", + label: "Create spec", + description: "Name a new specification workspace", + kind: "newSpec", + }) + options.push({ + id: "cancel", + label: "Cancel", + description: "Exit without opening a Brunch session", + kind: "cancel", + decision: { action: "cancel" }, + }) + + return options +} + +function findCurrentSession( + inventory: WorkspaceLaunchInventory, +): WorkspaceLaunchSession | undefined { + if (!inventory.currentSessionFile) { + return undefined + } + for (const spec of inventory.specs) { + const session = spec.sessions.find( + (candidate) => candidate.file === inventory.currentSessionFile, + ) + if (session) { + return session + } + } + return undefined +} + +function sessionDescription( + session: WorkspaceLaunchSession, + prefix: string, +): string { + return `${prefix} · ${session.id}` +} diff --git a/src/workspace-switcher/preflight.ts b/src/workspace-switcher/preflight.ts new file mode 100644 index 00000000..919cf84c --- /dev/null +++ b/src/workspace-switcher/preflight.ts @@ -0,0 +1,29 @@ +import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceSwitchDecision, +} from "../workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "./component.js" + +export async function runWorkspaceSwitchPreflight( + inventory: WorkspaceLaunchInventory, +): Promise { + const terminal = new ProcessTerminal() + const tui = new TUI(terminal) + + return await new Promise((resolve) => { + const finish = (decision: WorkspaceSwitchDecision) => { + tui.stop() + resolve(decision) + } + const component = createWorkspaceSwitchComponent({ + inventory, + onDecision: finish, + }) + tui.addChild(component) + tui.setFocus(component) + terminal.clearScreen() + tui.start() + }) +} From c268fffa173065206818eeb8ee1a434a0df03657 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:44:49 +0200 Subject: [PATCH 019/164] FE-744: Replace shell source test with helpers --- src/brunch-tui.test.ts | 32 +++++++++++++++++--------- src/brunch-tui.ts | 51 ++++++++++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 0b5dff82..e5ae7f90 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -11,8 +11,11 @@ import { } from "@earendil-works/pi-coding-agent" import { + applyBrunchOfflineDefault, + brunchResourceLoaderOptions, chromeStateForWorkspace, createBrunchChromeExtension, + createBrunchSettingsManager, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatChromeWidgetLines, @@ -427,17 +430,24 @@ describe("Brunch TUI boot", () => { }) it("suppresses generic Pi startup resources for the Brunch shell", async () => { - const source = await readFile( - new URL("./brunch-tui.ts", import.meta.url), - "utf8", - ) - - expect(source).toContain("settingsManager.getQuietStartup = () => true") - expect(source).toContain("noContextFiles: true") - expect(source).toContain("noExtensions: true") - expect(source).toContain("noPromptTemplates: true") - expect(source).toContain("noSkills: true") - expect(source).toContain('process.env.PI_OFFLINE ??= "1"') + const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) + const settingsManager = createBrunchSettingsManager(cwd, cwd) + const extension = () => {} + const resourceOptions = brunchResourceLoaderOptions([extension]) + const env: { PI_OFFLINE?: string } = {} + + applyBrunchOfflineDefault(env) + + expect(settingsManager.getQuietStartup()).toBe(true) + expect(resourceOptions).toEqual({ + noContextFiles: true, + noExtensions: true, + noPromptTemplates: true, + noSkills: true, + noThemes: true, + extensionFactories: [extension], + }) + expect(env.PI_OFFLINE).toBe("1") }) }) diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 6b8c4e7c..4da9e4e0 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -8,6 +8,7 @@ import { InteractiveMode, SettingsManager, type CreateAgentSessionRuntimeFactory, + type ExtensionFactory, } from "@earendil-works/pi-coding-agent" import { @@ -108,23 +109,16 @@ async function launchPiInteractive({ cwd, agentDir: runtimeAgentDir, settingsManager, - resourceLoaderOptions: { - noContextFiles: true, - noExtensions: true, - noPromptTemplates: true, - noSkills: true, - noThemes: true, - extensionFactories: [ - createBrunchChromeExtension( - chromeStateForWorkspace(workspace), - async (sessionManager) => { - await coordinator.bindCurrentSpecToReplacementSession( - sessionManager, - ) - }, - ), - ], - }, + resourceLoaderOptions: brunchResourceLoaderOptions([ + createBrunchChromeExtension( + chromeStateForWorkspace(workspace), + async (sessionManager) => { + await coordinator.bindCurrentSpecToReplacementSession( + sessionManager, + ) + }, + ), + ]), }) const created = await createAgentSessionFromServices({ services, @@ -143,11 +137,30 @@ async function launchPiInteractive({ sessionManager: workspace.session.manager, }) - process.env.PI_OFFLINE ??= "1" + applyBrunchOfflineDefault() await new InteractiveMode(runtime).run() } -function createBrunchSettingsManager( +export function brunchResourceLoaderOptions( + extensionFactories: ExtensionFactory[], +) { + return { + noContextFiles: true, + noExtensions: true, + noPromptTemplates: true, + noSkills: true, + noThemes: true, + extensionFactories, + } +} + +export function applyBrunchOfflineDefault( + env: Pick = process.env, +): void { + env.PI_OFFLINE ??= "1" +} + +export function createBrunchSettingsManager( cwd: string, agentDir: string, ): SettingsManager { From 4c7378e2bb77d70fed6a59dcd6e805a5b10e2382 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:45:40 +0200 Subject: [PATCH 020/164] FE-744: Fix offline default env typing --- src/brunch-tui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 4da9e4e0..efa24f80 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -155,7 +155,7 @@ export function brunchResourceLoaderOptions( } export function applyBrunchOfflineDefault( - env: Pick = process.env, + env: { PI_OFFLINE?: string } = process.env, ): void { env.PI_OFFLINE ??= "1" } From 98fbc163843ab8dc2b6634eb0332799ff08accab Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:45:52 +0200 Subject: [PATCH 021/164] FE-744: Retire exhausted refactor queue --- memory/CARDS.md | 231 ------------------------------------------------ 1 file changed, 231 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index a06f6ef0..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,231 +0,0 @@ -# FE-744 Scope Cards — Workspace switcher / startup flow - -## Orientation - -- Containing seam: Brunch TUI/workspace-session boot seam over Pi `SessionManager` and `InteractiveMode`; the coordinator owns spec/session effects, while UI/adapters return product decisions. -- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these are slices within the existing frontier, not new Linear issues or branches. -- Volatile state from `HANDOFF.md`: `memory/SPEC.md` now persists D21-L/D22-L/D35-L/D36-L/I22-L; dirty `src/brunch-tui.ts` and `src/brunch-tui.test.ts` suppress generic Pi startup noise but do not solve implicit stale transcript resume. -- Main open risk: Pi session inspection may tempt activation/binding as a side effect; keep inventory/read-model code separate from activation, and prove no prior transcript reaches Pi before explicit resume/open. - -Frontier-level obligations every card must preserve: - -- Preserve workspace hierarchy and startup invariant: `.brunch/state.json` is default acceleration, not an implicit resume instruction; no prior transcript or agent loop before explicit workspace-switch activation (R19 / D11-L / D21-L / D22-L / D36-L / I22-L). -- Preserve the linear transcript policy: no Pi branch creation/navigation as Brunch product behavior, and no transcript flattening to hide branch shape (D24-L / I19-L). -- Keep UI and adapters out of session mutation: only `WorkspaceSessionCoordinator` may create/open Brunch Pi sessions, write `.brunch/state.json`, or write `brunch.session_binding` (D21-L / D36-L). -- Keep chrome product-shaped: when a real session is activated, downstream chrome receives the activated session id rather than fabricating `unbound` (D35-L). - ---- - -## Card 1 — Workspace launch inventory - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -The coordinator can report launch inventory for existing Brunch specs/sessions without activating a session. - -### Boundary Crossings - -```text -→ caller asks WorkspaceSessionCoordinator.inspectWorkspace() -→ .brunch/state.json default-state reader -→ .brunch/sessions/*.jsonl binding/header/message scanner -→ WorkspaceLaunchInventory read model -``` - -### Risks and Assumptions - -- RISK: Inventory scanning accidentally calls existing bind/open helpers and rewrites JSONL/state. → MITIGATION: implement a read-only scanner path and assert file counts/content mtimes or source boundaries in tests. -- RISK: Current spec state is not enough to enumerate historical specs. → MITIGATION: reconstruct spec candidates from `brunch.session_binding` entries and treat state-only current spec as a candidate with zero/unknown sessions. -- RISK: Session labels become a premature UX taxonomy. → MITIGATION: expose minimal stable fields first (`sessionId`, `file`, `spec`, optional `name`/first-message preview/timestamps) and keep rich label formatting in the switcher model. -- ASSUMPTION: Existing linear JSONL headers plus `brunch.session_binding` entries are sufficient for launch inventory. → VALIDATE: inventory tests with current/default session, multiple sessions, missing state, and incompatible bindings. → memory/SPEC.md A1-L, D6-L, D21-L, D36-L - -### Acceptance Criteria - -✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` returns cwd, current spec/session defaults, bound specs, and bound sessions for a seeded `.brunch/state.json` plus multiple JSONL sessions. - -✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` on an empty workspace returns an inventory requiring new-spec creation without creating `.brunch/sessions/*.jsonl`. - -✓ `workspace-session-coordinator.test.ts` — `inspectWorkspace()` marks unbound or incompatible JSONL sessions unavailable instead of binding, rewriting, or silently selecting them. - -✓ Boundary/source test — inventory code does not call `bindSessionToSpec`, `appendCustomEntry`, `SessionManager.create`, or `writeCurrentWorkspaceState`. - -### Verification Approach - -- Inner: unit + boundary tests — prove the read model shape and read-only behavior. -- Middle: store oracle — compare before/after `.brunch/state.json` and session JSONL content for no activation writes. - -### Cross-cutting obligations - -- Inventory is not activation; it must not mutate `.brunch/state.json`, create sessions, or write `brunch.session_binding`. -- Inventory must preserve Brunch-supported linear-session assumptions and surface invalid sessions honestly. -- Inventory types should be Brunch-owned; Pi types should be imported/projected only where Pi owns the envelope (`SessionHeader`, `CustomEntry`, `SessionInfo`) per `docs/praxis/pi-types.md`. - ---- - -## Card 2 — Workspace decision activation - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -The coordinator can turn an explicit workspace decision into the resulting ready or cancelled workspace state. - -### Boundary Crossings - -```text -→ WorkspaceSwitchDecision from UI/adapter -→ WorkspaceSessionCoordinator.activateWorkspace(decision) -→ session binding/state validation -→ SessionManager.open/create through coordinator-owned helpers -→ .brunch/state.json + binding-only JSONL persistence -→ WorkspaceSessionReadyState or cancellation result -``` - -### Risks and Assumptions - -- RISK: `continue` reintroduces implicit resume semantics. → MITIGATION: only call activation after a caller supplies an explicit `continue` or `openSession` decision; keep `openExisting()` from being the TUI startup path after Card 4. -- RISK: Cancel/quit return shape leaks into durable architecture. → MITIGATION: keep cancellation a small adapter-facing product result with no persistent state mutation; update SPEC only if semantics exceed D36-L. -- RISK: Opening a selected session with stale/mismatched binding corrupts current state. → MITIGATION: validate selected file binding against the decision spec before writing `.brunch/state.json`. -- ASSUMPTION: Existing binding flush helper remains sufficient for newly-created binding-only sessions. → VALIDATE: reload newly-created sessions with `SessionManager.open` and `verifyWorkspaceSessionStores()`. → memory/SPEC.md D21-L, I8-L - -### Acceptance Criteria - -✓ `workspace-session-coordinator.test.ts` — activating `{ action: "openSession" }` or `{ action: "continue" }` opens the selected bound session, writes it as the current workspace default, and returns `WorkspaceSessionReadyState` with the real session id. - -✓ `workspace-session-coordinator.test.ts` — activating `{ action: "newSession" }` creates a binding-only session for the selected spec, writes it as current, and preserves all existing sessions. - -✓ `workspace-session-coordinator.test.ts` — activating `{ action: "newSpec" }` creates a new spec plus binding-only session and makes that pair current. - -✓ `workspace-session-coordinator.test.ts` — activating `{ action: "cancel" }` returns a non-ready cancellation result and leaves `.brunch/state.json` plus session files unchanged. - -✓ `workspace-session-coordinator.test.ts` — activating a mismatched or unavailable session fails with a structured `needs_human`/error result rather than rebinding it. - -### Verification Approach - -- Inner: coordinator contract tests — prove each decision discriminant and returned state shape. -- Middle: store oracle — prove state JSON and session binding postconditions after each activation path. -- Middle: reload round-trip — prove binding-only sessions reopen without duplicate headers/bindings. - -### Cross-cutting obligations - -- Activation is the only place this queue may create/open Brunch Pi sessions or write bindings/state. -- New-session activation must land in a binding-only session for the selected spec; no assistant/user transcript entries are required. -- Returned ready state must carry enough product state for chrome to render the real session id in later cards. - ---- - -## Card 3 — Workspace switcher decision UI - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -The workspace switcher UI can turn launch inventory into a typed workspace decision with no workspace side effects. - -### Boundary Crossings - -```text -→ WorkspaceLaunchInventory -→ workspace-switcher option/label model -→ pi-tui selection/input component or testable component factory -→ WorkspaceSwitchDecision -``` - -### Risks and Assumptions - -- RISK: UI imports the coordinator and becomes a hidden mutation path. → MITIGATION: keep `workspace-switcher/*` dependent only on inventory/decision types and `@earendil-works/pi-tui`; add a source/boundary test. -- RISK: First-screen choices overfit current fixture data. → MITIGATION: start with stable actions only: continue current session when available, start new session in a spec, choose/open another session, create spec, cancel/quit. -- RISK: Direct `@earendil-works/pi-tui` usage remains transitive. → MITIGATION: add `@earendil-works/pi-tui` as a direct dependency when importing it. -- ASSUMPTION: Pi `SelectList`/`Input` components are sufficient for the first switcher surface. → VALIDATE: component tests or a minimal render/input harness for up/down/enter/escape/name entry. → memory/SPEC.md D22-L, D36-L, A10-L - -### Acceptance Criteria - -✓ `workspace-switcher.test.ts` — option construction from inventory prioritizes explicit resume/new-session/create-spec/cancel choices without inventing a default exhaustive lens/menu surface. - -✓ `workspace-switcher.test.ts` — selecting an existing session returns `{ action: "openSession", specId, sessionFile }` and selecting current resume returns an explicit continue/open decision. - -✓ `workspace-switcher.test.ts` — selecting create-spec plus title entry returns `{ action: "newSpec", title }`; escape/cancel returns `{ action: "cancel" }`. - -✓ Boundary/source test — `workspace-switcher/*` does not import `SessionManager`, `WorkspaceSessionCoordinator`, or session-binding write helpers. - -✓ Dependency check — if the component imports `@earendil-works/pi-tui`, `package.json` declares it directly. - -### Verification Approach - -- Inner: pure model tests — prove inventory-to-option and option-to-decision mappings. -- Inner: component input tests — prove enter/escape/navigation/name entry where feasible without a full terminal. -- Middle: boundary/source test — prove UI cannot mutate workspace/session state directly. - -### Cross-cutting obligations - -- Switcher UI returns decisions only; coordinator activation owns all effects. -- Continue/resume must be an explicit selectable decision, not an automatic consequence of `.brunch/state.json`. -- Keep line widths bounded in custom components; use `truncateToWidth`/`SelectList` patterns from Pi TUI docs. - ---- - -## Card 4 — Pre-Pi startup gate - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -TUI mode starts Pi `InteractiveMode` only after a workspace switch decision has been activated. - -### Boundary Crossings - -```text -→ runBrunchTui() -→ coordinator.inspectWorkspace() -→ runWorkspaceSwitchPreflight(inventory) -→ coordinator.activateWorkspace(decision) -→ launchPiInteractive({ workspace, coordinator }) -→ Pi InteractiveMode.run() -``` - -### Risks and Assumptions - -- RISK: Existing `openExisting()` call path remains reachable from TUI startup and still renders stale transcript. → MITIGATION: replace TUI boot with inspect → decision → activate; keep `openExisting()` only for print/RPC/headless paths that intentionally project defaults. -- RISK: Pre-Pi TUI lifecycle leaves terminal state dirty before Pi starts. → MITIGATION: isolate terminal lifecycle in `runWorkspaceSwitchPreflight()` and add manual/pty runbook coverage after unit tests land. -- RISK: Dirty Pi startup-noise suppression gets confused with the startup fix. → MITIGATION: keep suppression as product-shell hardening in this adapter, but acceptance must prove no transcript launch before decision independently. -- ASSUMPTION: Injected preflight runner is enough to prove boot ordering before a full pty oracle is added. → VALIDATE: unit test with stale transcript seed and launch spy, then follow with pty/ANSI runbook before tying off FE-744. → memory/SPEC.md I22-L - -### Acceptance Criteria - -✓ `brunch-tui.test.ts` — `runBrunchTui()` calls inspect/preflight/activate before `launchInteractive`, and `launchInteractive` receives the activated ready workspace. - -✓ `brunch-tui.test.ts` — with an existing current session containing transcript entries, TUI startup does not call `launchInteractive` when the preflight returns cancel. - -✓ `brunch-tui.test.ts` — with an existing current session containing transcript entries, choosing `newSession` launches a different binding-only session for the same spec. - -✓ `brunch-tui.test.ts` — chrome setup receives activated chrome/session state sufficient to render the real session id, not `unbound`. - -✓ Existing startup suppression test still passes or is replaced by an equivalent product-shell assertion for quiet Pi resources and `PI_OFFLINE`. - -### Verification Approach - -- Inner: TUI boot unit tests with injected coordinator/preflight/launcher spies — prove ordering and no implicit resume. -- Middle: store oracle after new-session decision — prove binding-only session and preserved prior transcript. -- Middle: pty/ANSI-stripped runbook follow-up — prove prior transcript text is absent before explicit resume/open in an actual TUI launch. - -### Cross-cutting obligations - -- Do not start `InteractiveMode` before decision activation. -- Do not delete or mutate prior transcript when the user chooses a new session. -- Keep generic Pi resource/update suppression separate from the workspace-switch invariant; suppression reduces shell noise but does not prove I22-L. - ---- - -## Not queued yet - -- Product-shell metadata hardening: fold/review the dirty startup-noise suppression, reduce duplicated header/widget/footer/status facts, and decide permanent `PI_OFFLINE` semantics after Card 4 proves the startup gate. -- In-session workspace switcher command: reuse the same decision UI through Pi `ctx.ui.custom()` plus `waitForIdle`/session replacement; scope after the pre-Pi path proves the reusable decision model. From 14e0f13b1c27a7e2cdec0264be4ce6802d885f1a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:52:20 +0200 Subject: [PATCH 022/164] FE-744: Split Brunch extension surfaces --- memory/CARDS.md | 271 +++++++++++++++++++ src/brunch-tui.test.ts | 8 +- src/pi-extensions/brunch/branch-policy.ts | 15 + src/pi-extensions/brunch/chrome.ts | 112 ++++++++ src/pi-extensions/brunch/index.ts | 163 +++-------- src/pi-extensions/brunch/session-boundary.ts | 35 +++ 6 files changed, 471 insertions(+), 133 deletions(-) create mode 100644 memory/CARDS.md create mode 100644 src/pi-extensions/brunch/branch-policy.ts create mode 100644 src/pi-extensions/brunch/chrome.ts create mode 100644 src/pi-extensions/brunch/session-boundary.ts diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..f7c21cc4 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,271 @@ +# FE-744 Scope Cards — Brunch Pi extension shell follow-through + +## Orientation + +- Containing seam: Brunch TUI/workspace-session boot plus the internal Pi extension shell under `src/pi-extensions/brunch/`; the TUI host orchestrates pre-Pi activation, while the extension owns Pi event/command/UI registration. +- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these are slices inside the existing frontier, not new Linear issues or branches. +- Current state: workspace inventory/activation, pure switcher UI, pre-Pi startup gate, coordinator interface cleanup, active-session chrome, and initial extension/workspace-switcher module extraction are committed; `HANDOFF.md` is the only untracked file and is stale once these cards land. +- Main risk: product-shell hardening must not become cosmetic rearrangement; each slice should clarify which Pi UI surface owns which Brunch fact and keep all session mutation behind coordinator activation. + +Pi extension patterns to preserve from the reviewed examples: + +- Extension entrypoints are thin and event-shaped: `index.ts` registers `pi.on(...)`, commands, tools, or UI hooks; private helpers own formatting/state details. +- Use the lightest Pi UI surface: `setStatus` for compact persistent facts, `setWidget` for multi-line contextual facts, `setHeader` for product identity, `setFooter` only when intentionally replacing Pi's footer, `setTitle` for terminal title/working signal. +- `ctx.ui.custom()` components should return typed product data; they should not perform workspace/session effects. +- Any timer or session-bound UI state must clean up on `session_shutdown`. + +Frontier-level obligations every card must preserve: + +- Preserve workspace hierarchy and startup invariant: `.brunch/state.json` is default acceleration, not an implicit resume instruction; no prior transcript or agent loop before explicit workspace-switch activation (R19 / D11-L / D21-L / D22-L / D36-L / I22-L). +- Preserve linear transcript policy: no Pi branch creation/navigation as Brunch product behavior; branch effects remain blocked and transcript readers fail fast on non-linear JSONL (D24-L / I19-L). +- Keep UI/adapters out of session mutation: only `WorkspaceSessionCoordinator` activates decisions, creates/opens Brunch Pi sessions, writes `.brunch/state.json`, or writes `brunch.session_binding` (D21-L / D36-L). +- Keep Brunch chrome product-shaped and activated-session-shaped: no fabricated `unbound` session ids (D35-L). + +--- + +## Card 1 — Split the Brunch Pi extension by Pi surface + +- **Status:** done +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +The Brunch Pi extension entrypoint registers extension behavior through surface-specific private modules. + +### Boundary Crossings + +```text +→ launchPiInteractive() supplies createBrunchExtension(...) as an ExtensionFactory +→ src/pi-extensions/brunch/index.ts wires Pi events +→ chrome/session-binding/branch-policy private modules own their surface logic +→ Pi ExtensionAPI receives the same registered handlers as before +``` + +### Risks and Assumptions + +- RISK: This becomes file shuffling without deleting complexity. → MITIGATION: keep `index.ts` as a thin registration map and move behavior to modules named by Pi surface/responsibility, not generic `utils`. +- RISK: Tests keep importing through `brunch-tui.ts`, hiding extension boundaries. → MITIGATION: test extension formatting/registration through `src/pi-extensions/brunch` exports where possible; leave `brunch-tui` tests for launch orchestration. +- RISK: Splitting modules accidentally changes handler order. → MITIGATION: preserve current registration order: session binding/chrome on `session_start`, binding refresh on pre-agent/assistant start, branch policy cancellation hooks. +- ASSUMPTION: One internal Brunch extension remains the right public factory; separate exported Pi extensions are not needed yet. → VALIDATE: `brunchResourceLoaderOptions()` still receives one Brunch factory and existing behavior tests pass. → memory/SPEC.md D22-L, D35-L + +### Acceptance Criteria + +✓ `pi-extensions/brunch` structure — `index.ts` is a thin entrypoint that composes private surface modules; chrome formatting/rendering, branch policy, and session-boundary binding are no longer all implemented in `index.ts`. + +✓ Extension behavior tests — existing chrome rendering, branch-flow cancellation, and session-boundary binding tests still pass through the exported Brunch extension factory. + +✓ TUI host tests — `brunch-tui.ts` still proves inspect → decision → activate → launch ordering, resource suppression, and explicit extension factory wiring without owning extension handler internals. + +✓ `npm run verify` — full gate passes after the extraction. + +### Verification Approach + +- Inner: refactor-preservation tests — existing extension behavior tests continue to prove the same UI calls and cancellation return values. +- Inner: module-boundary compile check — the TUI host imports only the public Brunch extension factory/state helper, not private surface modules. + +### Cross-cutting obligations + +- Do not use Pi auto-discovery; Brunch still passes explicit `extensionFactories` while `noExtensions: true` remains set. +- Do not add product behavior in this card; it is structural extraction only. +- Preserve replacement-session binding before rendering chrome on `session_start`. + +--- + +## Card 2 — Product-shell chrome surface allocation + +- **Status:** queued +- **Weight:** light scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Objective + +Brunch chrome renders each persistent shell fact on one deliberate Pi UI surface instead of repeating metadata across header, widget, status, and footer. + +### Acceptance Criteria + +✓ Chrome formatting tests — header contains product identity plus active spec/session; status contains compact phase/coherence/need summary; widget contains only expanded diagnostic facts; footer is either restored to Pi default or has a narrowly justified Brunch-only purpose. + +✓ Title tests — terminal title remains Brunch-owned and compact, derived from activated workspace state. + +✓ Existing RPC degradation expectations remain true — tests assert only status/widget/title/notify as RPC-visible surfaces; header/footer/working indicator stay TUI-only assumptions. + +✓ Product-shell noise suppression still holds — quiet startup settings, disabled Pi resource categories, and `PI_OFFLINE` default remain covered. + +### Verification Approach + +- Inner: formatting/unit tests for each chrome surface. +- Inner: extension UI call tests proving the intended `setHeader` / `setStatus` / `setWidget` / `setTitle` calls and absence or deliberate use of `setFooter`. +- Middle: existing RPC/chrome expectations — no new fixture should rely on TUI-only header/footer events. + +### Cross-cutting obligations + +- Preserve active-session chrome: no `unbound` fallback. +- Keep Brunch product wrappers as the only downstream API; do not scatter raw `ctx.ui.*` calls outside the Brunch extension surface modules. +- Follow Pi example posture: use `setFooter` only when replacing the whole footer is intentionally the feature; otherwise prefer status/widget/title. + +### Promotion checklist + +- [ ] Does this change a requirement? No. +- [ ] Does this create, retire, or invalidate an assumption? No. +- [ ] Does this make or reverse a non-trivial design decision? No; it applies D35-L. +- [ ] Does this establish a new seam-level invariant? No. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. +- [ ] Does it cross more than two major seams? No. +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. + +--- + +## Card 3 — In-session workspace switcher command + +- **Status:** queued +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +A Brunch-owned slash command opens the reusable workspace switcher inside an active Pi session and switches to the activated workspace decision. + +### Boundary Crossings + +```text +→ Brunch extension registers a product command +→ command handler waits for idle +→ coordinator.inspectWorkspace() +→ ctx.ui.custom(...) renders workspace-switcher component as a typed decision UI +→ coordinator.activateWorkspace(decision) +→ ctx.switchSession(activated.session.file, { withSession }) replaces the Pi session +→ fresh replacement-session context renders Brunch chrome/notification +``` + +### Risks and Assumptions + +- RISK: Old command context/session objects are used after `ctx.switchSession()`. → MITIGATION: follow Pi docs; after replacement, use only the `withSession` context and plain data captured before switching. +- RISK: Command handler bypasses coordinator activation for new-session/new-spec decisions. → MITIGATION: all decisions go through `activateWorkspace()` first; Pi `switchSession()` only attaches the already-activated file to the current runtime. +- RISK: Command name collides with Pi built-ins or implies strict built-in suppression. → MITIGATION: use a Brunch-owned non-conflicting command name and keep command-containment docs honest. +- RISK: Switching to the currently active session causes unnecessary shutdown/rebind. → MITIGATION: either no-op with a notification when activated file equals current file, or prove `switchSession` handles it safely. +- ASSUMPTION: A coordinator-created binding-only session can be attached via `ctx.switchSession()` without needing Pi `ctx.newSession()`. → VALIDATE: unit/fake command tests and, if feasible, a small integration harness using a real coordinator-created session file. → memory/SPEC.md D21-L, D36-L, I8-L + +### Acceptance Criteria + +✓ Brunch extension command registration test — the exported extension registers a non-conflicting Brunch workspace command with a clear description. + +✓ Command handler test — command calls `waitForIdle()`, obtains inventory, renders the switcher through `ctx.ui.custom()`, activates the returned decision through the coordinator, and switches to the activated session file. + +✓ Replacement context test — post-switch notification/chrome update uses only the `withSession` context, not stale pre-switch `ctx` session-bound objects. + +✓ Cancel/needs-human tests — cancel leaves the current session untouched; `needs_human` reports a warning/error and does not switch. + +✓ Store oracle — new-session/new-spec command decisions produce coordinator-owned binding/state effects before Pi runtime switches. + +### Verification Approach + +- Inner: command registration/handler tests with fake ExtensionCommandContext — prove ordering, cancellation, and no stale-context use. +- Middle: coordinator store oracle — prove activated target session binding and current workspace state. +- Outer: manual TUI walkthrough later — invoke the command, switch sessions, confirm chrome/session id changes. + +### Cross-cutting obligations + +- Workspace switcher UI remains pure decision UI; no session mutation in the component. +- Coordinator remains the only owner of activation effects. +- After Pi session replacement, use only `withSession` context for session-bound UI/notifications. +- Do not claim or attempt built-in `/resume` or `/new` override; this is a product command alongside residual Pi built-ins. + +--- + +## Card 4 — Startup pty oracle for no implicit transcript resume + +- **Status:** queued +- **Weight:** full scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Target Behavior + +An executable startup oracle proves Brunch TUI startup does not render a prior transcript before an explicit workspace-switch decision. + +### Boundary Crossings + +```text +→ seeded scratch cwd with current session containing unique transcript text +→ Brunch TUI launch under a pty/script harness +→ ANSI-stripped startup capture before resume/open activation +→ oracle assertion on captured text and store state +``` + +### Risks and Assumptions + +- RISK: TUI/pty testing is flaky in CI-like environments. → MITIGATION: make the oracle a runbook/checker script or targeted test that can be run manually, with deterministic seed text and ANSI stripping; do not block normal unit tests if terminal prerequisites are absent unless the project already supports it. +- RISK: The harness accidentally chooses resume and invalidates the claim. → MITIGATION: capture the initial switcher screen before sending any activation keystroke, then separately exercise new-session if automated input is reliable. +- RISK: This becomes only a screenshot test. → MITIGATION: pair terminal capture with store assertions: old transcript file preserved, new binding-only session when new-session path is exercised. +- ASSUMPTION: Existing source launch can be driven through `tsx`/built CLI in a pty enough to capture first paint. → VALIDATE: run locally and document command/output in the runbook or test fixture. → memory/SPEC.md I22-L + +### Acceptance Criteria + +✓ Runbook/checker exists — a documented command seeds a workspace with unique stale transcript text and captures Brunch TUI startup output with ANSI stripped. + +✓ No-stale-transcript assertion — captured startup output before explicit resume/open does not contain the unique stale transcript text. + +✓ Switcher-visible assertion — captured startup output contains Brunch workspace-switcher text or a stable product startup marker. + +✓ Optional new-session assertion when automated input is reliable — choosing new session creates a new binding-only session and preserves the stale transcript file unchanged. + +### Verification Approach + +- Middle: runbook oracle — combines terminal capture and executable text/store postconditions. +- Inner: any helper functions for ANSI stripping/seed setup get unit tests if introduced. +- Outer: manual walkthrough can reuse the same runbook for qualitative startup feel. + +### Cross-cutting obligations + +- This card proves I22-L at the user-facing boundary; it should not change product behavior unless the oracle exposes a real bug. +- Keep fixture/test artifacts out of the repo unless intentionally checked in as runbook scripts. + +--- + +## Card 5 — FE-744 affordance memo reconciliation + +- **Status:** queued +- **Weight:** light scope card +- **Frontier:** `pi-ui-extension-patterns` / FE-744 + +### Objective + +The Pi UI extension patterns memo reflects the Brunch implementation and the relevant Pi example patterns for chrome, typed custom UI, command shutdown, structured output, and title/status surfaces. + +### Acceptance Criteria + +✓ `docs/architecture/pi-ui-extension-patterns.md` records Brunch's internal extension layout and current implementation evidence for header/status/widget/title/footer choices. + +✓ The memo distinguishes implemented Brunch surfaces from source/example-derived Pi affordance evidence: `question`/`questionnaire` typed UI, `shutdown-command`, `structured-output`, `titlebar-spinner`, `custom-header`, `custom-footer`, `status-line`, and `border-status-editor`. + +✓ The memo records remaining FE-744 gaps honestly: residual built-in command exposure, keybinding policy, manual startup pty oracle status, and whether in-session switcher command is implemented. + +✓ No SPEC/PLAN durable semantics change unless implementation revealed a new decision; otherwise this is evidence reconciliation only. + +### Verification Approach + +- Inner: doc review against current code paths and the reviewed Pi examples. +- Middle: traceability check — memo claims match implemented tests/runbook evidence and do not overclaim strict Pi built-in suppression. + +### Cross-cutting obligations + +- Keep FE-744 evidence tiered: Brunch-host proof, Pi source/example evidence, RPC controllability, and manual runbook evidence are not interchangeable. +- Do not let source/example evidence masquerade as Brunch integration proof. + +### Promotion checklist + +- [ ] Does this change a requirement? No. +- [ ] Does this create, retire, or invalidate an assumption? No. +- [ ] Does this make or reverse a non-trivial design decision? No. +- [ ] Does this establish a new seam-level invariant? No. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. +- [ ] Does it cross more than two major seams? No. +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. + +--- + +## Done / retired context + +The earlier workspace-switcher and extension-organization refactor queues are exhausted and intentionally not repeated here. `HANDOFF.md` should be deleted once these cards are underway or once a newer handoff supersedes it; its startup diagnosis has been absorbed into SPEC/PLAN/code/cards. diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index e5ae7f90..440143ad 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -13,15 +13,17 @@ import { import { applyBrunchOfflineDefault, brunchResourceLoaderOptions, + createBrunchSettingsManager, + runBrunchTui, +} from "./brunch-tui.js" +import { chromeStateForWorkspace, createBrunchChromeExtension, - createBrunchSettingsManager, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatChromeWidgetLines, renderBrunchChrome, - runBrunchTui, -} from "./brunch-tui.js" +} from "./pi-extensions/brunch/index.js" import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, diff --git a/src/pi-extensions/brunch/branch-policy.ts b/src/pi-extensions/brunch/branch-policy.ts new file mode 100644 index 00000000..e2eaff13 --- /dev/null +++ b/src/pi-extensions/brunch/branch-policy.ts @@ -0,0 +1,15 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = + "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." + +export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { + pi.on("session_before_tree", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) + pi.on("session_before_fork", (_event, ctx) => { + ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") + return { cancel: true } + }) +} diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts new file mode 100644 index 00000000..9097737e --- /dev/null +++ b/src/pi-extensions/brunch/chrome.ts @@ -0,0 +1,112 @@ +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" + +import type { + WorkspaceSessionChromeState, + WorkspaceSessionReadyState, +} from "../../workspace-session-coordinator.js" + +export type BrunchChromeStage = "idle" | "streaming" | "observer-review" +export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" +export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" + +export interface BrunchChromeState extends WorkspaceSessionChromeState { + session: { + id: string + label?: string + } + stage: BrunchChromeStage + activeLens: string | null + coherenceVerdict: BrunchChromeCoherenceVerdict + observerStatus: BrunchChromeWorkerStatus + reviewerStatus: BrunchChromeWorkerStatus + reconcilerStatus: BrunchChromeWorkerStatus + reconciliationNeedCount: number + latestEstablishmentOfferSummary: string | null + streaming: boolean +} + +export type BrunchChromeUi = Pick + +export function formatBrunchChromeHeaderLines( + chrome: BrunchChromeState, +): string[] { + return [ + "brunch specification workspace", + `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, + ] +} + +export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { + return [ + `cwd: ${chrome.cwd}`, + `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, + `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, + `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, + ] +} + +export function formatBrunchChromeFooterLines( + chrome: BrunchChromeState, +): string[] { + const offer = chrome.latestEstablishmentOfferSummary + ? `offer: ${chrome.latestEstablishmentOfferSummary}` + : "offer: none" + return [ + `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, + offer, + ] +} + +export function chromeStateForWorkspace( + workspace: WorkspaceSessionReadyState, +): BrunchChromeState { + return { + ...workspace.chrome, + session: { + id: workspace.session.id, + label: workspace.session.id, + }, + stage: "idle", + activeLens: null, + coherenceVerdict: "unknown", + observerStatus: "idle", + reviewerStatus: "idle", + reconcilerStatus: "idle", + reconciliationNeedCount: 0, + latestEstablishmentOfferSummary: null, + streaming: false, + } +} + +export function renderBrunchChrome( + ui: BrunchChromeUi, + chrome: BrunchChromeState, +): void { + ui.setHeader(() => ({ + render: () => formatBrunchChromeHeaderLines(chrome), + invalidate: () => {}, + })) + ui.setFooter(() => ({ + render: () => formatBrunchChromeFooterLines(chrome), + invalidate: () => {}, + })) + ui.setStatus( + "brunch.chrome", + `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, + ) + ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { + placement: "aboveEditor", + }) + ui.setWorkingIndicator( + chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, + ) + ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) +} + +function formatSpec(chrome: BrunchChromeState): string { + return chrome.spec?.title ?? "no spec selected" +} + +function formatSession(chrome: BrunchChromeState): string { + return chrome.session.label ?? chrome.session.id +} diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts index 3835d78b..3f8b9b14 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions/brunch/index.ts @@ -1,145 +1,48 @@ import { SessionManager, type ExtensionFactory, - type ExtensionUIContext, } from "@earendil-works/pi-coding-agent" -import type { - WorkspaceSessionChromeState, - WorkspaceSessionReadyState, -} from "../../workspace-session-coordinator.js" - -export const BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE = - "Brunch does not support Pi session branches in this POC. Use /new to continue within the selected spec." - -export type BrunchChromeStage = "idle" | "streaming" | "observer-review" -export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" -export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" - -export interface BrunchChromeState extends WorkspaceSessionChromeState { - session: { - id: string - label?: string - } - stage: BrunchChromeStage - activeLens: string | null - coherenceVerdict: BrunchChromeCoherenceVerdict - observerStatus: BrunchChromeWorkerStatus - reviewerStatus: BrunchChromeWorkerStatus - reconcilerStatus: BrunchChromeWorkerStatus - reconciliationNeedCount: number - latestEstablishmentOfferSummary: string | null - streaming: boolean -} - -export function formatBrunchChromeHeaderLines( - chrome: BrunchChromeState, -): string[] { - return [ - "brunch specification workspace", - `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, - ] -} - -export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { - return [ - `cwd: ${chrome.cwd}`, - `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, - `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, - `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, - ] -} - -export function formatBrunchChromeFooterLines( - chrome: BrunchChromeState, -): string[] { - const offer = chrome.latestEstablishmentOfferSummary - ? `offer: ${chrome.latestEstablishmentOfferSummary}` - : "offer: none" - return [ - `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, - offer, - ] -} - -export function chromeStateForWorkspace( - workspace: WorkspaceSessionReadyState, -): BrunchChromeState { - return { - ...workspace.chrome, - session: { - id: workspace.session.id, - label: workspace.session.id, - }, - stage: "idle", - activeLens: null, - coherenceVerdict: "unknown", - observerStatus: "idle", - reviewerStatus: "idle", - reconcilerStatus: "idle", - reconciliationNeedCount: 0, - latestEstablishmentOfferSummary: null, - streaming: false, - } -} - -export function renderBrunchChrome( - ui: Pick, - chrome: BrunchChromeState, -): void { - ui.setHeader(() => ({ - render: () => formatBrunchChromeHeaderLines(chrome), - invalidate: () => {}, - })) - ui.setFooter(() => ({ - render: () => formatBrunchChromeFooterLines(chrome), - invalidate: () => {}, - })) - ui.setStatus( - "brunch.chrome", - `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, - ) - ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { - placement: "aboveEditor", - }) - ui.setWorkingIndicator( - chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, - ) - ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) -} +import { registerBrunchBranchPolicyHandlers } from "./branch-policy.js" +import { renderBrunchChrome, type BrunchChromeState } from "./chrome.js" +import { + bindBrunchSessionBoundary, + registerBrunchSessionBoundaryRefreshHandlers, + type BrunchSessionBoundaryHandler, +} from "./session-boundary.js" + +export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./branch-policy.js" +export { + chromeStateForWorkspace, + formatBrunchChromeFooterLines, + formatBrunchChromeHeaderLines, + formatChromeWidgetLines, + renderBrunchChrome, + type BrunchChromeCoherenceVerdict, + type BrunchChromeStage, + type BrunchChromeState, + type BrunchChromeUi, + type BrunchChromeWorkerStatus, +} from "./chrome.js" +export { + bindBrunchSessionBoundary, + registerBrunchSessionBoundaryRefreshHandlers, + type BrunchSessionBoundaryHandler, +} from "./session-boundary.js" export function createBrunchChromeExtension( chrome: BrunchChromeState, - onSessionBoundary?: (sessionManager: SessionManager) => Promise | void, + onSessionBoundary?: BrunchSessionBoundaryHandler, ): ExtensionFactory { return (pi) => { pi.on("session_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) renderBrunchChrome(ctx.ui, chrome) }) - pi.on("before_agent_start", async (_event, ctx) => { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - }) - pi.on("message_start", async (event, ctx) => { - if (event.message.role === "assistant") { - await onSessionBoundary?.(ctx.sessionManager as SessionManager) - } - }) - pi.on("session_before_tree", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) - pi.on("session_before_fork", (_event, ctx) => { - ctx.ui.notify(BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, "warning") - return { cancel: true } - }) + registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) + registerBrunchBranchPolicyHandlers(pi) } } - -function formatSpec(chrome: BrunchChromeState): string { - return chrome.spec?.title ?? "no spec selected" -} - -function formatSession(chrome: BrunchChromeState): string { - return chrome.session.label ?? chrome.session.id -} diff --git a/src/pi-extensions/brunch/session-boundary.ts b/src/pi-extensions/brunch/session-boundary.ts new file mode 100644 index 00000000..4e49f8c1 --- /dev/null +++ b/src/pi-extensions/brunch/session-boundary.ts @@ -0,0 +1,35 @@ +import { + SessionManager, + type ExtensionAPI, +} from "@earendil-works/pi-coding-agent" + +export type BrunchSessionBoundaryHandler = ( + sessionManager: SessionManager, +) => Promise | void + +export async function bindBrunchSessionBoundary( + sessionManager: SessionManager, + onSessionBoundary?: BrunchSessionBoundaryHandler, +): Promise { + await onSessionBoundary?.(sessionManager) +} + +export function registerBrunchSessionBoundaryRefreshHandlers( + pi: ExtensionAPI, + onSessionBoundary?: BrunchSessionBoundaryHandler, +): void { + pi.on("before_agent_start", async (_event, ctx) => { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) + }) + pi.on("message_start", async (event, ctx) => { + if (event.message.role === "assistant") { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + onSessionBoundary, + ) + } + }) +} From b7be6a5098e145a0c64e6c77a446d6ad0b2214ff Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:54:05 +0200 Subject: [PATCH 023/164] FE-744: Allocate Brunch chrome surfaces --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 55 ++++++++++++++---------------- src/pi-extensions/brunch/chrome.ts | 38 +++++++++------------ src/pi-extensions/brunch/index.ts | 1 + 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index f7c21cc4..9d0558eb 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -74,7 +74,7 @@ The Brunch Pi extension entrypoint registers extension behavior through surface- ## Card 2 — Product-shell chrome surface allocation -- **Status:** queued +- **Status:** done - **Weight:** light scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 440143ad..2c004e1c 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -21,6 +21,7 @@ import { createBrunchChromeExtension, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, + formatBrunchStatus, formatChromeWidgetLines, renderBrunchChrome, } from "./pi-extensions/brunch/index.js" @@ -178,28 +179,12 @@ describe("Brunch TUI boot", () => { }) it("passes activated session state into chrome instead of fabricating unbound", async () => { - const widgets = new Map() - const ui: FakeExtensionUi = { - setHeader: (_factory) => {}, - setFooter: (_factory) => {}, - setStatus: (_key, _text) => {}, - setWidget: (key: string, content: unknown) => { - if (isStringArray(content)) { - widgets.set(key, content) - } - }, - setWorkingIndicator: (_options) => {}, - setTitle: (_title: string) => {}, - notify: (_message: string, _type?: "info" | "warning" | "error") => {}, - } - - renderBrunchChrome( - ui, - chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-real")), + const state = chromeStateForWorkspace( + readyWorkspace("/tmp/project", "session-real"), ) - expect(widgets.get("brunch.chrome")?.join("\n")).toContain( - "session: session-real", + expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( + "session-real", ) }) @@ -228,13 +213,13 @@ describe("Brunch TUI boot", () => { expect(formatChromeWidgetLines(state).join("\n")).toContain( "lens: problem-framing", ) - expect(formatChromeWidgetLines(state).join("\n")).toContain("needs: 3") - expect(formatBrunchChromeFooterLines(state).join("\n")).toContain( - "observer: running", + expect(formatBrunchStatus(state)).toBe( + "Brunch · elicitation · needs_review · needs 3", ) - expect(formatBrunchChromeFooterLines(state).join("\n")).toContain( + expect(formatChromeWidgetLines(state).join("\n")).toContain( "offer: Recommended lens: problem-framing; missing constraints.", ) + expect(formatBrunchChromeFooterLines(state)).toEqual([]) }) it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { @@ -280,20 +265,26 @@ describe("Brunch TUI boot", () => { "setWorkingIndicator", "setTitle", ]) + expect(calls.find((call) => call.method === "setFooter")?.args).toEqual([ + undefined, + ]) expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ "brunch.chrome", - "Brunch · elicitation · no active lens · coherent · needs 0", + "Brunch · elicitation · coherent · needs 0", ]) expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ "brunch.chrome", [ "cwd: /tmp/project", - "spec: Spec One session: session-1 stage: idle", - "lens: none coherence: coherent needs: 0", - "observer: idle reviewer: idle reconciler: idle", + "chat mode: responding-to-elicitation stage: idle", + "lens: none", + "workers: observer idle · reviewer idle · reconciler idle", ], { placement: "aboveEditor" }, ]) + expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ + "brunch — Spec One", + ]) }) it("binds replacement sessions through internal session boundary events", async () => { @@ -301,6 +292,7 @@ describe("Brunch TUI boot", () => { const manager = SessionManager.create(cwd, join(cwd, ".brunch", "sessions")) const boundSessionIds: string[] = [] const widgets = new Map() + const titles: string[] = [] const ui: FakeExtensionUi = { setHeader: (_factory) => {}, setFooter: (_factory) => {}, @@ -311,7 +303,7 @@ describe("Brunch TUI boot", () => { } }, setWorkingIndicator: (_options) => {}, - setTitle: (_title: string) => {}, + setTitle: (title: string) => titles.push(title), notify: (_message: string, _type?: "info" | "warning" | "error") => {}, } const ctx: FakeExtensionContext = { sessionManager: manager, ui } @@ -363,7 +355,10 @@ describe("Brunch TUI boot", () => { manager.getSessionId(), manager.getSessionId(), ]) - expect(widgets.get("brunch.chrome")?.join("\n")).toContain("Spec One") + expect(widgets.get("brunch.chrome")?.join("\n")).toContain( + "chat mode: responding-to-elicitation", + ) + expect(titles).toEqual(["brunch — Spec One"]) }) it("cancels Pi branch-flow hooks with a stable user-facing reason", async () => { diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts index 9097737e..18d3ef1c 100644 --- a/src/pi-extensions/brunch/chrome.ts +++ b/src/pi-extensions/brunch/chrome.ts @@ -32,29 +32,31 @@ export function formatBrunchChromeHeaderLines( ): string[] { return [ "brunch specification workspace", - `${formatSpec(chrome)} · ${formatSession(chrome)} · ${chrome.phase}`, + `${formatSpec(chrome)} · ${formatSession(chrome)}`, ] } +export function formatBrunchStatus(chrome: BrunchChromeState): string { + return `Brunch · ${chrome.phase} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}` +} + export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { - return [ + const lines = [ `cwd: ${chrome.cwd}`, - `spec: ${formatSpec(chrome)} session: ${formatSession(chrome)} stage: ${chrome.stage}`, - `lens: ${chrome.activeLens ?? "none"} coherence: ${chrome.coherenceVerdict} needs: ${chrome.reconciliationNeedCount}`, - `observer: ${chrome.observerStatus} reviewer: ${chrome.reviewerStatus} reconciler: ${chrome.reconcilerStatus}`, + `chat mode: ${chrome.chatMode} stage: ${chrome.stage}`, + `lens: ${chrome.activeLens ?? "none"}`, + `workers: observer ${chrome.observerStatus} · reviewer ${chrome.reviewerStatus} · reconciler ${chrome.reconcilerStatus}`, ] + if (chrome.latestEstablishmentOfferSummary) { + lines.push(`offer: ${chrome.latestEstablishmentOfferSummary}`) + } + return lines } export function formatBrunchChromeFooterLines( - chrome: BrunchChromeState, + _chrome: BrunchChromeState, ): string[] { - const offer = chrome.latestEstablishmentOfferSummary - ? `offer: ${chrome.latestEstablishmentOfferSummary}` - : "offer: none" - return [ - `observer: ${chrome.observerStatus} · reviewer: ${chrome.reviewerStatus} · reconciler: ${chrome.reconcilerStatus}`, - offer, - ] + return [] } export function chromeStateForWorkspace( @@ -86,14 +88,8 @@ export function renderBrunchChrome( render: () => formatBrunchChromeHeaderLines(chrome), invalidate: () => {}, })) - ui.setFooter(() => ({ - render: () => formatBrunchChromeFooterLines(chrome), - invalidate: () => {}, - })) - ui.setStatus( - "brunch.chrome", - `Brunch · ${chrome.phase} · ${chrome.activeLens ?? "no active lens"} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}`, - ) + ui.setFooter(undefined) + ui.setStatus("brunch.chrome", formatBrunchStatus(chrome)) ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { placement: "aboveEditor", }) diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts index 3f8b9b14..6bb25c65 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions/brunch/index.ts @@ -16,6 +16,7 @@ export { chromeStateForWorkspace, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, + formatBrunchStatus, formatChromeWidgetLines, renderBrunchChrome, type BrunchChromeCoherenceVerdict, From a724b75e4146861e408caf5bf167dc1504519af8 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:57:20 +0200 Subject: [PATCH 024/164] FE-744: Add in-session workspace switch command --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 219 ++++++++++++++++++ src/brunch-tui.ts | 7 +- src/pi-extensions/brunch/index.ts | 12 + src/pi-extensions/brunch/workspace-command.ts | 89 +++++++ 5 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 src/pi-extensions/brunch/workspace-command.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 9d0558eb..6af81d5b 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -119,7 +119,7 @@ Brunch chrome renders each persistent shell fact on one deliberate Pi UI surface ## Card 3 — In-session workspace switcher command -- **Status:** queued +- **Status:** done - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 2c004e1c..f72d7def 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -6,8 +6,10 @@ import { describe, expect, it } from "vitest" import { SessionManager, + type ExtensionCommandContext, type ExtensionContext, type ExtensionUIContext, + type RegisteredCommand, } from "@earendil-works/pi-coding-agent" import { @@ -17,6 +19,7 @@ import { runBrunchTui, } from "./brunch-tui.js" import { + BRUNCH_WORKSPACE_COMMAND, chromeStateForWorkspace, createBrunchChromeExtension, formatBrunchChromeFooterLines, @@ -24,10 +27,12 @@ import { formatBrunchStatus, formatChromeWidgetLines, renderBrunchChrome, + runBrunchWorkspaceCommand, } from "./pi-extensions/brunch/index.js" import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, + type WorkspaceLaunchInventory, type WorkspaceSessionReadyState, } from "./workspace-session-coordinator.js" @@ -361,6 +366,129 @@ describe("Brunch TUI boot", () => { expect(titles).toEqual(["brunch — Spec One"]) }) + it("registers a Brunch-owned workspace switch command", async () => { + const commands = + new Map>() + + createBrunchChromeExtension( + chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), + undefined, + { + coordinator: { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => + readyWorkspace("/tmp/project", "session-1"), + }, + }, + )({ + on: (_event: string, _handler: unknown) => {}, + registerCommand: (name, options) => commands.set(name, options), + } as never) + + expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( + "Switch Brunch spec/session workspace", + ) + }) + + it("runs the in-session workspace switch through coordinator activation and replacement context", async () => { + const events: string[] = [] + const target = readyWorkspace("/tmp/project", "session-target") + const replacementUi = fakeUi((method) => + events.push(`replacement:${method}`), + ) + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { + action: "openSession", + specId: target.spec.id, + sessionFile: target.session.file, + }, + onEvent: (event) => events.push(event), + replacementUi, + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => { + events.push("inspect") + return inventoryWithWorkspace(target) + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return target + }, + }) + + expect(events).toEqual([ + "waitForIdle", + "inspect", + "custom", + "activate:openSession", + `switch:${target.session.file}`, + "replacement:setHeader", + "replacement:setFooter", + "replacement:setStatus", + "replacement:setWidget", + "replacement:setWorkingIndicator", + "replacement:setTitle", + "replacement:notify", + ]) + }) + + it("leaves the current session untouched when workspace switch is cancelled", async () => { + const events: string[] = [] + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { action: "cancel" }, + onEvent: (event) => events.push(event), + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => ({ + status: "cancelled", + cwd: "/tmp/project", + chrome: { + cwd: "/tmp/project", + spec: null, + phase: "select_spec", + chatMode: "select-spec", + }, + }), + }) + + expect(events).toEqual(["waitForIdle", "custom", "notify:info"]) + }) + + it("reports needs-human workspace switch decisions without switching sessions", async () => { + const events: string[] = [] + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { + action: "openSession", + specId: "missing", + sessionFile: "/sessions/missing.jsonl", + }, + onEvent: (event) => events.push(event), + }) + + await runBrunchWorkspaceCommand(ctx, { + inspectWorkspace: async () => emptyInventory("/tmp/project"), + activateWorkspace: async () => ({ + status: "needs_human", + cwd: "/tmp/project", + reason: "Selected session is not available.", + chrome: { + cwd: "/tmp/project", + spec: null, + phase: "select_spec", + chatMode: "select-spec", + }, + }), + }) + + expect(events).toEqual(["waitForIdle", "custom", "notify:warning"]) + }) + it("cancels Pi branch-flow hooks with a stable user-facing reason", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch", "sessions")) @@ -471,6 +599,97 @@ function readyWorkspace( } } +function emptyInventory(cwd: string): WorkspaceLaunchInventory { + return { + cwd, + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], + } +} + +function inventoryWithWorkspace( + workspace: WorkspaceSessionReadyState, +): WorkspaceLaunchInventory { + return { + cwd: workspace.cwd, + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [ + { + spec: workspace.spec, + sessions: [ + { + id: workspace.session.id, + file: workspace.session.file, + specId: workspace.spec.id, + specTitle: workspace.spec.title, + available: true, + }, + ], + }, + ], + unavailableSessions: [], + } +} + +function fakeCommandContext(options: { + currentSessionFile: string + decision: Awaited> + onEvent: (event: string) => void + replacementUi?: FakeExtensionUi +}): ExtensionCommandContext { + const ui = fakeUi((method, type) => { + if (method === "notify") { + options.onEvent(`notify:${type}`) + } + }) + const ctx = { + cwd: "/tmp/project", + sessionManager: { + getSessionFile: () => options.currentSessionFile, + }, + ui: { + ...ui, + custom: async () => { + options.onEvent("custom") + return options.decision + }, + }, + waitForIdle: async () => options.onEvent("waitForIdle"), + switchSession: async ( + sessionPath: string, + switchOptions?: Parameters[1], + ) => { + options.onEvent(`switch:${sessionPath}`) + await switchOptions?.withSession?.({ + ...ctx, + ui: options.replacementUi ?? ui, + sessionManager: { getSessionFile: () => sessionPath }, + } as ExtensionCommandContext) + return { cancelled: false } + }, + } + return ctx as unknown as ExtensionCommandContext +} + +function fakeUi( + onCall: (method: string, notifyType?: "info" | "warning" | "error") => void, +): FakeExtensionUi { + return { + setHeader: (_factory) => onCall("setHeader"), + setFooter: (_factory) => onCall("setFooter"), + setStatus: (_key, _text) => onCall("setStatus"), + setWidget: (_key, _content, _options) => onCall("setWidget"), + setWorkingIndicator: (_options) => onCall("setWorkingIndicator"), + setTitle: (_title) => onCall("setTitle"), + notify: (_message, type) => onCall("notify", type), + } +} + interface FakeUiCall { method: string args: unknown[] diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index efa24f80..ce66be16 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -39,13 +39,13 @@ export { } from "./pi-extensions/brunch/index.js" export { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" +export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator + export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState - coordinator: WorkspaceSessionBoundaryCoordinator + coordinator: BrunchTuiCoordinator } -export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator - export interface BrunchTuiOptions { cwd?: string coordinator?: BrunchTuiCoordinator @@ -117,6 +117,7 @@ async function launchPiInteractive({ sessionManager, ) }, + { coordinator }, ), ]), }) diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts index 6bb25c65..985f3afb 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions/brunch/index.ts @@ -10,6 +10,10 @@ import { registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, } from "./session-boundary.js" +import { + registerBrunchWorkspaceCommand, + type BrunchWorkspaceCommandOptions, +} from "./workspace-command.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./branch-policy.js" export { @@ -30,10 +34,17 @@ export { registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, } from "./session-boundary.js" +export { + BRUNCH_WORKSPACE_COMMAND, + registerBrunchWorkspaceCommand, + runBrunchWorkspaceCommand, + type BrunchWorkspaceCommandOptions, +} from "./workspace-command.js" export function createBrunchChromeExtension( chrome: BrunchChromeState, onSessionBoundary?: BrunchSessionBoundaryHandler, + options: BrunchWorkspaceCommandOptions = {}, ): ExtensionFactory { return (pi) => { pi.on("session_start", async (_event, ctx) => { @@ -45,5 +56,6 @@ export function createBrunchChromeExtension( }) registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) registerBrunchBranchPolicyHandlers(pi) + registerBrunchWorkspaceCommand(pi, options) } } diff --git a/src/pi-extensions/brunch/workspace-command.ts b/src/pi-extensions/brunch/workspace-command.ts new file mode 100644 index 00000000..ff067e52 --- /dev/null +++ b/src/pi-extensions/brunch/workspace-command.ts @@ -0,0 +1,89 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent" + +import { + type WorkspaceSessionReadyState, + type WorkspaceSwitchCoordinator, + type WorkspaceSwitchDecision, +} from "../../workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "../../workspace-switcher/index.js" +import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" + +export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" + +export interface BrunchWorkspaceCommandOptions { + coordinator?: WorkspaceSwitchCoordinator +} + +export function registerBrunchWorkspaceCommand( + pi: ExtensionAPI, + options: BrunchWorkspaceCommandOptions = {}, +): void { + if (!options.coordinator) { + return + } + + pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { + description: "Switch Brunch spec/session workspace", + handler: async (_args, ctx) => { + await runBrunchWorkspaceCommand(ctx, options.coordinator!) + }, + }) +} + +export async function runBrunchWorkspaceCommand( + ctx: ExtensionCommandContext, + coordinator: WorkspaceSwitchCoordinator, +): Promise { + await ctx.waitForIdle() + const inventory = await coordinator.inspectWorkspace() + const decision = await ctx.ui.custom( + (_tui, _theme, _keybindings, done) => + createWorkspaceSwitchComponent({ inventory, onDecision: done }), + { overlay: true }, + ) + const activated = await coordinator.activateWorkspace(decision) + + if (activated.status === "cancelled") { + ctx.ui.notify("Workspace switch cancelled.", "info") + return + } + if (activated.status === "needs_human") { + ctx.ui.notify(activated.reason, "warning") + return + } + + await switchToActivatedWorkspace(ctx, activated) +} + +async function switchToActivatedWorkspace( + ctx: ExtensionCommandContext, + activated: WorkspaceSessionReadyState, +): Promise { + const targetFile = activated.session.file + if (ctx.sessionManager.getSessionFile() === targetFile) { + renderBrunchChrome(ctx.ui, chromeStateForWorkspace(activated)) + ctx.ui.notify("Already using the selected Brunch workspace.", "info") + return + } + + const targetSessionId = activated.session.id + const targetSpecTitle = activated.spec.title + const targetChrome = chromeStateForWorkspace(activated) + + const result = await ctx.switchSession(targetFile, { + withSession: async (replacementCtx) => { + renderBrunchChrome(replacementCtx.ui, targetChrome) + replacementCtx.ui.notify( + `Switched Brunch workspace to ${targetSpecTitle} (${targetSessionId}).`, + "info", + ) + }, + }) + + if (result.cancelled) { + ctx.ui.notify("Workspace switch was cancelled by Pi.", "warning") + } +} From ee08a64d032f99a48fc7bc0f8273c676746ad223 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:00:08 +0200 Subject: [PATCH 025/164] FE-744: Add startup no-resume oracle --- memory/CARDS.md | 2 +- memory/SPEC.md | 2 +- runbooks/verify-startup-no-resume.sh | 66 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100755 runbooks/verify-startup-no-resume.sh diff --git a/memory/CARDS.md b/memory/CARDS.md index 6af81d5b..674b9b02 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -176,7 +176,7 @@ A Brunch-owned slash command opens the reusable workspace switcher inside an act ## Card 4 — Startup pty oracle for no implicit transcript resume -- **Status:** queued +- **Status:** done - **Weight:** full scope card - **Frontier:** `pi-ui-extension-patterns` / FE-744 diff --git a/memory/SPEC.md b/memory/SPEC.md index 154bb56a..411780b7 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -190,7 +190,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | -| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | planned (FE-744 startup-switcher coordinator tests plus pty/ANSI-stripped TUI runbook oracle) | D11-L, D21-L, D22-L, D36-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | ## Future Direction Register diff --git a/runbooks/verify-startup-no-resume.sh b/runbooks/verify-startup-no-resume.sh new file mode 100755 index 00000000..0f86bf07 --- /dev/null +++ b/runbooks/verify-startup-no-resume.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Proves FE-744/I22 at the terminal boundary: Brunch TUI startup shows the +# workspace switcher before any prior transcript is rendered. This runbook uses +# a real pty via `script`; it is intended as a manual/middle-loop oracle rather +# than part of the default verify gate. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/brunch-startup-oracle.XXXXXX")}" +CAPTURE_RAW="$WORK_DIR/startup.raw" +CAPTURE_STRIPPED="$WORK_DIR/startup.stripped" +STALE_TEXT="BRUNCH_STALE_TRANSCRIPT_SENTINEL_$(date +%s)_$$" + +cd "$ROOT_DIR" +npm run build >/dev/null + +STALE_TEXT="$STALE_TEXT" WORK_DIR="$WORK_DIR" node --input-type=module <<'NODE' +import { createWorkspaceSessionCoordinator } from './dist/workspace-session-coordinator.js' + +const cwd = process.env.WORK_DIR +const staleText = process.env.STALE_TEXT +const coordinator = createWorkspaceSessionCoordinator({ cwd }) +const workspace = await coordinator.createSetupSession({ + specTitle: 'Startup Oracle Spec', +}) +workspace.session.manager.appendMessage({ + role: 'assistant', + content: staleText, +}) +console.log(`Seeded stale transcript: ${workspace.session.file}`) +NODE + +BRUNCH_CMD="cd '$WORK_DIR' && PI_OFFLINE=1 node '$ROOT_DIR/dist/brunch.js' --mode tui" + +set +e +if script --version >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 3 script -q -f -c "$BRUNCH_CMD" "$CAPTURE_RAW" +else + perl -e 'alarm shift; exec @ARGV' 3 script -q -F "$CAPTURE_RAW" /bin/sh -lc "$BRUNCH_CMD" +fi +set -e + +perl -CS -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/\e\][^\a]*(\a|\e\\)//g; s/\eP.*?(\a|\e\\)//g; s/\r/\n/g' \ + "$CAPTURE_RAW" > "$CAPTURE_STRIPPED" + +if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup rendered stale transcript text before explicit activation" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +if ! grep -Eq "Brunch workspace|Choose how to start this session|New spec" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show a stable workspace-switcher marker" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +cat < Date: Fri, 22 May 2026 16:01:54 +0200 Subject: [PATCH 026/164] FE-744: Reconcile Pi UI extension memo --- docs/architecture/pi-ui-extension-patterns.md | 57 +++- memory/CARDS.md | 271 ------------------ memory/PLAN.md | 4 +- 3 files changed, 47 insertions(+), 285 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 8a1088b9..e75135be 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -12,13 +12,16 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | +| Startup workspace switcher | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | +| In-session workspace switcher command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable switcher beyond startup | Brunch extension command tests + coordinator store oracle | +| Typed custom UI (`ctx.ui.custom`) | feasible/proven for Brunch workspace decisions; richer question/questionnaire surfaces remain Pi-example evidence only | informs M5 review/lens affordances | Brunch command tests + Pi docs/examples | ## Evidence inventory - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** Card 2 adds `renderBrunchChrome(ctx.ui, state)` in `src/brunch-tui.ts`, with tests proving one Brunch-owned wrapper drives `setHeader`, `setFooter`, `setStatus`, `setWidget`, optional `setWorkingIndicator`, and terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, optional `setWorkingIndicator`, and terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -107,27 +110,33 @@ Raw RPC probe results with the temporary extension: The same probe emitted corresponding `notify` requests (`cancel switch new`, `cancel fork/clone`). No Brunch product transcript fixture was created; the probe used `--no-session`. -## Dynamic Brunch chrome proof +## Brunch extension layout and dynamic chrome proof -Card 2 adds a product-named wrapper, `renderBrunchChrome(ctx.ui, state)`, rather than letting downstream affordance probes scatter raw Pi UI calls. The wrapper treats chrome as projection state over canonical Brunch/session facts and renders: +The Brunch extension entrypoint is intentionally a registration map. It composes private modules by Pi surface/responsibility: -- cwd, selected spec, and session label/id; -- phase, stage, chat mode, and streaming state; -- active lens or `none`; -- coherence verdict and reconciliation-need count; -- observer, reviewer, and reconciler status; -- latest establishment-offer summary or `offer: none`. +- `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. +- `session-boundary.ts` owns coordinator refresh calls on session-boundary events. +- `branch-policy.ts` owns `session_before_tree` / `session_before_fork` cancellation. +- `workspace-command.ts` owns the product slash command and replacement-session lifecycle. -The wrapper currently uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`, `●`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer factories render in TUI; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +`renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current surface allocation is deliberate: + +- header: product identity plus active spec/session (`brunch specification workspace`, spec title, real activated session id/label); +- status: compact persistent phase/coherence/reconciliation-need summary; +- widget: expanded diagnostics (cwd, chat mode, stage, active lens, worker statuses, latest establishment offer when present); +- title: compact Brunch-owned terminal title derived from activated workspace state; +- footer: restored to Pi default via `setFooter(undefined)` because Brunch does not currently need to replace the whole footer. + +The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`, `●`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: | Scenario | Result | Evidence | | --- | --- | --- | -| Idle TUI mount | Header, footer, status, widget, and title are called from one snapshot; raw TUI transcript shows Brunch header/footer/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | +| Idle TUI mount | Header, status, diagnostic widget, title, and default-footer restoration are called from one snapshot; raw TUI transcript shows Brunch header/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | | Streaming/progress update | Wrapper formats streaming/worker state deterministically; raw RPC extension command updates status/widget to `stage: streaming`, `lens: problem-framing`, `needs: 3`. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | | `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | -| Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. This is safe for same-spec coordinator flows but does not authorize raw Pi session switching. | `src/brunch-tui.test.ts` | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The Brunch workspace command activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | ## Startup/splash logo asset decision @@ -154,6 +163,27 @@ chafa -f symbols \ Runtime should **not** invoke Chafa on startup. The logo should be deterministic, cheap to render, and independent of host-installed CLI tools. Chafa is therefore a maintainer/dev tool at most, not a runtime dependency. Startup chrome should choose `brunch-logo-quad-56x18.ansi` when truecolor is available, otherwise `brunch-logo-quad-56x18-240.ansi`; for very limited terminals, a plain `brunch` wordmark is sufficient rather than carrying 16-color or 8-color assets. +## Workspace switcher implementation evidence + +Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure `workspace-switcher` UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. + +The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-switcher markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. + +The in-session command is product-named `/brunch-workspace`. Its handler waits for idle, inspects inventory, renders the same typed workspace-switcher component with `ctx.ui.custom()`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. + +## Pi example evidence not yet Brunch integration proof + +Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance design, but they are not interchangeable with Brunch-host proof: + +| Example/source affordance | Evidence status | Brunch interpretation | +| --- | --- | --- | +| `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | +| `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | +| `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-switcher proof. | +| `titlebar-spinner` / working indicator examples | Pi example plus Brunch wrapper tests | Brunch exposes `streaming` to `setWorkingIndicator`, but no live side-task/reviewer spinner is product-proven yet. | +| `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | +| `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | + ## RPC controllability observations relevant to command containment and chrome Raw Pi RPC success is not Brunch integration proof, but it matters for the fixture-driver oracle: @@ -193,6 +223,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom - `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. - M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. - M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. +- Future switcher/review/elicitation commands should follow `/brunch-workspace`: product-owned names, typed `ctx.ui.custom()` decisions, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. - A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. ## Open evidence gaps @@ -200,4 +231,6 @@ The policy must run before interactive-mode built-in dispatch and before autocom - Interactive autocomplete filtering was source-proven but not visually observed in a TUI session from this API-only run. - Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. +- The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. +- The in-session `/brunch-workspace` command is unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. - Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; a full Brunch-host manual walkthrough remains useful before product signoff because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data. diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 674b9b02..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,271 +0,0 @@ -# FE-744 Scope Cards — Brunch Pi extension shell follow-through - -## Orientation - -- Containing seam: Brunch TUI/workspace-session boot plus the internal Pi extension shell under `src/pi-extensions/brunch/`; the TUI host orchestrates pre-Pi activation, while the extension owns Pi event/command/UI registration. -- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these are slices inside the existing frontier, not new Linear issues or branches. -- Current state: workspace inventory/activation, pure switcher UI, pre-Pi startup gate, coordinator interface cleanup, active-session chrome, and initial extension/workspace-switcher module extraction are committed; `HANDOFF.md` is the only untracked file and is stale once these cards land. -- Main risk: product-shell hardening must not become cosmetic rearrangement; each slice should clarify which Pi UI surface owns which Brunch fact and keep all session mutation behind coordinator activation. - -Pi extension patterns to preserve from the reviewed examples: - -- Extension entrypoints are thin and event-shaped: `index.ts` registers `pi.on(...)`, commands, tools, or UI hooks; private helpers own formatting/state details. -- Use the lightest Pi UI surface: `setStatus` for compact persistent facts, `setWidget` for multi-line contextual facts, `setHeader` for product identity, `setFooter` only when intentionally replacing Pi's footer, `setTitle` for terminal title/working signal. -- `ctx.ui.custom()` components should return typed product data; they should not perform workspace/session effects. -- Any timer or session-bound UI state must clean up on `session_shutdown`. - -Frontier-level obligations every card must preserve: - -- Preserve workspace hierarchy and startup invariant: `.brunch/state.json` is default acceleration, not an implicit resume instruction; no prior transcript or agent loop before explicit workspace-switch activation (R19 / D11-L / D21-L / D22-L / D36-L / I22-L). -- Preserve linear transcript policy: no Pi branch creation/navigation as Brunch product behavior; branch effects remain blocked and transcript readers fail fast on non-linear JSONL (D24-L / I19-L). -- Keep UI/adapters out of session mutation: only `WorkspaceSessionCoordinator` activates decisions, creates/opens Brunch Pi sessions, writes `.brunch/state.json`, or writes `brunch.session_binding` (D21-L / D36-L). -- Keep Brunch chrome product-shaped and activated-session-shaped: no fabricated `unbound` session ids (D35-L). - ---- - -## Card 1 — Split the Brunch Pi extension by Pi surface - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -The Brunch Pi extension entrypoint registers extension behavior through surface-specific private modules. - -### Boundary Crossings - -```text -→ launchPiInteractive() supplies createBrunchExtension(...) as an ExtensionFactory -→ src/pi-extensions/brunch/index.ts wires Pi events -→ chrome/session-binding/branch-policy private modules own their surface logic -→ Pi ExtensionAPI receives the same registered handlers as before -``` - -### Risks and Assumptions - -- RISK: This becomes file shuffling without deleting complexity. → MITIGATION: keep `index.ts` as a thin registration map and move behavior to modules named by Pi surface/responsibility, not generic `utils`. -- RISK: Tests keep importing through `brunch-tui.ts`, hiding extension boundaries. → MITIGATION: test extension formatting/registration through `src/pi-extensions/brunch` exports where possible; leave `brunch-tui` tests for launch orchestration. -- RISK: Splitting modules accidentally changes handler order. → MITIGATION: preserve current registration order: session binding/chrome on `session_start`, binding refresh on pre-agent/assistant start, branch policy cancellation hooks. -- ASSUMPTION: One internal Brunch extension remains the right public factory; separate exported Pi extensions are not needed yet. → VALIDATE: `brunchResourceLoaderOptions()` still receives one Brunch factory and existing behavior tests pass. → memory/SPEC.md D22-L, D35-L - -### Acceptance Criteria - -✓ `pi-extensions/brunch` structure — `index.ts` is a thin entrypoint that composes private surface modules; chrome formatting/rendering, branch policy, and session-boundary binding are no longer all implemented in `index.ts`. - -✓ Extension behavior tests — existing chrome rendering, branch-flow cancellation, and session-boundary binding tests still pass through the exported Brunch extension factory. - -✓ TUI host tests — `brunch-tui.ts` still proves inspect → decision → activate → launch ordering, resource suppression, and explicit extension factory wiring without owning extension handler internals. - -✓ `npm run verify` — full gate passes after the extraction. - -### Verification Approach - -- Inner: refactor-preservation tests — existing extension behavior tests continue to prove the same UI calls and cancellation return values. -- Inner: module-boundary compile check — the TUI host imports only the public Brunch extension factory/state helper, not private surface modules. - -### Cross-cutting obligations - -- Do not use Pi auto-discovery; Brunch still passes explicit `extensionFactories` while `noExtensions: true` remains set. -- Do not add product behavior in this card; it is structural extraction only. -- Preserve replacement-session binding before rendering chrome on `session_start`. - ---- - -## Card 2 — Product-shell chrome surface allocation - -- **Status:** done -- **Weight:** light scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Objective - -Brunch chrome renders each persistent shell fact on one deliberate Pi UI surface instead of repeating metadata across header, widget, status, and footer. - -### Acceptance Criteria - -✓ Chrome formatting tests — header contains product identity plus active spec/session; status contains compact phase/coherence/need summary; widget contains only expanded diagnostic facts; footer is either restored to Pi default or has a narrowly justified Brunch-only purpose. - -✓ Title tests — terminal title remains Brunch-owned and compact, derived from activated workspace state. - -✓ Existing RPC degradation expectations remain true — tests assert only status/widget/title/notify as RPC-visible surfaces; header/footer/working indicator stay TUI-only assumptions. - -✓ Product-shell noise suppression still holds — quiet startup settings, disabled Pi resource categories, and `PI_OFFLINE` default remain covered. - -### Verification Approach - -- Inner: formatting/unit tests for each chrome surface. -- Inner: extension UI call tests proving the intended `setHeader` / `setStatus` / `setWidget` / `setTitle` calls and absence or deliberate use of `setFooter`. -- Middle: existing RPC/chrome expectations — no new fixture should rely on TUI-only header/footer events. - -### Cross-cutting obligations - -- Preserve active-session chrome: no `unbound` fallback. -- Keep Brunch product wrappers as the only downstream API; do not scatter raw `ctx.ui.*` calls outside the Brunch extension surface modules. -- Follow Pi example posture: use `setFooter` only when replacing the whole footer is intentionally the feature; otherwise prefer status/widget/title. - -### Promotion checklist - -- [ ] Does this change a requirement? No. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No; it applies D35-L. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. - ---- - -## Card 3 — In-session workspace switcher command - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -A Brunch-owned slash command opens the reusable workspace switcher inside an active Pi session and switches to the activated workspace decision. - -### Boundary Crossings - -```text -→ Brunch extension registers a product command -→ command handler waits for idle -→ coordinator.inspectWorkspace() -→ ctx.ui.custom(...) renders workspace-switcher component as a typed decision UI -→ coordinator.activateWorkspace(decision) -→ ctx.switchSession(activated.session.file, { withSession }) replaces the Pi session -→ fresh replacement-session context renders Brunch chrome/notification -``` - -### Risks and Assumptions - -- RISK: Old command context/session objects are used after `ctx.switchSession()`. → MITIGATION: follow Pi docs; after replacement, use only the `withSession` context and plain data captured before switching. -- RISK: Command handler bypasses coordinator activation for new-session/new-spec decisions. → MITIGATION: all decisions go through `activateWorkspace()` first; Pi `switchSession()` only attaches the already-activated file to the current runtime. -- RISK: Command name collides with Pi built-ins or implies strict built-in suppression. → MITIGATION: use a Brunch-owned non-conflicting command name and keep command-containment docs honest. -- RISK: Switching to the currently active session causes unnecessary shutdown/rebind. → MITIGATION: either no-op with a notification when activated file equals current file, or prove `switchSession` handles it safely. -- ASSUMPTION: A coordinator-created binding-only session can be attached via `ctx.switchSession()` without needing Pi `ctx.newSession()`. → VALIDATE: unit/fake command tests and, if feasible, a small integration harness using a real coordinator-created session file. → memory/SPEC.md D21-L, D36-L, I8-L - -### Acceptance Criteria - -✓ Brunch extension command registration test — the exported extension registers a non-conflicting Brunch workspace command with a clear description. - -✓ Command handler test — command calls `waitForIdle()`, obtains inventory, renders the switcher through `ctx.ui.custom()`, activates the returned decision through the coordinator, and switches to the activated session file. - -✓ Replacement context test — post-switch notification/chrome update uses only the `withSession` context, not stale pre-switch `ctx` session-bound objects. - -✓ Cancel/needs-human tests — cancel leaves the current session untouched; `needs_human` reports a warning/error and does not switch. - -✓ Store oracle — new-session/new-spec command decisions produce coordinator-owned binding/state effects before Pi runtime switches. - -### Verification Approach - -- Inner: command registration/handler tests with fake ExtensionCommandContext — prove ordering, cancellation, and no stale-context use. -- Middle: coordinator store oracle — prove activated target session binding and current workspace state. -- Outer: manual TUI walkthrough later — invoke the command, switch sessions, confirm chrome/session id changes. - -### Cross-cutting obligations - -- Workspace switcher UI remains pure decision UI; no session mutation in the component. -- Coordinator remains the only owner of activation effects. -- After Pi session replacement, use only `withSession` context for session-bound UI/notifications. -- Do not claim or attempt built-in `/resume` or `/new` override; this is a product command alongside residual Pi built-ins. - ---- - -## Card 4 — Startup pty oracle for no implicit transcript resume - -- **Status:** done -- **Weight:** full scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Target Behavior - -An executable startup oracle proves Brunch TUI startup does not render a prior transcript before an explicit workspace-switch decision. - -### Boundary Crossings - -```text -→ seeded scratch cwd with current session containing unique transcript text -→ Brunch TUI launch under a pty/script harness -→ ANSI-stripped startup capture before resume/open activation -→ oracle assertion on captured text and store state -``` - -### Risks and Assumptions - -- RISK: TUI/pty testing is flaky in CI-like environments. → MITIGATION: make the oracle a runbook/checker script or targeted test that can be run manually, with deterministic seed text and ANSI stripping; do not block normal unit tests if terminal prerequisites are absent unless the project already supports it. -- RISK: The harness accidentally chooses resume and invalidates the claim. → MITIGATION: capture the initial switcher screen before sending any activation keystroke, then separately exercise new-session if automated input is reliable. -- RISK: This becomes only a screenshot test. → MITIGATION: pair terminal capture with store assertions: old transcript file preserved, new binding-only session when new-session path is exercised. -- ASSUMPTION: Existing source launch can be driven through `tsx`/built CLI in a pty enough to capture first paint. → VALIDATE: run locally and document command/output in the runbook or test fixture. → memory/SPEC.md I22-L - -### Acceptance Criteria - -✓ Runbook/checker exists — a documented command seeds a workspace with unique stale transcript text and captures Brunch TUI startup output with ANSI stripped. - -✓ No-stale-transcript assertion — captured startup output before explicit resume/open does not contain the unique stale transcript text. - -✓ Switcher-visible assertion — captured startup output contains Brunch workspace-switcher text or a stable product startup marker. - -✓ Optional new-session assertion when automated input is reliable — choosing new session creates a new binding-only session and preserves the stale transcript file unchanged. - -### Verification Approach - -- Middle: runbook oracle — combines terminal capture and executable text/store postconditions. -- Inner: any helper functions for ANSI stripping/seed setup get unit tests if introduced. -- Outer: manual walkthrough can reuse the same runbook for qualitative startup feel. - -### Cross-cutting obligations - -- This card proves I22-L at the user-facing boundary; it should not change product behavior unless the oracle exposes a real bug. -- Keep fixture/test artifacts out of the repo unless intentionally checked in as runbook scripts. - ---- - -## Card 5 — FE-744 affordance memo reconciliation - -- **Status:** queued -- **Weight:** light scope card -- **Frontier:** `pi-ui-extension-patterns` / FE-744 - -### Objective - -The Pi UI extension patterns memo reflects the Brunch implementation and the relevant Pi example patterns for chrome, typed custom UI, command shutdown, structured output, and title/status surfaces. - -### Acceptance Criteria - -✓ `docs/architecture/pi-ui-extension-patterns.md` records Brunch's internal extension layout and current implementation evidence for header/status/widget/title/footer choices. - -✓ The memo distinguishes implemented Brunch surfaces from source/example-derived Pi affordance evidence: `question`/`questionnaire` typed UI, `shutdown-command`, `structured-output`, `titlebar-spinner`, `custom-header`, `custom-footer`, `status-line`, and `border-status-editor`. - -✓ The memo records remaining FE-744 gaps honestly: residual built-in command exposure, keybinding policy, manual startup pty oracle status, and whether in-session switcher command is implemented. - -✓ No SPEC/PLAN durable semantics change unless implementation revealed a new decision; otherwise this is evidence reconciliation only. - -### Verification Approach - -- Inner: doc review against current code paths and the reviewed Pi examples. -- Middle: traceability check — memo claims match implemented tests/runbook evidence and do not overclaim strict Pi built-in suppression. - -### Cross-cutting obligations - -- Keep FE-744 evidence tiered: Brunch-host proof, Pi source/example evidence, RPC controllability, and manual runbook evidence are not interchangeable. -- Do not let source/example evidence masquerade as Brunch integration proof. - -### Promotion checklist - -- [ ] Does this change a requirement? No. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. - ---- - -## Done / retired context - -The earlier workspace-switcher and extension-organization refactor queues are exhausted and intentionally not repeated here. `HANDOFF.md` should be deleted once these cards are underway or once a newer handoff supersedes it; its startup diagnosis has been absorbed into SPEC/PLAN/code/cards. diff --git a/memory/PLAN.md b/memory/PLAN.md index 4fcd27bd..ab1be20f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -221,7 +221,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment and dynamic chrome proofs landed; current continuation is the workspace-switcher/startup-flow proof under FE-744) +- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; residual work is qualitative/manual product-shell review and any future Pi command-policy follow-up) - **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs without forking Pi or building a parallel rendering substrate, including both downstream elicitation/review affordances and the immediate Brunch-owned startup/session-selection flow. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec/session/entity selection; ambient rendering of the latest `brunch.establishment_offer`; and a reusable workspace switcher whose pure UI returns decisions while the `WorkspaceSessionCoordinator` owns inventory, activation, session binding, and `.brunch/state.json` effects. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. - **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); workspace switcher implementation supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; generic Pi startup resource/update noise is suppressed or documented as residual product-shell risk; the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -229,7 +229,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R19, R20, R21 / D2-L, D11-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L / I18-L, I19-L, I22-L / A10-L, A14-L, A17-L, A18-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Workspace inventory, coordinator activation, pure decision UI, and the pre-Pi startup gate have landed. Next FE-744 slices stay inside this frontier unless `ln-scope` promotes a durable split: product-shell metadata/noise hardening, then in-session switcher command. +- **Current execution pointer:** Workspace inventory, coordinator activation, pure decision UI, pre-Pi startup gate, deliberate chrome surface allocation, in-session `/brunch-workspace` command, startup no-resume pty oracle, and memo reconciliation have landed. Next FE-744 work, if any, should scope qualitative/manual product-shell review or an upstream Pi command/keybinding policy follow-up rather than continuing the exhausted implementation queue. ### flue-pattern-adoption From 6974ee165c12db15a9769390ff772a511fef7929 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:19:50 +0200 Subject: [PATCH 027/164] FE-744: Use default workspace custom UI --- src/brunch-tui.test.ts | 9 ++++++++- src/pi-extensions/brunch/workspace-command.ts | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index f72d7def..b8c05514 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -392,6 +392,7 @@ describe("Brunch TUI boot", () => { it("runs the in-session workspace switch through coordinator activation and replacement context", async () => { const events: string[] = [] + const customOptions: unknown[] = [] const target = readyWorkspace("/tmp/project", "session-target") const replacementUi = fakeUi((method) => events.push(`replacement:${method}`), @@ -403,6 +404,7 @@ describe("Brunch TUI boot", () => { specId: target.spec.id, sessionFile: target.session.file, }, + onCustomOptions: (options) => customOptions.push(options), onEvent: (event) => events.push(event), replacementUi, }) @@ -432,6 +434,7 @@ describe("Brunch TUI boot", () => { "replacement:setTitle", "replacement:notify", ]) + expect(customOptions).toEqual([]) }) it("leaves the current session untouched when workspace switch is cancelled", async () => { @@ -639,6 +642,7 @@ function inventoryWithWorkspace( function fakeCommandContext(options: { currentSessionFile: string decision: Awaited> + onCustomOptions?: (customOptions: unknown) => void onEvent: (event: string) => void replacementUi?: FakeExtensionUi }): ExtensionCommandContext { @@ -654,8 +658,11 @@ function fakeCommandContext(options: { }, ui: { ...ui, - custom: async () => { + custom: async (_component: unknown, customOptions?: unknown) => { options.onEvent("custom") + if (customOptions !== undefined) { + options.onCustomOptions?.(customOptions) + } return options.decision }, }, diff --git a/src/pi-extensions/brunch/workspace-command.ts b/src/pi-extensions/brunch/workspace-command.ts index ff067e52..81529f7b 100644 --- a/src/pi-extensions/brunch/workspace-command.ts +++ b/src/pi-extensions/brunch/workspace-command.ts @@ -42,7 +42,6 @@ export async function runBrunchWorkspaceCommand( const decision = await ctx.ui.custom( (_tui, _theme, _keybindings, done) => createWorkspaceSwitchComponent({ inventory, onDecision: done }), - { overlay: true }, ) const activated = await coordinator.activateWorkspace(decision) From 7ed1a90bf8f0ba44ae11af97a2bb8a953b7a1551 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:20:28 +0200 Subject: [PATCH 028/164] FE-744: Remove empty footer formatter --- src/brunch-tui.test.ts | 2 -- src/brunch-tui.ts | 1 - src/pi-extensions/brunch/chrome.ts | 6 ------ src/pi-extensions/brunch/index.ts | 1 - 4 files changed, 10 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index b8c05514..fef85e41 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -22,7 +22,6 @@ import { BRUNCH_WORKSPACE_COMMAND, chromeStateForWorkspace, createBrunchChromeExtension, - formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, @@ -224,7 +223,6 @@ describe("Brunch TUI boot", () => { expect(formatChromeWidgetLines(state).join("\n")).toContain( "offer: Recommended lens: problem-framing; missing constraints.", ) - expect(formatBrunchChromeFooterLines(state)).toEqual([]) }) it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index ce66be16..842e7126 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -28,7 +28,6 @@ export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, createBrunchChromeExtension, - formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatChromeWidgetLines, renderBrunchChrome, diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts index 18d3ef1c..9e50b2f6 100644 --- a/src/pi-extensions/brunch/chrome.ts +++ b/src/pi-extensions/brunch/chrome.ts @@ -53,12 +53,6 @@ export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { return lines } -export function formatBrunchChromeFooterLines( - _chrome: BrunchChromeState, -): string[] { - return [] -} - export function chromeStateForWorkspace( workspace: WorkspaceSessionReadyState, ): BrunchChromeState { diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts index 985f3afb..9aa1983e 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions/brunch/index.ts @@ -18,7 +18,6 @@ import { export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./branch-policy.js" export { chromeStateForWorkspace, - formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, From 69d683f0a1512c11a58ef7a777ae5d62a90fd900 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:21:15 +0200 Subject: [PATCH 029/164] FE-744: Restore default working indicator --- src/brunch-tui.test.ts | 5 +++-- src/pi-extensions/brunch/chrome.ts | 6 +----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index fef85e41..6b91ad0c 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -208,7 +208,6 @@ describe("Brunch TUI boot", () => { reconciliationNeedCount: 3, latestEstablishmentOfferSummary: "Recommended lens: problem-framing; missing constraints.", - streaming: true, } expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( @@ -257,7 +256,6 @@ describe("Brunch TUI boot", () => { reconcilerStatus: "idle", reconciliationNeedCount: 0, latestEstablishmentOfferSummary: null, - streaming: false, }) expect(calls.map((call) => call.method)).toEqual([ @@ -285,6 +283,9 @@ describe("Brunch TUI boot", () => { ], { placement: "aboveEditor" }, ]) + expect( + calls.find((call) => call.method === "setWorkingIndicator")?.args, + ).toEqual([undefined]) expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ "brunch — Spec One", ]) diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts index 9e50b2f6..aaa306cf 100644 --- a/src/pi-extensions/brunch/chrome.ts +++ b/src/pi-extensions/brunch/chrome.ts @@ -22,7 +22,6 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { reconcilerStatus: BrunchChromeWorkerStatus reconciliationNeedCount: number latestEstablishmentOfferSummary: string | null - streaming: boolean } export type BrunchChromeUi = Pick @@ -70,7 +69,6 @@ export function chromeStateForWorkspace( reconcilerStatus: "idle", reconciliationNeedCount: 0, latestEstablishmentOfferSummary: null, - streaming: false, } } @@ -87,9 +85,7 @@ export function renderBrunchChrome( ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { placement: "aboveEditor", }) - ui.setWorkingIndicator( - chrome.streaming ? { frames: ["●"], intervalMs: 120 } : undefined, - ) + ui.setWorkingIndicator(undefined) ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } From 17c20879cbee3e5b7c25da6f50cc6045578877f2 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:21:43 +0200 Subject: [PATCH 030/164] FE-744: Document simplified custom UI posture --- docs/architecture/pi-ui-extension-patterns.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index e75135be..78209f97 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, optional `setWorkingIndicator`, and terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, restores the default working indicator with `setWorkingIndicator(undefined)`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -127,14 +127,14 @@ The Brunch extension entrypoint is intentionally a registration map. It composes - title: compact Brunch-owned terminal title derived from activated workspace state; - footer: restored to Pi default via `setFooter(undefined)` because Brunch does not currently need to replace the whole footer. -The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`, `●`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. Brunch currently restores Pi's default footer and working indicator instead of carrying empty/custom chrome abstractions. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: | Scenario | Result | Evidence | | --- | --- | --- | | Idle TUI mount | Header, status, diagnostic widget, title, and default-footer restoration are called from one snapshot; raw TUI transcript shows Brunch header/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | -| Streaming/progress update | Wrapper formats streaming/worker state deterministically; raw RPC extension command updates status/widget to `stage: streaming`, `lens: problem-framing`, `needs: 3`. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | +| Streaming/progress update | Wrapper formats stage/worker state deterministically in status/widget; Brunch leaves the interactive working indicator on Pi defaults until a concrete side-task/reviewer spinner is product-proven. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | | `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | | Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The Brunch workspace command activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | @@ -169,7 +169,7 @@ Startup now runs through Brunch-owned inventory and activation before Pi `Intera The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-switcher markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. -The in-session command is product-named `/brunch-workspace`. Its handler waits for idle, inspects inventory, renders the same typed workspace-switcher component with `ctx.ui.custom()`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. +The in-session command is product-named `/brunch-workspace`. Its handler waits for idle, inspects inventory, renders the same typed workspace-switcher component with the default `ctx.ui.custom()` component-replacement flow, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. Overlay/modal custom-UI patterns remain deferred to later review-set, orientation, or picker surfaces only when a concrete product interaction needs them. ## Pi example evidence not yet Brunch integration proof @@ -180,7 +180,7 @@ Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance desig | `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | | `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | | `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-switcher proof. | -| `titlebar-spinner` / working indicator examples | Pi example plus Brunch wrapper tests | Brunch exposes `streaming` to `setWorkingIndicator`, but no live side-task/reviewer spinner is product-proven yet. | +| `titlebar-spinner` / working indicator examples | Pi example plus Brunch wrapper tests | Brunch restores Pi's default working indicator; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | | `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | | `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | @@ -223,7 +223,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom - `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. - M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. - M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. -- Future switcher/review/elicitation commands should follow `/brunch-workspace`: product-owned names, typed `ctx.ui.custom()` decisions, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. +- Future switcher/review/elicitation commands should follow `/brunch-workspace`: product-owned names, typed default `ctx.ui.custom()` decision components unless richer modal behavior is specifically needed, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. - A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. ## Open evidence gaps From 1b4a4eb2759d2770e15eb347d06278115f9a7966 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:28:11 +0200 Subject: [PATCH 031/164] FE-744: Delete inert working indicator seam --- docs/architecture/pi-ui-extension-patterns.md | 6 +- memory/CARDS.md | 91 +++++++++++++++++++ src/brunch-tui.test.ts | 8 +- src/pi-extensions/brunch/chrome.ts | 3 +- 4 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 78209f97..07e3144f 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, restores the default working indicator with `setWorkingIndicator(undefined)`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -127,7 +127,7 @@ The Brunch extension entrypoint is intentionally a registration map. It composes - title: compact Brunch-owned terminal title derived from activated workspace state; - footer: restored to Pi default via `setFooter(undefined)` because Brunch does not currently need to replace the whole footer. -The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. Brunch currently restores Pi's default footer and working indicator instead of carrying empty/custom chrome abstractions. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. Brunch currently restores Pi's default footer and leaves Pi's working indicator untouched instead of carrying empty/custom chrome abstractions. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: @@ -180,7 +180,7 @@ Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance desig | `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | | `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | | `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-switcher proof. | -| `titlebar-spinner` / working indicator examples | Pi example plus Brunch wrapper tests | Brunch restores Pi's default working indicator; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | +| `titlebar-spinner` / working indicator examples | Pi example evidence only | Brunch leaves Pi's working indicator untouched; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | | `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | | `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..ef41848c --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,91 @@ +# FE-744 cleanup cards + +## Orientation + +- Containing seam: Brunch's internal Pi extension shell (`src/pi-extensions/brunch/`) and TUI launcher wiring. +- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; this is cleanup inside the existing branch/issue, not a new frontier. +- Volatile handoff state: absorbed and deleted; latest builder pass removed overlay usage, empty footer formatting, and custom spinner behavior. +- Main risk: preserving product-shell behavior while deleting inert extension seams; do not widen into new custom UI patterns or upstream Pi command policy work. + +Frontier obligations to preserve: + +- Brunch chrome/status affordances route through Brunch-owned wrappers rather than scattered raw `ctx.ui.*` calls. +- Workspace switcher UI remains pure decision rendering; coordinator activation owns session/state effects. +- Replacement-session work after `ctx.switchSession()` uses only the `withSession` replacement context. +- Exact built-in Pi command/keybinding suppression remains a documented upstream policy gap, not local workaround code. + +## Card 1 — Delete inert working-indicator seam + +Status: done + +### Objective + +Remove Brunch's no-op working-indicator reset from the chrome wrapper so the current shell only owns Pi UI surfaces with product behavior. + +### Acceptance Criteria + +✓ `renderBrunchChrome` no longer requires or calls `setWorkingIndicator`. +✓ Brunch chrome tests still prove header, footer restoration, status, widget, and title projection from one product-state snapshot. +✓ The Pi UI extension memo no longer claims Brunch tests or wrapper behavior around working-indicator reset; it only records custom spinner patterns as deferred future evidence. + +### Verification Approach + +- Inner: targeted unit tests plus `npm run fix` — proves the wrapper surface and exports compile after deletion. +- Gate: `npm run verify` before commit. + +### Cross-cutting obligations + +- Keep `renderBrunchChrome` as the sole Brunch chrome projection API for current downstream TUI affordances. +- Do not add a replacement spinner abstraction until a concrete side-task/reviewer spinner is product-proven. + +### Promotion checklist + +- [ ] Does this change a requirement? +- [ ] Does this create, retire, or invalidate an assumption? +- [ ] Does this make or reverse a non-trivial design decision? +- [ ] Does this establish a new seam-level invariant? +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? +- [ ] Does it cross more than two major seams? +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? +- [ ] Can you not name the containing seam or current rationale from the live docs? + +Result: stays light. + +## Card 2 — Rename extension shell and make workspace command dependency explicit + +Status: next + +### Objective + +Make the Brunch Pi extension factory describe the full extension shell and make workspace-command registration impossible to call with an absent coordinator. + +### Acceptance Criteria + +✓ The exported/internal factory name reflects that it registers the Brunch Pi extension shell, not chrome only. +✓ Workspace command registration accepts a required coordinator and contains no optional coordinator branch or non-null assertion. +✓ Existing tests still prove session-start chrome binding, branch-flow cancellation, command registration, workspace activation, and replacement-context use. +✓ Public re-exports and TUI launcher imports use the new name consistently, with no stale `createBrunchChromeExtension` references. + +### Verification Approach + +- Inner: targeted search/tests plus `npm run fix` — proves naming and type-contract cleanup across imports/exports. +- Gate: `npm run verify` before commit. + +### Cross-cutting obligations + +- Preserve the internal extension layout by Pi surface/responsibility. +- Keep `/brunch-workspace` product-owned and routed through coordinator activation before any Pi session replacement. +- Do not use extension command collisions as a built-in command override mechanism. + +### Promotion checklist + +- [ ] Does this change a requirement? +- [ ] Does this create, retire, or invalidate an assumption? +- [ ] Does this make or reverse a non-trivial design decision? +- [ ] Does this establish a new seam-level invariant? +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? +- [ ] Does it cross more than two major seams? +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? +- [ ] Can you not name the containing seam or current rationale from the live docs? + +Result: stays light. diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 6b91ad0c..9772b4fe 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -235,8 +235,7 @@ describe("Brunch TUI boot", () => { calls.push({ method: "setStatus", args }), setWidget: (...args: unknown[]) => calls.push({ method: "setWidget", args }), - setWorkingIndicator: (...args: unknown[]) => - calls.push({ method: "setWorkingIndicator", args }), + setWorkingIndicator: (_options) => {}, setTitle: (...args: unknown[]) => calls.push({ method: "setTitle", args }), notify: (_message: string, _type?: "info" | "warning" | "error") => {}, @@ -263,7 +262,6 @@ describe("Brunch TUI boot", () => { "setFooter", "setStatus", "setWidget", - "setWorkingIndicator", "setTitle", ]) expect(calls.find((call) => call.method === "setFooter")?.args).toEqual([ @@ -283,9 +281,6 @@ describe("Brunch TUI boot", () => { ], { placement: "aboveEditor" }, ]) - expect( - calls.find((call) => call.method === "setWorkingIndicator")?.args, - ).toEqual([undefined]) expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ "brunch — Spec One", ]) @@ -429,7 +424,6 @@ describe("Brunch TUI boot", () => { "replacement:setFooter", "replacement:setStatus", "replacement:setWidget", - "replacement:setWorkingIndicator", "replacement:setTitle", "replacement:notify", ]) diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/brunch/chrome.ts index aaa306cf..8d942455 100644 --- a/src/pi-extensions/brunch/chrome.ts +++ b/src/pi-extensions/brunch/chrome.ts @@ -24,7 +24,7 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { latestEstablishmentOfferSummary: string | null } -export type BrunchChromeUi = Pick +export type BrunchChromeUi = Pick export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, @@ -85,7 +85,6 @@ export function renderBrunchChrome( ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { placement: "aboveEditor", }) - ui.setWorkingIndicator(undefined) ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } From a0be85045ff119b90b3e9cb6d5e62adb2dd248d3 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:30:11 +0200 Subject: [PATCH 032/164] FE-744: Make Brunch extension shell explicit --- memory/CARDS.md | 91 ------------------- src/brunch-tui.test.ts | 20 +++- src/brunch-tui.ts | 6 +- src/pi-extensions/brunch/index.ts | 6 +- src/pi-extensions/brunch/workspace-command.ts | 10 +- 5 files changed, 25 insertions(+), 108 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index ef41848c..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,91 +0,0 @@ -# FE-744 cleanup cards - -## Orientation - -- Containing seam: Brunch's internal Pi extension shell (`src/pi-extensions/brunch/`) and TUI launcher wiring. -- Frontier item: `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; this is cleanup inside the existing branch/issue, not a new frontier. -- Volatile handoff state: absorbed and deleted; latest builder pass removed overlay usage, empty footer formatting, and custom spinner behavior. -- Main risk: preserving product-shell behavior while deleting inert extension seams; do not widen into new custom UI patterns or upstream Pi command policy work. - -Frontier obligations to preserve: - -- Brunch chrome/status affordances route through Brunch-owned wrappers rather than scattered raw `ctx.ui.*` calls. -- Workspace switcher UI remains pure decision rendering; coordinator activation owns session/state effects. -- Replacement-session work after `ctx.switchSession()` uses only the `withSession` replacement context. -- Exact built-in Pi command/keybinding suppression remains a documented upstream policy gap, not local workaround code. - -## Card 1 — Delete inert working-indicator seam - -Status: done - -### Objective - -Remove Brunch's no-op working-indicator reset from the chrome wrapper so the current shell only owns Pi UI surfaces with product behavior. - -### Acceptance Criteria - -✓ `renderBrunchChrome` no longer requires or calls `setWorkingIndicator`. -✓ Brunch chrome tests still prove header, footer restoration, status, widget, and title projection from one product-state snapshot. -✓ The Pi UI extension memo no longer claims Brunch tests or wrapper behavior around working-indicator reset; it only records custom spinner patterns as deferred future evidence. - -### Verification Approach - -- Inner: targeted unit tests plus `npm run fix` — proves the wrapper surface and exports compile after deletion. -- Gate: `npm run verify` before commit. - -### Cross-cutting obligations - -- Keep `renderBrunchChrome` as the sole Brunch chrome projection API for current downstream TUI affordances. -- Do not add a replacement spinner abstraction until a concrete side-task/reviewer spinner is product-proven. - -### Promotion checklist - -- [ ] Does this change a requirement? -- [ ] Does this create, retire, or invalidate an assumption? -- [ ] Does this make or reverse a non-trivial design decision? -- [ ] Does this establish a new seam-level invariant? -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? -- [ ] Does it cross more than two major seams? -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? -- [ ] Can you not name the containing seam or current rationale from the live docs? - -Result: stays light. - -## Card 2 — Rename extension shell and make workspace command dependency explicit - -Status: next - -### Objective - -Make the Brunch Pi extension factory describe the full extension shell and make workspace-command registration impossible to call with an absent coordinator. - -### Acceptance Criteria - -✓ The exported/internal factory name reflects that it registers the Brunch Pi extension shell, not chrome only. -✓ Workspace command registration accepts a required coordinator and contains no optional coordinator branch or non-null assertion. -✓ Existing tests still prove session-start chrome binding, branch-flow cancellation, command registration, workspace activation, and replacement-context use. -✓ Public re-exports and TUI launcher imports use the new name consistently, with no stale `createBrunchChromeExtension` references. - -### Verification Approach - -- Inner: targeted search/tests plus `npm run fix` — proves naming and type-contract cleanup across imports/exports. -- Gate: `npm run verify` before commit. - -### Cross-cutting obligations - -- Preserve the internal extension layout by Pi surface/responsibility. -- Keep `/brunch-workspace` product-owned and routed through coordinator activation before any Pi session replacement. -- Do not use extension command collisions as a built-in command override mechanism. - -### Promotion checklist - -- [ ] Does this change a requirement? -- [ ] Does this create, retire, or invalidate an assumption? -- [ ] Does this make or reverse a non-trivial design decision? -- [ ] Does this establish a new seam-level invariant? -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? -- [ ] Does it cross more than two major seams? -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? -- [ ] Can you not name the containing seam or current rationale from the live docs? - -Result: stays light. diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 9772b4fe..b2ba6812 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -21,7 +21,7 @@ import { import { BRUNCH_WORKSPACE_COMMAND, chromeStateForWorkspace, - createBrunchChromeExtension, + createBrunchPiExtensionShell, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, @@ -319,11 +319,12 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => Promise) | undefined - createBrunchChromeExtension( + createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), (sessionManager) => { boundSessionIds.push(sessionManager.getSessionId()) }, + { coordinator: noOpWorkspaceCoordinator(cwd) }, )({ on: (event: string, handler: typeof sessionStart) => { if (event === "session_start") { @@ -336,6 +337,7 @@ describe("Brunch TUI boot", () => { messageStart = handler } }, + registerCommand: (_name: string, _options: unknown) => {}, } as never) await sessionStart?.({}, ctx) @@ -364,7 +366,7 @@ describe("Brunch TUI boot", () => { const commands = new Map>() - createBrunchChromeExtension( + createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), undefined, { @@ -509,8 +511,10 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => unknown>() - createBrunchChromeExtension( + createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), + undefined, + { coordinator: noOpWorkspaceCoordinator(cwd) }, )({ on: ( event: string, @@ -518,6 +522,7 @@ describe("Brunch TUI boot", () => { ) => { handlers.set(event, handler) }, + registerCommand: (_name: string, _options: unknown) => {}, } as never) await expect( @@ -632,6 +637,13 @@ function inventoryWithWorkspace( } } +function noOpWorkspaceCoordinator(cwd: string) { + return { + inspectWorkspace: async () => emptyInventory(cwd), + activateWorkspace: async () => readyWorkspace(cwd, "session-1"), + } +} + function fakeCommandContext(options: { currentSessionFile: string decision: Awaited> diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 842e7126..6200e2b9 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -21,13 +21,13 @@ import { } from "./workspace-session-coordinator.js" import { chromeStateForWorkspace, - createBrunchChromeExtension, + createBrunchPiExtensionShell, } from "./pi-extensions/brunch/index.js" import { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, - createBrunchChromeExtension, + createBrunchPiExtensionShell, formatBrunchChromeHeaderLines, formatChromeWidgetLines, renderBrunchChrome, @@ -109,7 +109,7 @@ async function launchPiInteractive({ agentDir: runtimeAgentDir, settingsManager, resourceLoaderOptions: brunchResourceLoaderOptions([ - createBrunchChromeExtension( + createBrunchPiExtensionShell( chromeStateForWorkspace(workspace), async (sessionManager) => { await coordinator.bindCurrentSpecToReplacementSession( diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions/brunch/index.ts index 9aa1983e..291310ae 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions/brunch/index.ts @@ -40,10 +40,10 @@ export { type BrunchWorkspaceCommandOptions, } from "./workspace-command.js" -export function createBrunchChromeExtension( +export function createBrunchPiExtensionShell( chrome: BrunchChromeState, - onSessionBoundary?: BrunchSessionBoundaryHandler, - options: BrunchWorkspaceCommandOptions = {}, + onSessionBoundary: BrunchSessionBoundaryHandler | undefined, + options: BrunchWorkspaceCommandOptions, ): ExtensionFactory { return (pi) => { pi.on("session_start", async (_event, ctx) => { diff --git a/src/pi-extensions/brunch/workspace-command.ts b/src/pi-extensions/brunch/workspace-command.ts index 81529f7b..4b610ecf 100644 --- a/src/pi-extensions/brunch/workspace-command.ts +++ b/src/pi-extensions/brunch/workspace-command.ts @@ -14,21 +14,17 @@ import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" export interface BrunchWorkspaceCommandOptions { - coordinator?: WorkspaceSwitchCoordinator + coordinator: WorkspaceSwitchCoordinator } export function registerBrunchWorkspaceCommand( pi: ExtensionAPI, - options: BrunchWorkspaceCommandOptions = {}, + { coordinator }: BrunchWorkspaceCommandOptions, ): void { - if (!options.coordinator) { - return - } - pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { description: "Switch Brunch spec/session workspace", handler: async (_args, ctx) => { - await runBrunchWorkspaceCommand(ctx, options.coordinator!) + await runBrunchWorkspaceCommand(ctx, coordinator) }, }) } From 4b280baad24a52beefdc752625ab871e474db4c7 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:51:32 +0200 Subject: [PATCH 033/164] do docs sync, to capture remaining critical UI issues --- ...-ui-extension-patterns-provisional-plan.md | 613 +--- docs/architecture/pi-ui-extension-patterns.md | 24 + docs/reference/pi-extensions.md | 2596 +++++++++++++++++ memory/PLAN.md | 18 +- memory/SPEC.md | 11 +- 5 files changed, 2700 insertions(+), 562 deletions(-) create mode 100644 docs/reference/pi-extensions.md diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md index 0faa4d8c..cc5a9b5a 100644 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -1,575 +1,88 @@ -# Pi UI Extension Patterns — Provisional Handoff Plan +# Pi UI Extension Patterns — Offer-First Custom UI Working Plan -> Generated by `ln-handoff` at 2026-05-22T11:33:57Z. Read this file to resume `pi-ui-extension-patterns` work. -> This file is volatile transfer state for a spike-shaped frontier, not canonical product truth. Reconcile durable conclusions into `memory/SPEC.md`, `memory/PLAN.md`, and/or `docs/architecture/pi-seam-extensions.md` once the spike produces evidence. -> -> **Status update (2026-05-22):** The restored body matches the previously read provisional handoff content: it contains the same expanded need inventory, source-audit findings, exploration groups A–G, proposed matrix, repo-state snapshot, and resume prompt that were visible before deletion. Since then, Cards 1–2 have landed on FE-744 / `ln/fe-744-pi-ui-extension-patterns`; durable findings now live in `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, and `memory/PLAN.md`. Keep this file only as the remaining future-affordance inventory and scoping aid. +This file is a trimmed working inventory for the remaining FE-744 gap. It is not canonical product contract; durable conclusions belong in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md`. -## Goal +## Why this is still live -Prove which Pi extension and TUI customization seams Brunch can use to become an opinionated elicitation product shell — including narrowed commands, Brunch-owned chrome, dynamic background status, structured prompts, review-set interactions, and fixture/RPC controllability — without forking Pi or exposing Pi's generic extension system to Brunch users. +Command containment, Brunch chrome, startup no-resume, and `/brunch-workspace` are proven enough for now. The unresolved POC seam is different: -## Session State +> Brunch sessions must work offer-first: a system/assistant-originated structured offer should act like the assistant turn, render as custom UI in place of the default input surface, and persist the user's structured response before the next agent turn. -- **Originally captured by**: `ln-handoff` after `ln-consult` classified this as the existing `pi-ui-extension-patterns` parallel frontier. -- **Current branch/issue**: FE-744 / `ln/fe-744-pi-ui-extension-patterns`, tracked in Graphite off `ln/fe-737-web-shell` and parallel to `ln/fe-741-graph-data-plane`. -- **Completed since original handoff**: - - Card 1 — command containment feasibility: landed in commit `4b1c2604`; established `A18-L`/`D34-L` and the command-containment matrix. - - Card 2 — dynamic Brunch chrome proof: landed in commit `233c2cd1`; added `renderBrunchChrome` and established `D35-L`; validated `A10-L`. -- **Current flow position**: after two `ln-build` cards. Next step is not the original first scope; use this doc to scope remaining affordance work (structured prompts, overlays, action buttons, pickers, message rendering, RPC controllability) or to prepare a product-shell review of residual built-in command exposure. -- **Retirement posture**: this file should no longer describe completed command/chrome work as future work; completed results are summarized below and authoritative detail is in `docs/architecture/pi-ui-extension-patterns.md`. +This is not generic UI polish. It is the mechanism behind elicitation-first sessions, typed responses, review-cycle decisions, and fixture-controllable prompt/response exchanges. -## Current canonical context +## Pi evidence already relevant -- `memory/PLAN.md` active frontier is `graph-data-plane` (M4), but `pi-ui-extension-patterns` is explicitly listed under **Parallel / Low-conflict** and should inform M5/M6/M7. -- `pi-ui-extension-patterns` objective in PLAN: prove Pi extension seams Brunch needs for lens/review-set UX: custom slash commands, styled persistent chrome, overlays, multi-choice prompts, action buttons, picker modals, ambient establishment-offer rendering, and agent-as-user driver controllability. -- `memory/SPEC.md` contains the durable stance that Brunch uses Pi internally but hides Pi's generic extension surface from Brunch users, preserves linear transcript policy, keeps establishment offers orientation-first, and routes writes through `CommandExecutor`. -- Durable updates since this plan was written: - - `A18-L` remains open: autocomplete hiding plus effect blocking may be sufficient for the POC shell, but product review must accept exact built-in residual exposure. - - `D34-L` records that command containment separates visibility suppression from effect blocking; strict exact built-in suppression requires a Pi command/keybinding policy seam. - - `A10-L` is validated: persistent/dynamic TUI chrome can be mounted without forking Pi. - - `D35-L` records that dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives; downstream affordances should use Brunch wrappers, not raw `ctx.ui.*` calls. -- No `HANDOFF.md` exists; `memory/CARDS.md` was exhausted and retired after Cards 1–2. +- `docs/usage.md`: the editor can be replaced temporarily by built-in UI or custom extension UI. +- `docs/tui.md`: `ctx.ui.custom()` can replace the editor area with a custom component and return typed data; overlays are optional, not required. +- `docs/tui.md` Pattern 7: `ctx.ui.setEditorComponent()` can replace the main input editor with a custom editor implementation. +- `examples/extensions/question.ts`: single-choice options plus a "Type something" escape hatch using `ctx.ui.custom()` and `Editor`. +- `examples/extensions/questionnaire.ts`: multi-question/tabbed choice UI with optional custom text answers. +- `examples/extensions/message-renderer.ts`: `registerMessageRenderer()` displays custom messages, but display rendering alone does not collect a response. +- `docs/rpc.md` / extension docs: `ctx.ui.custom()` is TUI-only/degraded in RPC, so semantic pending-offer state must have an RPC/web response path independent of the TUI component. -## In-flight work +## Target seam to prove -### A. Expanded need inventory +### Offer-first custom interaction loop -| Need | Brunch purpose | Pi seams to probe | Known risk / question | -| --- | --- | --- | --- | -| Custom slash commands | `/lens`, `/spec`, user-invoked orientation views, review actions, debug/demo commands | `pi.registerCommand`, `getArgumentCompletions`, `ctx.waitForIdle`, extension command lifecycle | Writes must route through Brunch handlers/`CommandExecutor`; built-in command collisions do not override Pi built-ins. | -| Suppression of standard slash commands | Brunch should feel like an opinionated product, not a general Pi shell; hide or block commands Brunch does not support | autocomplete wrapping, settings (`enableSkillCommands`), lifecycle cancellation hooks, input interception, possible upstream Pi API | Autocomplete suppression and execution suppression differ. Built-in commands are handled by `InteractiveMode` before extension `input` events. Full allowlisting likely needs a Pi change or Brunch-owned wrapper. | -| Styled persistent chrome | Always-visible cwd/spec/session/phase/lens/coherence/status summary | `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, theme colors/glyphs | Need to know whether replacing Pi header/footer and widgets is enough to make the shell feel Brunch-owned even if some built-ins remain technically callable. | -| Dynamic background-status chrome | Observer/reviewer/reconciler running; N reconciliation needs; N observer jobs; new stage/mode available | `setStatus`, `setWidget`, event hooks, Brunch state renderer, maybe `pi.events` | Must update while idle and during streaming without corrupting UI; must survive reload/session replacement via state reconstruction. | -| Ambient establishment-offer rendering | Show latest `brunch.establishment_offer` as orientation, not a default lens menu | `setWidget`, `registerMessageRenderer`, transcript custom entries | Must preserve D32-L: orientation-first, user-invoked expanded view, not exhaustive persistent next-action menu. | -| Modal/popover overlays | Proposal review, orientation inspection, spec/entity pickers | `ctx.ui.custom(..., { overlay: true })`, overlay options, TUI components | Overlay stacking/priority, visual quality, cancellation semantics. | -| Radio / checkbox / select prompts | Structured elicitation answers and authority confirmations | `ctx.ui.select`, `ctx.ui.custom()`, `SelectList`, custom checkbox/radio component | Built-in `select` is single-choice; checkbox/freeform-plus-choice likely need custom component. | -| Freeform-plus-choice prompt | User can pick an option or write an escape-hatch answer | `ctx.ui.custom()`, `Editor`, questionnaire pattern | Must capture durable `brunch.offer_response`, not ephemeral UI-only state. | -| Clickable/navigable action buttons | Accept / request changes / reject review-set proposals | keyboard-navigable custom component, action bar, maybe mouse support if available | Clarify whether “clickable” is keyboard-only in TUI. Mouse support should be proven or explicitly left to web. | -| Picker/list-selection modals | Spec switching, entity selection, mention target selection | `SelectList`, `ctx.ui.custom`, `addAutocompleteProvider` | Spec switching must preserve `cwd → spec → session`; mentions must rewrite to stable IDs. | -| Message rendering for custom entries | Display offers, lens hints, review proposals, side-task results, world updates | `pi.registerMessageRenderer`, `pi.sendMessage`, `pi.appendEntry` | Need explicit context-participating vs persistence-only distinction. | -| Tool rendering / graph tool affordances | Show graph mutations and dry-run validation clearly | custom tool `renderCall`/`renderResult`, `renderShell` | Useful for M5 graph tools; must not obscure command-result discriminants. | -| RPC controllability of UI | Agent-as-user driver exercises choices/actions in fixtures | RPC extension UI protocol for built-in dialogs; Brunch RPC method families for custom affordances | `ctx.ui.custom()` returns `undefined` in RPC mode, so rich custom components are not automatically fixture-controllable. Biggest cross-cutting risk after command suppression. | -| Branch-flow blocking | Enforce Brunch linear transcript policy | `session_before_tree`, `session_before_fork`, `session_before_switch` | Already partly proven in M3; prototypes must not regress I19-L. | -| Prompt/tool/lens switching | Lenses as system prompt + active tools + context projection | `before_agent_start`, `context`, `pi.setActiveTools`, `registerTool` | Pi extensions cannot be cleanly unregistered; Brunch should register once and gate with active tools/session state. | -| Autocomplete providers | `#` graph mentions; slash arg completions | `ctx.ui.addAutocompleteProvider`, command completions | Need stable ID insertion, not title anchoring. | +1. Brunch appends or sends a structured custom message/entry representing an unresolved offer, for example `brunch.offer` / `brunch.establishment_offer` / `brunch.review_set_proposal`. +2. The custom entry is visible in the transcript through a message renderer or transcript row. +3. While that offer is unresolved, Brunch replaces the default input surface with an offer-response UI. +4. The response UI supports the POC interaction kernel: + - single-choice selection, + - multi-choice selection, + - optional freeform additional input, + - cancel/skip where allowed. +5. The user's response is persisted as a structured custom entry, not just returned from ephemeral UI. +6. The response either triggers the next agent turn or is available to `prepareNextTurn` / the next prompt path as the user's response to the offer. +7. RPC/web answer the same semantic pending offer through product methods or supported dialog fallbacks; they do not depend on TUI-only `ctx.ui.custom()`. -### B. Source audit findings already gathered +## Active slice candidate -#### B1. Built-in commands are hardcoded and always included in base autocomplete +**Name:** Offer-first custom UI loop -Evidence: +**Goal:** Prove that a transcript-native unresolved offer can replace ambient free input with a typed custom response surface and persist the response as session truth. -- `~/Clones/earendil-works/pi/packages/coding-agent/src/core/slash-commands.ts` defines `BUILTIN_SLASH_COMMANDS` with: - - `/settings`, `/model`, `/scoped-models`, `/export`, `/import`, `/share`, `/copy`, `/name`, `/session`, `/changelog`, `/hotkeys`, `/fork`, `/clone`, `/tree`, `/login`, `/logout`, `/new`, `/compact`, `/resume`, `/reload`, `/quit`. -- `~/Clones/earendil-works/pi/packages/coding-agent/src/modes/interactive/interactive-mode.ts` imports `BUILTIN_SLASH_COMMANDS` and builds autocomplete from them in `createBaseAutocompleteProvider()`. -- The same autocomplete method appends prompt-template commands, extension commands, and skill commands when `settingsManager.getEnableSkillCommands()` is true. +**Likely implementation shape:** -Implication: +- Define a minimal offer payload type with `id`, `lens`, prompt text, response mode (`single | multiple | freeform-plus-choice`), options, and response policy. +- Add a Brunch-owned TUI helper, e.g. `requestOfferResponse(ctx, offer)`, modeled on Pi's `question.ts` / `questionnaire.ts` examples. +- Add a renderer for the offer custom entry so the assistant/system offer appears as the current prompt in transcript history. +- Add response persistence as a Brunch custom entry, e.g. `brunch.offer_response`, tied to the offer id. +- For RPC/fixture paths, expose a product method or supported built-in dialog fallback that submits the same response payload. -- Autocomplete narrowing is probably feasible by wrapping the autocomplete provider and/or disabling skill commands, but built-in commands are a default base layer. -- Need to test whether `ctx.ui.addAutocompleteProvider()` can filter slash suggestions after delegating to the base provider. +**Acceptance:** -#### B2. Built-in command execution happens before extension `input` interception +- A fixture/demo session can start with no ambient user prompt and present an assistant/system offer first. +- The default freeform editor is replaced while the offer is pending. +- The user can choose one option, choose multiple options, or choose/type optional additional text depending on offer mode. +- The response persists in Pi JSONL as a structured Brunch custom entry linked to the offer id. +- Elicitation exchange projection treats the offer entry as the prompt side and the response entry as the response side. +- RPC/fixture driver can answer the offer through a semantic path even if rich TUI custom UI is unavailable. +- No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. -Evidence: +## Residual catalog still carried forward -- `InteractiveMode.setupEditorSubmitHandler()` checks exact command strings directly (`/settings`, `/model`, `/fork`, `/tree`, `/new`, `/compact`, `/reload`, `/resume`, `/quit`, etc.) before normal message submission. -- `AgentSession.prompt()` executes extension commands first, then emits extension `input`, then expands skill/prompt-template commands. -- Since many built-ins are handled in `InteractiveMode` before `AgentSession.prompt()`, extension `input` cannot be relied upon to block built-in interactive commands. - -Implication: - -- Full built-in command allowlisting is not currently a clean extension-level capability. -- Some effects can be cancelled by lifecycle events (`session_before_fork`, `session_before_tree`, `session_before_switch`, `session_before_compact`), but that is not the same as suppressing command availability or intercepting execution. -- One spike output should be a minimal Pi upstream/API ask if Brunch needs true command policy. - -#### B3. Extension command collisions do not override built-ins - -Evidence: - -- `InteractiveMode.getBuiltInCommandConflictDiagnostics()` detects extension commands whose names conflict with built-ins and warns/skips in autocomplete or suffixes invocation names. -- `ExtensionRunner.resolveRegisteredCommands()` suffixes duplicate extension command names as `name:1`, `name:2`, etc. - -Implication: - -- Brunch cannot override `/model` or `/settings` by registering an extension command of the same name. -- Brunch commands should use product-specific names or rely on a future command policy hook. - -#### B4. Chrome replacement/update seams are proven for the current POC wrapper - -Evidence from docs and examples: - -- `custom-header.ts` uses `ctx.ui.setHeader(...)` to replace the built-in header. -- `custom-footer.ts` uses `ctx.ui.setFooter(...)` and can access `footerData.getGitBranch()` and extension statuses. -- `status-line.ts` uses `ctx.ui.setStatus(...)` from `session_start`, `turn_start`, and `turn_end`. -- `widget-placement.ts` uses `ctx.ui.setWidget(...)` above and below the editor. -- `working-indicator.ts` uses `setWorkingIndicator` and status updates. -- `hidden-thinking-label.ts` customizes the hidden thinking label. -- `InteractiveMode.init()` has built-in header construction, but extension `setHeader` exists as a replacement seam. -- Brunch now has `renderBrunchChrome(ctx.ui, state)` in `src/brunch-tui.ts`; tests prove it drives header/footer/status/widget/working-indicator/title from one product-state snapshot. -- A raw TUI transcript proof showed Brunch header/footer/widget text rendered in a live Pi TUI. A raw RPC probe showed status/widget/title are observable over RPC while header/footer/working-indicator are no-ops. - -Implication: - -- Brunch chrome replacement/dynamic status is proven enough for downstream M5/M6/M7 wrappers to build on. -- A Brunch UI state renderer should continue to concentrate calls to `setHeader`, `setFooter`, `setStatus`, and `setWidget` rather than scattering raw Pi UI calls across subsystems. -- Remaining chrome evidence gap: full Brunch-host manual walkthrough with real coordinator-derived graph/lens/coherence data, not just unit tests and temporary raw Pi harness probes. - -#### B5. Custom UI is powerful in TUI but degraded in RPC - -Evidence: - -- `docs/extensions.md` and `docs/tui.md` describe `ctx.ui.custom()`, overlay mode, `overlayOptions`, and custom components. -- `overlay-test.ts` and `overlay-qa-tests.ts` exercise custom overlays. -- `questionnaire.ts` implements a multi-question custom UI with options, tabs, and freeform input. -- `docs/rpc.md` says RPC supports dialog/fire-and-forget methods (`select`, `confirm`, `input`, `editor`, `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`), but `custom()` returns `undefined` in RPC mode and several TUI-specific methods are no-ops. - -Implication: - -- Rich TUI UI can be built, but fixture-controllable semantics must not depend on `ctx.ui.custom()` alone. -- Critical Brunch interactions should be represented as product payloads/commands with mode-specific renderers: TUI custom overlay, web component, RPC decision method or built-in dialog fallback. - -#### B6. This Pi-based harness can be the live test bed - -User insight: - -- Because this coding harness itself is Pi and can auto-reload extension changes, the ideal test bed for many extension explorations is the same harness we are working in, in real time. - -Implication: - -- The spike should include a `scratch` or project-local Pi extension loaded into this harness, not only tests in Brunch’s future TUI host. -- Use auto-discovered extension locations or `pi -e` style prototypes where appropriate, but avoid committing harness-local experiments as Brunch product code unless promoted. -- Document any manual/realtime observations: what reloaded cleanly, what required restart, what UI state survived reload, which hooks fire inside the harness. - -### C. Strategically grouped exploration inventory - -#### Group A — Product-shell containment: “Can Brunch narrow Pi?” - -**Status:** Mostly answered by Card 1. Keep this group as evidence background and product-review input, not as the next implementation target unless strict containment becomes mandatory. - -**A1. Built-in command inventory and policy matrix — done** - -The completed matrix is now in `docs/architecture/pi-ui-extension-patterns.md`. Original audit target: - -- `/settings` -- `/model` -- `/scoped-models` -- `/export` -- `/import` -- `/share` -- `/copy` -- `/name` -- `/session` -- `/changelog` -- `/hotkeys` -- `/fork` -- `/clone` -- `/tree` -- `/login` -- `/logout` -- `/new` -- `/compact` -- `/resume` -- `/reload` -- `/quit` - -Classify each: - -| Command | Hide autocomplete? | Block execution by hook? | Safe to leave? | Needs Pi change? | Notes | -| --- | --- | --- | --- | --- | --- | -| `/fork` | TBD | likely yes via `session_before_fork` | no | maybe | Branch creation unsupported by Brunch POC. | -| `/clone` | TBD | likely yes via `session_before_fork` | no | maybe | Same branch-policy concern. | -| `/tree` | TBD | likely yes via `session_before_tree` | no | maybe | Branch navigation unsupported. | -| `/new` | TBD | maybe via `session_before_switch`; Brunch needs custom same-spec behavior | maybe with coordinator | likely if full replacement needed | Must preserve selected spec. | -| `/resume` | TBD | maybe via `session_before_switch` | uncertain | maybe | Needs explicit Brunch session/spec validation. | -| `/model` | TBD | no known hook | maybe hidden or allowed internally | likely if strict | Product may want curated model policy. | -| `/settings` | TBD | no known hook | probably no for product shell | likely if strict | Generic Pi settings expose non-Brunch surface. | -| all others | TBD | TBD | TBD | TBD | Complete in spike. | - -**A2. Autocomplete allowlist probe — source-proven, not visually proven** - -Card 1 source-audited that `ctx.ui.addAutocompleteProvider()` can wrap the base provider and should be able to filter slash suggestions while delegating file/path and future `#` mention completion. Visual TUI autocomplete proof remains open if product review needs it. - -Original acceptance evidence: - -- Brunch-allowed commands appear. -- Disallowed built-ins do not appear in suggestions. -- Path/file completions still work. -- Skill commands can be disabled or filtered. - -**A3. Execution allowlist probe — done, strict allowlist blocked on Pi API** - -Card 1 established that exact interactive built-ins are consumed by `InteractiveMode` before extension `input`; lifecycle hooks can block dangerous effects but cannot strictly suppress all built-in execution. - -Original probe list: - -1. extension `input` event, -2. custom editor wrapper, -3. lifecycle hooks, -4. registering conflicting extension commands, -5. settings knobs. - -Expected result: - -- `input` is too late for built-in interactive commands. -- lifecycle hooks can block specific session operations but not all commands. -- command conflicts do not override built-ins. -- if a custom editor can pre-intercept submit, determine whether it is safe enough or too invasive. - -**A4. Minimum Pi upstream/API ask — done** - -`docs/architecture/pi-ui-extension-patterns.md` now records the minimal command/keybinding policy ask. Original shape: - -```ts -pi.setCommandPolicy({ - hiddenBuiltins: ["fork", "clone", "tree", "settings"], - blockedBuiltins: ["fork", "clone", "tree"], -}); -``` - -or a launch/session option: - -```ts -allowedBuiltInCommands: ["new", "compact", "quit"] -``` - -Spike must distinguish “nice to have” from “required before M5/M6/M7.” - -#### Group B — Brunch chrome: “Can the shell feel like Brunch, not Pi?” - -**Status:** Initial command/chrome question answered by Card 2. The core wrapper exists; remaining work is product-shell walkthrough and extending the wrapper for future real graph/lens/coherence data. - -**B1. Header/footer replacement demo — done at raw TUI + unit level** - -`renderBrunchChrome` uses `setHeader` and `setFooter` to replace Pi branding/help with Brunch-specific chrome. - -Questions: - -- Can startup hints be fully removed/replaced? -- Does footer replacement lose any useful status Brunch needs? -- Can model/tool/debug info be hidden or made secondary? -- Does this work after `/reload` and session replacement? - -**B2. Persistent status/widget layout demo — done for above-editor/status path** - -`renderBrunchChrome` uses: - -- `setStatus` for compact counters, -- `setWidget(aboveEditor)` for spec/session/lens/coherence/worker summary. - -`setWidget(belowEditor)` remains available but was not needed for the first wrapper. - -Prototype fields: - -- cwd, -- spec, -- session, -- phase/stage, -- active lens, -- coherence verdict, -- observer/reviewer queue state, -- reconciliation need count. - -**B3. Dynamic background updates demo — partially done** - -Card 2 simulated streaming/worker state via unit tests and a raw RPC extension command; full live Brunch-host idle-vs-streaming manual walkthrough remains open. - -Original target simulations: - -- reviewer starts/runs/completes, -- observer queue count increments/decrements, -- reconciliation need count changes, -- new stage/mode becomes available. - -Acceptance evidence: - -- Updates render while idle. -- Updates render during streaming. -- Updates do not corrupt editor input. -- Updates survive `/reload` by reconstructing from state or deliberately reset with clear semantics. - -#### Group C — Guided interaction primitives: “Can Brunch ask in product-native shapes?” - -**C1. Built-in dialog coverage** - -Probe `ctx.ui.select`, `confirm`, `input`, and `editor`. - -Map to: - -- simple authority confirmation, -- single-choice question, -- freeform answer, -- multiline request-changes. - -Record which are supported in interactive TUI and RPC. - -**C2. Custom radio/checkbox/freeform component** - -Build one `ctx.ui.custom()` component covering: - -- radio, -- checkbox, -- freeform-plus-choice, -- skip/cancel, -- optional timeout if feasible. - -Acceptance evidence: - -- returns typed payload, -- handles keyboard navigation, -- renders clearly in narrow terminals, -- can write `brunch.offer_response`-shaped payloads via a Brunch wrapper, -- has a non-custom RPC fallback path. - -**C3. Picker/list modal** - -Use `SelectList` pattern for: - -- spec picker, -- entity picker, -- lens/orientation inspection. - -Constraints: - -- Establishment offer expansion remains user-invoked. -- Spec picker cannot mutate session binding directly; it must route through coordinator/command handler. -- Entity picker must return stable IDs. - -#### Group D — Review-set UX: “Can accept/request/reject be controllable?” - -**D1. Review-set overlay prototype** - -Use `ctx.ui.custom(..., { overlay: true })` to render: - -- proposal summary, -- candidate entities/edges, -- grounding coverage, -- epistemic status, -- actions: approve / request changes / reject. - -**D2. Action-button semantics** - -Clarify and document whether TUI target is: - -- keyboard-navigable only, -- mouse-clickable, -- or web-only clickable. - -Likely posture: keyboard-navigable in TUI is sufficient unless Pi mouse support is proven cheaply. - -**D3. Transcript persistence check** - -Every action must produce durable transcript/product state: - -- `brunch.review_set_response` or equivalent, -- `acceptReviewSet` command for approve, -- regeneration request for request-changes, -- rejection entry for reject. - -No review-set decision may be UI-only. - -#### Group E — RPC / fixture controllability: “Can the agent-as-user driver exercise this?” - -**E1. Built-in RPC extension UI parity** - -Confirm RPC support for: - -- `select`, -- `confirm`, -- `input`, -- `editor`, -- `notify`, -- `setStatus`, -- `setWidget`, -- `setTitle`, -- `setEditorText`. - -Use `rpc-demo.ts` plus `docs/rpc.md` as reference. - -**E2. Custom component gap** - -Because `ctx.ui.custom()` returns `undefined` in RPC mode, evaluate options: - -1. restrict critical fixture paths to RPC-supported dialogs, -2. add Brunch-owned RPC methods for offer/review decisions, -3. model custom TUI choices as transcript-native offers with RPC-specific decision renderers, -4. accept rich overlays as manual-only but test their payload contracts separately. - -Recommended direction: - -- Separate semantic offer/review payloads from mode-specific renderers. -- TUI overlay, web component, and RPC driver should all answer the same Brunch-level pending interaction, not each invent state. - -#### Group F — Custom transcript/message rendering - -**F1. Custom message renderer audit** - -Probe `registerMessageRenderer` for: - -- `brunch.establishment_offer`, -- `brunch.elicitor_intent_hint`, -- `brunch.review_set_proposal`, -- `brunch.side_task_result`, -- `worldUpdate`, -- `brunch.mention_staleness_hint`. - -**F2. Context vs persistence distinction** - -For each entry type, record whether it is: - -- persisted only via `appendEntry`, -- context-participating via `sendMessage`, -- displayed, -- hidden/internal, -- part of exchange projection, -- relevant to RPC/web subscriptions. - -This prevents accidental parallel chat/turn state and protects M2/M3 transcript decisions. - -#### Group G — Live harness test-bed strategy - -**G1. Use this Pi harness as realtime prototype host** - -Because the agent harness itself is Pi and supports extension reloads, use the current working harness as a fast feedback loop for extension seams. - -Candidate approach: - -- Put scratch extensions in a clearly temporary location, ideally outside Brunch product source or under a `docs/architecture/artifacts/pi-ui-extension-patterns/` scratch area if committed artifacts are desired. -- Prefer project-local `.pi/extensions/` or explicit `pi -e` for quick tests; if using this repository’s `.pi/`, ensure experiments do not imply Brunch user-facing configuration. -- Use `/reload` to test hot reload and state reconstruction. -- Capture findings in the feasibility matrix, not as production code by default. - -**G2. Promote only wrappers that survive the spike** - -The spike may leave behind minimum-viable wrappers, but they should be Brunch-owned and semantically named, e.g.: - -- `renderBrunchChrome(ctx, state)` -- `requestBrunchChoice(ctx, offer)` -- `requestReviewSetDecision(ctx, reviewSet)` -- `installBrunchCommandPolicy(pi, policy)` if feasible -- `installBrunchAutocomplete(pi, provider)` - -Do not spread raw Pi extension calls throughout M5/M6/M7 code. - -### D. Recommended exploration order - -1. **Command/chrome containment audit** — done in Card 1; see `docs/architecture/pi-ui-extension-patterns.md`. -2. **Dynamic chrome demo** — done for wrapper/unit/raw-TUI/raw-RPC proof in Card 2; full Brunch-host walkthrough remains optional/product-review debt. -3. **Structured prompt primitives** — next likely build target: radio/checkbox/freeform picker with semantic payloads and RPC fallback. -4. **Review-set overlay** — richest UX, depends on primitives. -5. **RPC controllability pass** — determine which affordances need semantic fallback methods; already known that TUI custom components are not RPC-controllable directly. -6. **Wrapper design** — started with `renderBrunchChrome`; continue with Brunch-owned APIs over Pi primitives so M5/M6/M7 do not depend directly on raw Pi extension calls. -7. **Feasibility matrix + memo** — started in `docs/architecture/pi-ui-extension-patterns.md`; continue updating it as new affordance categories are proven. - -### E. Proposed feasibility matrix shape - -Create during spike: - -| Affordance | User-visible purpose | Pi seam(s) | Demo status | RPC/fixture controllable? | Brunch wrapper required | Verdict | Residual risk | Downstream frontier | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Command autocomplete allowlist | Hide unsupported Pi commands | `addAutocompleteProvider`, settings | source-proven; visual TUI proof open | n/a | yes | feasible-with-cost | execution still separate | M5/M6 | -| Built-in command execution block | Prevent unsupported product flows | lifecycle hooks / Pi command-policy API | proven incomplete for strict exact built-ins; effect blocking proven for branch/session flows | n/a | yes | requires-pi-change for strict suppression | exact interactive built-ins remain callable; lifecycle hooks block dangerous effects only | M0/M5/M7 | -| Dynamic chrome | Show product state | `setHeader`, `setFooter`, `setStatus`, `setWidget`, `setWorkingIndicator`, `setTitle` | proven for wrapper/unit/raw-TUI/raw-RPC | partial (`setStatus`, string-array `setWidget`, `setTitle`) | yes: `renderBrunchChrome` | proven for POC wrapper | full Brunch-host walkthrough still useful; reload reconstructs from product state | M5/M7/M8 | -| Multi-choice prompt | Structured elicitation | `select`, `custom` | not started | partial (`select`) | yes | TBD | custom RPC gap | M5 | -| Review-set overlay | Accept/request/reject | `custom` overlay | not started | no unless fallback | yes | TBD | fixture controllability | M5/M6 | - -## Review findings - -No `ln-review` was run in this session, so there are no review findings to preserve. - -| # | Finding | Status | Implications | -| --- | --- | --- | --- | -| — | No review findings from this session. | n/a | n/a | - -## Diagnostic evidence - -- `memory/PLAN.md`: `pi-ui-extension-patterns` is a parallel/low-conflict frontier and explicitly lists custom slash commands, styled chrome, overlays, multi-choice prompts, action buttons, picker modals, establishment-offer rendering, and agent-as-user controllability as acceptance concerns. -- `memory/SPEC.md`: Brunch hides Pi's generic extension surface from users, preserves linear transcript policy, keeps establishment offers orientation-first, and routes writes through `CommandExecutor`. -- Pi docs `docs/extensions.md`: extensions can register tools/commands, subscribe to lifecycle events, call UI methods, set widgets/status/header/footer, add autocomplete providers, custom-render messages/tools, and use `sendMessage`/`appendEntry` with delivery modes. -- Pi docs `docs/tui.md`: `ctx.ui.custom()` supports custom components and overlays; built-in components include `SelectList`, `SettingsList`, `Editor`, `Text`, `Container`, etc.; every render line must fit width; components must handle invalidation/theme changes. -- Pi docs `docs/rpc.md`: RPC extension UI supports built-in dialogs and fire-and-forget UI updates; `ctx.ui.custom()` returns `undefined` in RPC mode. -- Pi source `src/core/slash-commands.ts`: built-in commands are statically enumerated. -- Pi source `src/modes/interactive/interactive-mode.ts`: built-in command execution is handled in the editor submit handler before normal prompt flow. -- Pi source `src/core/agent-session.ts`: extension commands are tried before extension `input`; extension `input` fires before skill/template expansion, but too late for built-ins already handled by interactive mode. -- Pi source/examples: `custom-header.ts`, `custom-footer.ts`, `status-line.ts`, `widget-placement.ts`, `working-indicator.ts`, `questionnaire.ts`, `overlay-test.ts`, `rpc-demo.ts`, and `plan-mode/index.ts` provide concrete implementation patterns for the likely Brunch affordances. - -## Decisions and assumptions - -| Item | Type | Status | Source | -| --- | --- | --- | --- | -| Treat `pi-ui-extension-patterns` as a structural spike, not ordinary UI polish. | decision | persisted in PLAN, reinforced by conversation | `memory/PLAN.md`, conversation | -| Built-in command suppression is now a first-class spike question. | decision | reconciled: `D34-L`; strict exact suppression requires a Pi command/keybinding policy seam | `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md` | -| Dynamic background-status chrome is a first-class need: observer/reviewer/reconciler running, queues, reconciliation needs, new stage/mode. | decision | reconciled: `D35-L`; core wrapper proven, full product walkthrough still useful | `src/brunch-tui.ts`, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md` | -| This Pi harness should serve as a realtime test bed for extension changes and reload behavior. | decision | still provisional practice; evidence should remain tiered as raw Pi harness vs Brunch-host proof | user conversation, Cards 1–2 probe evidence | -| Chrome replacement/update seams are feasible for the POC wrapper. | assumption | validated: `A10-L` | Card 2 unit/raw-TUI/raw-RPC evidence, `memory/SPEC.md` | -| Full built-in command execution allowlisting is not feasible solely through current public extension APIs. | assumption | supported by Card 1 source/RPC evidence; Pi API ask recorded | `docs/architecture/pi-ui-extension-patterns.md`, `D34-L` | -| Rich custom TUI affordances need semantic RPC fallbacks because `ctx.ui.custom()` is not available in RPC mode. | assumption | still open for remaining affordance work; high confidence from docs | `docs/rpc.md` | - -## Repo state - -Original snapshot when this handoff was written: - -- **Branch**: `ln/fe-741-graph-data-plane` -- **Recent commits**: - - `eab91dfb Restore ln-judo-review skill` - - `64406a91 Sync web shell closeout` - - `1cbd57b4 Use typed web session projection target` - - `ab28054e Use explicit transcript custom entry classifiers` - - `f5a26ea0 Share Brunch session envelope reader` -- **Dirty files before writing this doc**: none. -- **Dirty files after writing this doc**: expected `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`. -- **Test status**: not run; this session only read docs/source and wrote a planning/handoff document. - -Current snapshot after Cards 1–2: - -- **Branch**: `ln/fe-744-pi-ui-extension-patterns` -- **Linear**: FE-744 -- **Relevant commits**: - - `4b1c2604 FE-744: Document Pi command containment evidence` - - `233c2cd1 FE-744: Prove dynamic Brunch chrome wrapper` - - `ee3faff8 restore provisional plan` -- **Verification after Card 2**: `npm run fix` and `npm run verify` passed. -- **Current update intent**: keep this provisional plan aligned as future-affordance inventory; do not treat the original repo-state snapshot as current. - -## Artifact status - -| Artifact | Exists | Current vs conversation | +| Need | Status after current evidence | Carry-forward | | --- | --- | --- | -| `memory/SPEC.md` | yes | current for command containment and dynamic chrome: includes `A18-L`, validated `A10-L`, `D34-L`, `D35-L`, and updated `I19-L`. | -| `memory/PLAN.md` | yes | current for FE-744 branch/issue, Cards 1–2 progress, and wrapper/RPC obligations; this provisional plan remains more detailed for future affordance inventory. | -| `memory/CARDS.md` | no | exhausted after Cards 1–2 and retired. | -| `memory/REFACTOR.md` | no | n/a | -| `docs/architecture/pi-seam-extensions.md` | yes | contains earlier Pi seam analysis; should receive or link to final feasibility matrix after spike. | -| `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` | yes | this temporary/provisional handoff plan; retire or supersede after scoping/spike. | - -## Next steps - -1. Decide whether to pause for product-shell review of Cards 1–2. Review question: given strict command suppression is unavailable, are autocomplete hiding + effect blocking + strong Brunch chrome sufficient for the POC? -2. If continuing implementation, run `ln-scope` for the next remaining affordance category rather than command/chrome again. Strong candidates: - - structured prompt primitives (radio / checkbox / freeform-plus-choice) with semantic payloads and RPC fallback; - - review-set overlay action semantics (approve / request changes / reject) after prompt primitives; - - picker/list-selection modals for spec/entity/lens orientation; - - message rendering for establishment offers, review-set proposals, side-task results, world updates, and mention staleness. -3. Continue using the local Pi clone (`~/Clones/earendil-works/pi`) and temporary `pi -e`/raw harness probes where useful. Record source audit, raw Pi harness, Brunch-host, and RPC evidence as separate tiers. -4. Keep `docs/architecture/pi-ui-extension-patterns.md` as the stable feasibility memo; update it when each new affordance category is proven or rejected. -5. Reconcile only durable conclusions into `memory/SPEC.md` and `memory/PLAN.md`; keep this provisional file as future-affordance inventory until superseded. - -## Retirement rule - -- Delete or overwrite this file only once its remaining future-affordance inventory (Groups C–G and the open questions below) is absorbed into scoped cards, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, `memory/PLAN.md`, or a newer handoff. Cards 1–2 alone do **not** exhaust this file. -- Do not treat this file as canonical product contract; its job is to preserve the expanded exploration inventory and reasoning for the next thread. +| Single-choice offer UI | Pi example-proven; Brunch offer loop not yet proven | Active slice | +| Multi-choice offer UI | Pi example can be adapted; Brunch semantics not yet proven | Active slice or immediate follow-up | +| Freeform-plus-choice | Pi `question.ts` proves the pattern | Active slice | +| Structured offer custom entries | Transcript/persistence model exists; offer-response loop not yet wired | Active slice | +| Message rendering for offers | Pi `message-renderer.ts` proves display; response collection is separate | Active slice | +| Review-set approve/request/reject | Depends on offer-response loop | M5 follow-up when `acceptReviewSet` exists | +| Establishment-offer orientation expansion | Depends on offer-response loop; must remain user-invoked, not default exhaustive menu | M5/M7 follow-up | +| RPC controllability | `ctx.ui.custom()` gap is known | Active slice must provide semantic response path | +| Mouse-clickable action buttons | Unproven and not required for POC if keyboard navigation works | Defer | +| Strict built-in command suppression | Requires Pi command/keybinding policy | Separate follow-up, not this slice | ## Open questions -- Is hiding unsupported built-in commands from autocomplete enough for Brunch POC, if dangerous effects like branch creation are blocked by lifecycle hooks? -- Does Brunch require a Pi upstream/API change for true built-in command allowlisting before M5, or can this wait? -- Should TUI “action buttons” be keyboard-navigable only, or should mouse-clickability be a hard requirement? -- Which rich custom interactions must be fixture-controllable in RPC mode for M5, and which can remain manual outer-loop checks? -- Where should realtime harness scratch extensions live so they are useful but not confused with Brunch product code? +- Should the first offer UI use transient `ctx.ui.custom()` only, or should Brunch replace the editor component while a pending offer exists and restore it after response? +- Which custom entry name is canonical for generic responses: `brunch.offer_response`, `brunch.elicitation_response`, or a more specific family? +- Does submitting an offer response call `pi.sendUserMessage()` with a textual summary, append a context-participating custom message, or both? +- How much of the offer is visible to the LLM as structured context versus displayed only to the user? +- What is the thinnest RPC method family for pending-offer discovery and response submission? -## Resume prompt - -Paste this into a new session: +## Retirement rule -> Read `docs/architecture/pi-ui-extension-patterns-provisional-plan.md`, `docs/architecture/pi-ui-extension-patterns.md`, `memory/SPEC.md`, and `memory/PLAN.md`. We are on FE-744 / `ln/fe-744-pi-ui-extension-patterns`. Command containment and dynamic Brunch chrome have landed; strict exact built-in suppression still requires a Pi command-policy API, while `renderBrunchChrome` proves Brunch-owned chrome projection. The next decision is whether to pause for product-shell review or scope the next remaining affordance category (structured prompt primitives, review-set overlays, picker/list modals, message rendering, or RPC controllability). Preserve evidence tiers: source audit vs raw Pi harness vs Brunch-host proof vs RPC behavior. +Retire this file only after the offer-first custom UI loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 07e3144f..cee05ce3 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -216,6 +216,30 @@ allowedBuiltInCommands: ["compact", "reload", "quit"] The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. +## Offer-first custom UI gap + +The remaining live FE-744 gap is not generic UI polish. Brunch still needs an offer-first interaction loop: a system/assistant-originated structured offer should act like the assistant turn, render as transcript-visible custom message state, replace the default input surface with custom response UI, and persist the user's structured response before the next agent turn. + +Pi source/docs already give strong evidence for the primitive: + +- `docs/usage.md` states that the editor can be temporarily replaced by custom extension UI. +- `docs/tui.md` documents `ctx.ui.custom()` for editor-area replacement and `ctx.ui.setEditorComponent()` for replacing the main input editor. +- `examples/extensions/question.ts` proves single-choice plus optional freeform input. +- `examples/extensions/questionnaire.ts` proves multi-question/multi-step choice UI with custom answers. +- `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. + +The seam Brunch must still prove is the composition: transcript-native unresolved offer → input-replacing custom UI → persisted structured response → projection as an elicitation exchange. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that loop is implemented or deliberately moved into a named M5 slice. + +| Residual affordance | Current posture | Carry-forward obligation | +| --- | --- | --- | +| Offer-first session loop | Missing and POC-critical. | A session can begin from a system/assistant offer without ambient user chat; unresolved offers own the input surface until answered. | +| Structured custom message as UI driver | Display is Pi-example-proven; response collection still needs Brunch composition. | Persist the offer as a Brunch custom entry, render it in transcript history, and mount response UI from the pending offer state. | +| Single-choice / multi-choice / freeform-plus-choice response | Pi examples prove the component patterns. | Build a Brunch-owned response helper over those patterns and persist `brunch.offer_response`-shaped data. | +| Review-set decisions | Depends on the offer-response loop. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a response entry. | +| Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | +| RPC/fixture controllability | `ctx.ui.custom()` is not automatically RPC-controllable. | Critical fixture paths need Brunch RPC methods or built-in dialog fallbacks over the same semantic pending offer. | +| Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | + ## Downstream posture - For the POC, Brunch can plausibly proceed if it hides disallowed commands from autocomplete and blocks branch/session effects with lifecycle hooks, **provided product documentation does not claim strict built-in suppression**. diff --git a/docs/reference/pi-extensions.md b/docs/reference/pi-extensions.md new file mode 100644 index 00000000..6426096f --- /dev/null +++ b/docs/reference/pi-extensions.md @@ -0,0 +1,2596 @@ +> pi can create extensions. Ask it to build one for your use case. + +# Extensions + +Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more. + +> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`. + +**Key capabilities:** +- **Custom tools** - Register tools the LLM can call via `pi.registerTool()` +- **Event interception** - Block or modify tool calls, inject context, customize compaction +- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions +- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` +- **Session persistence** - Store state that survives restarts via `pi.appendEntry()` +- **Custom rendering** - Control how tool calls/results and messages appear in TUI + +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on branch) +- Path protection (block writes to `.env`, `node_modules/`) +- Custom compaction (summarize conversation your way) +- Conversation summaries (see `summarize.ts` example) +- Interactive tools (questions, wizards, custom dialogs) +- Stateful tools (todo lists, connection pools) +- External integrations (file watchers, webhooks, CI triggers) +- Games while you wait (see `snake.ts` example) + +See [examples/extensions/](../examples/extensions/) for working implementations. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Extension Locations](#extension-locations) +- [Available Imports](#available-imports) +- [Writing an Extension](#writing-an-extension) + - [Extension Styles](#extension-styles) +- [Events](#events) + - [Lifecycle Overview](#lifecycle-overview) + - [Resource Events](#resource-events) + - [Session Events](#session-events) + - [Agent Events](#agent-events) + - [Model Events](#model-events) + - [Tool Events](#tool-events) +- [ExtensionContext](#extensioncontext) +- [ExtensionCommandContext](#extensioncommandcontext) +- [ExtensionAPI Methods](#extensionapi-methods) +- [State Management](#state-management) +- [Custom Tools](#custom-tools) +- [Custom UI](#custom-ui) +- [Error Handling](#error-handling) +- [Mode Behavior](#mode-behavior) +- [Examples Reference](#examples-reference) + +## Quick Start + +Create `~/.pi/agent/extensions/my-extension.ts`: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +export default function (pi: ExtensionAPI) { + // React to events + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify("Extension loaded!", "info"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register a custom tool + pi.registerTool({ + name: "greet", + label: "Greet", + description: "Greet someone by name", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register a command + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify(`Hello ${args || "world"}!`, "info"); + }, + }); +} +``` + +Test with `--extension` (or `-e`) flag: + +```bash +pi -e ./my-extension.ts +``` + +## Extension Locations + +> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. + +Extensions are auto-discovered from: + +| Location | Scope | +| ----------------------------------- | ---------------------------- | +| `~/.pi/agent/extensions/*.ts` | Global (all projects) | +| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) | +| `.pi/extensions/*.ts` | Project-local | +| `.pi/extensions/*/index.ts` | Project-local (subdirectory) | + +Additional paths via `settings.json`: + +```json +{ + "packages": [ + "npm:@foo/bar@1.0.0", + "git:github.com/user/repo@v1" + ], + "extensions": [ + "/path/to/local/extension.ts", + "/path/to/local/extension/dir" + ] +} +``` + +To share extensions via npm or git as pi packages, see [packages.md](packages.md). + +## Available Imports + +| Package | Purpose | +| --------------------------------- | ------------------------------------------------------------ | +| `@earendil-works/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | +| `typebox` | Schema definitions for tool parameters | +| `@earendil-works/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | +| `@earendil-works/pi-tui` | TUI components for custom rendering | + +npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically. + +For distributed pi packages installed with `pi install` (npm or git), runtime deps must be in `dependencies`. Package installation uses production installs (`npm install --omit=dev`) by default, so `devDependencies` are not available at runtime; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. + +## Writing an Extension + +An extension exports a default factory function that receives `ExtensionAPI`. The factory can be synchronous or asynchronous: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // ctx.ui for user interaction + const ok = await ctx.ui.confirm("Title", "Are you sure?"); + ctx.ui.notify("Done!", "info"); + ctx.ui.setStatus("my-ext", "Processing..."); // Footer status + ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) + }); + + // Register tools, commands, shortcuts, flags + pi.registerTool({ ... }); + pi.registerCommand("name", { ... }); + pi.registerShortcut("ctrl+x", { ... }); + pi.registerFlag("my-flag", { ... }); +} +``` + +Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. + +If the factory returns a `Promise`, pi awaits it before continuing startup. That means async initialization completes before `session_start`, before `resources_discover`, and before provider registrations queued via `pi.registerProvider()` are flushed. + +### Async factory functions + +Use an async factory for one-time startup work such as fetching remote configuration or dynamically discovering available models. + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default async function (pi: ExtensionAPI) { + const response = await fetch("http://localhost:1234/v1/models"); + const payload = (await response.json()) as { + data: Array<{ + id: string; + name?: string; + context_window?: number; + max_tokens?: number; + }>; + }; + + pi.registerProvider("local-openai", { + baseUrl: "http://localhost:1234/v1", + apiKey: "LOCAL_OPENAI_API_KEY", + api: "openai-completions", + models: payload.data.map((model) => ({ + id: model.id, + name: model.name ?? model.id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.context_window ?? 128000, + maxTokens: model.max_tokens ?? 4096, + })), + }); +} +``` + +This pattern makes the fetched models available during normal startup and to `pi --list-models`. + +### Extension Styles + +**Single file** - simplest, for small extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension.ts +``` + +**Directory with index.ts** - for multi-file extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── index.ts # Entry point (exports default function) + ├── tools.ts # Helper module + └── utils.ts # Helper module +``` + +**Package with dependencies** - for extensions that need npm packages: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── package.json # Declares dependencies and entry points + ├── package-lock.json + ├── node_modules/ # After npm install + └── src/ + └── index.ts +``` + +```json +// package.json +{ + "name": "my-extension", + "dependencies": { + "zod": "^3.0.0", + "chalk": "^5.0.0" + }, + "pi": { + "extensions": ["./src/index.ts"] + } +} +``` + +Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. + +## Events + +### Lifecycle Overview + +``` +pi starts + │ + ├─► session_start { reason: "startup" } + └─► resources_discover { reason: "startup" } + │ + ▼ +user sends prompt ─────────────────────────────────────────┐ + │ │ + ├─► (extension commands checked first, bypass if found) │ + ├─► input (can intercept, transform, or handle) │ + ├─► (skill/template expansion if not handled) │ + ├─► before_agent_start (can inject message, modify system prompt) + ├─► agent_start │ + ├─► message_start / message_update / message_end │ + │ │ + │ ┌─── turn (repeats while LLM calls tools) ───┐ │ + │ │ │ │ + │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ + │ ├─► before_provider_request (can inspect or replace payload) + │ ├─► after_provider_response (status + headers, before stream consume) + │ │ │ │ + │ │ LLM responds, may call tools: │ │ + │ │ ├─► tool_execution_start │ │ + │ │ ├─► tool_call (can block) │ │ + │ │ ├─► tool_execution_update │ │ + │ │ ├─► tool_result (can modify) │ │ + │ │ └─► tool_execution_end │ │ + │ │ │ │ + │ └─► turn_end │ │ + │ │ + └─► agent_end │ + │ +user sends another prompt ◄────────────────────────────────┘ + +/new (new session) or /resume (switch session) + ├─► session_before_switch (can cancel) + ├─► session_shutdown + ├─► session_start { reason: "new" | "resume", previousSessionFile? } + └─► resources_discover { reason: "startup" } + +/fork or /clone + ├─► session_before_fork (can cancel) + ├─► session_shutdown + ├─► session_start { reason: "fork", previousSessionFile } + └─► resources_discover { reason: "startup" } + +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact + +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +/model or Ctrl+P (model selection/cycling) + ├─► thinking_level_select (if model change changes/clamps thinking level) + └─► model_select + +thinking level changes (settings, keybinding, pi.setThinkingLevel()) + └─► thinking_level_select + +exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) + └─► session_shutdown +``` + +### Resource Events + +#### resources_discover + +Fired after `session_start` so extensions can contribute additional skill, prompt, and theme paths. +The startup path uses `reason: "startup"`. Reload uses `reason: "reload"`. + +```typescript +pi.on("resources_discover", async (event, _ctx) => { + // event.cwd - current working directory + // event.reason - "startup" | "reload" + return { + skillPaths: ["/path/to/skills"], + promptPaths: ["/path/to/prompts"], + themePaths: ["/path/to/themes"], + }; +}); +``` + +### Session Events + +See [Session Format](session-format.md) for session storage internals and the SessionManager API. + +#### session_start + +Fired when a session is started, loaded, or reloaded. + +```typescript +pi.on("session_start", async (event, ctx) => { + // event.reason - "startup" | "reload" | "new" | "resume" | "fork" + // event.previousSessionFile - present for "new", "resume", and "fork" + ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); +}); +``` + +#### session_before_switch + +Fired before starting a new session (`/new`) or switching sessions (`/resume`). + +```typescript +pi.on("session_before_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.targetSessionFile - session we're switching to (only for "resume") + + if (event.reason === "new") { + const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); + if (!ok) return { cancel: true }; + } +}); +``` + +After a successful switch or new-session action, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "new" | "resume"` and `previousSessionFile`. +Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. + +#### session_before_fork + +Fired when forking via `/fork` or cloning via `/clone`. + +```typescript +pi.on("session_before_fork", async (event, ctx) => { + // event.entryId - ID of the selected entry + // event.position - "before" for /fork, "at" for /clone + return { cancel: true }; // Cancel fork/clone + // OR + return { skipConversationRestore: true }; // Reserved for future conversation restore control +}); +``` + +After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. +Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. + +#### session_before_compact / session_compact + +Fired on compaction. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromExtension - whether extension provided it +}); +``` + +#### session_before_tree / session_tree + +Fired on `/tree` navigation. See [Sessions](sessions.md) for tree navigation concepts. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + return { cancel: true }; + // OR provide custom summary: + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromExtension +}); +``` + +#### session_shutdown + +Fired before an extension runtime is torn down. + +```typescript +pi.on("session_shutdown", async (event, ctx) => { + // event.reason - "quit" | "reload" | "new" | "resume" | "fork" + // event.targetSessionFile - destination session for session replacement flows + // Cleanup, save state, etc. +}); +``` + +### Agent Events + +#### before_agent_start + +Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt. + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt - user's prompt text + // event.images - attached images (if any) + // event.systemPrompt - current chained system prompt for this handler + // (includes changes from earlier before_agent_start handlers) + // event.systemPromptOptions - structured options used to build the system prompt + // .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates) + // .selectedTools - tools currently active in the prompt + // .toolSnippets - one-line descriptions for each tool + // .promptGuidelines - custom guideline bullets + // .appendSystemPrompt - text from --append-system-prompt flags + // .cwd - working directory + // .contextFiles - AGENTS.md files and other loaded context files + // .skills - loaded skills + + return { + // Inject a persistent message (stored in session, sent to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Replace the system prompt for this turn (chained across extensions) + systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...", + }; +}); +``` + +The `systemPromptOptions` field gives extensions access to the same structured data Pi uses to build the system prompt. This lets you inspect what Pi has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration. + +Inside `before_agent_start`, `event.systemPrompt` and `ctx.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again. + +#### agent_start / agent_end + +Fired once per user prompt. + +```typescript +pi.on("agent_start", async (_event, ctx) => {}); + +pi.on("agent_end", async (event, ctx) => { + // event.messages - messages from this prompt +}); +``` + +#### turn_start / turn_end + +Fired for each turn (one LLM response + tool calls). + +```typescript +pi.on("turn_start", async (event, ctx) => { + // event.turnIndex, event.timestamp +}); + +pi.on("turn_end", async (event, ctx) => { + // event.turnIndex, event.message, event.toolResults +}); +``` + +#### message_start / message_update / message_end + +Fired for message lifecycle updates. + +- `message_start` and `message_end` fire for user, assistant, and toolResult messages. +- `message_update` fires for assistant streaming updates. +- `message_end` handlers can return `{ message }` to replace the finalized message. The replacement must keep the same `role`. + +```typescript +pi.on("message_start", async (event, ctx) => { + // event.message +}); + +pi.on("message_update", async (event, ctx) => { + // event.message + // event.assistantMessageEvent (token-by-token stream event) +}); + +pi.on("message_end", async (event, ctx) => { + if (event.message.role !== "assistant") return; + + return { + message: { + ...event.message, + usage: { + ...event.message.usage, + cost: { + ...event.message.usage.cost, + total: 0.123, + }, + }, + }, + }; +}); +``` + +#### tool_execution_start / tool_execution_update / tool_execution_end + +Fired for tool execution lifecycle updates. + +In parallel tool mode: +- `tool_execution_start` is emitted in assistant source order during the preflight phase +- `tool_execution_update` events may interleave across tools +- `tool_execution_end` is emitted in tool completion order after each tool is finalized +- final `toolResult` message events are still emitted later in assistant source order + +```typescript +pi.on("tool_execution_start", async (event, ctx) => { + // event.toolCallId, event.toolName, event.args +}); + +pi.on("tool_execution_update", async (event, ctx) => { + // event.toolCallId, event.toolName, event.args, event.partialResult +}); + +pi.on("tool_execution_end", async (event, ctx) => { + // event.toolCallId, event.toolName, event.result, event.isError +}); +``` + +#### context + +Fired before each LLM call. Modify messages non-destructively. See [Session Format](session-format.md) for message types. + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages - deep copy, safe to modify + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +#### before_provider_request + +Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request. + +This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.getSystemPrompt()`, which reports Pi's system prompt string rather than the final serialized provider payload. + +```typescript +pi.on("before_provider_request", (event, ctx) => { + console.log(JSON.stringify(event.payload, null, 2)); + + // Optional: replace payload + // return { ...event.payload, temperature: 0 }; +}); +``` + +This is mainly useful for debugging provider serialization and cache behavior. + +#### after_provider_response + +Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order. + +```typescript +pi.on("after_provider_response", (event, ctx) => { + // event.status - HTTP status code + // event.headers - normalized response headers + if (event.status === 429) { + console.log("rate limited", event.headers["retry-after"]); + } +}); +``` + +Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers. + +### Model Events + +#### model_select + +Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. + +```typescript +pi.on("model_select", async (event, ctx) => { + // event.model - newly selected model + // event.previousModel - previous model (undefined if first selection) + // event.source - "set" | "cycle" | "restore" + + const prev = event.previousModel + ? `${event.previousModel.provider}/${event.previousModel.id}` + : "none"; + const next = `${event.model.provider}/${event.model.id}`; + + ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info"); +}); +``` + +Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. + +#### thinking_level_select + +Fired when the thinking level changes. This is notification-only; handler return values are ignored. + +```typescript +pi.on("thinking_level_select", async (event, ctx) => { + // event.level - newly selected thinking level + // event.previousLevel - previous thinking level + + ctx.ui.setStatus("thinking", `thinking: ${event.level}`); +}); +``` + +Use this to update extension UI when `pi.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level. + +### Tool Events + +#### tool_call + +Fired after `tool_execution_start`, before the tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs. + +Before `tool_call` runs, pi waits for previously emitted Agent events to finish draining through `AgentSession`. This means `ctx.sessionManager` is up to date through the current assistant tool-calling message. + +In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.sessionManager`. + +`event.input` is mutable. Mutate it in place to patch tool arguments before execution. + +Behavior guarantees: +- Mutations to `event.input` affect the actual tool execution +- Later `tool_call` handlers see mutations made by earlier handlers +- No re-validation is performed after your mutation +- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` + +```typescript +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; + +pi.on("tool_call", async (event, ctx) => { + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters (mutable) + + // Built-in tools: no type params needed + if (isToolCallEventType("bash", event)) { + // event.input is { command: string; timeout?: number } + event.input.command = `source ~/.profile\n${event.input.command}`; + + if (event.input.command.includes("rm -rf")) { + return { block: true, reason: "Dangerous command" }; + } + } + + if (isToolCallEventType("read", event)) { + // event.input is { path: string; offset?: number; limit?: number } + console.log(`Reading: ${event.input.path}`); + } +}); +``` + +#### Typing custom tool input + +Custom tools should export their input type: + +```typescript +// my-extension.ts +export type MyToolInput = Static; +``` + +Use `isToolCallEventType` with explicit type parameters: + +```typescript +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; +import type { MyToolInput } from "my-extension"; + +pi.on("tool_call", (event) => { + if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + event.input.action; // typed + } +}); +``` + +#### tool_result + +Fired after tool execution finishes and before `tool_execution_end` plus the final tool result message events are emitted. **Can modify result.** + +In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in tool completion order, while final `toolResult` message events are still emitted later in assistant source order. + +`tool_result` handlers chain like middleware: +- Handlers run in extension load order +- Each handler sees the latest result after previous handler changes +- Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values + +Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. + +```typescript +import { isBashToolResult } from "@earendil-works/pi-coding-agent"; + +pi.on("tool_result", async (event, ctx) => { + // event.toolName, event.toolCallId, event.input + // event.content, event.details, event.isError + + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + + const response = await fetch("https://example.com/summarize", { + method: "POST", + body: JSON.stringify({ content: event.content }), + signal: ctx.signal, + }); + + // Modify result: + return { content: [...], details: {...}, isError: false }; +}); +``` + +### User Bash Events + +#### user_bash + +Fired when user executes `!` or `!!` commands. **Can intercept.** + +```typescript +import { createLocalBashOperations } from "@earendil-works/pi-coding-agent"; + +pi.on("user_bash", (event, ctx) => { + // event.command - the bash command + // event.excludeFromContext - true if !! prefix + // event.cwd - working directory + + // Option 1: Provide custom operations (e.g., SSH) + return { operations: remoteBashOps }; + + // Option 2: Wrap pi's built-in local bash backend + const local = createLocalBashOperations(); + return { + operations: { + exec(command, cwd, options) { + return local.exec(`source ~/.profile\n${command}`, cwd, options); + } + } + }; + + // Option 3: Full replacement - return result directly + return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; +}); +``` + +### Input Events + +#### input + +Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded. + +**Processing order:** +1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped +2. `input` event fires - can intercept, transform, or handle +3. If not handled: skill commands (`/skill:name`) expanded to skill content +4. If not handled: prompt templates (`/template`) expanded to template content +5. Agent processing begins (`before_agent_start`, etc.) + +```typescript +pi.on("input", async (event, ctx) => { + // event.text - raw input (before skill/template expansion) + // event.images - attached images, if any + // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) + + // Transform: rewrite input before expansion + if (event.text.startsWith("?quick ")) + return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; + + // Handle: respond without LLM (extension shows its own feedback) + if (event.text === "ping") { + ctx.ui.notify("pong", "info"); + return { action: "handled" }; + } + + // Route by source: skip processing for extension-injected messages + if (event.source === "extension") return { action: "continue" }; + + // Intercept skill commands before expansion + if (event.text.startsWith("/skill:")) { + // Could transform, block, or let pass through + } + + return { action: "continue" }; // Default: pass through to expansion +}); +``` + +**Results:** +- `continue` - pass through unchanged (default if handler returns nothing) +- `transform` - modify text/images, then continue to expansion +- `handled` - skip agent entirely (first handler to return this wins) + +Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts). + +## ExtensionContext + +All handlers receive `ctx: ExtensionContext`. + +### ctx.ui + +UI methods for user interaction. See [Custom UI](#custom-ui) for full details. + +### ctx.hasUI + +`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). + +### ctx.cwd + +Current working directory. + +### ctx.sessionManager + +Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. + +For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message. + +```typescript +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID +``` + +### ctx.modelRegistry / ctx.model + +Access to models and API keys. + +### ctx.signal + +The current agent abort signal, or `undefined` when no agent turn is active. + +Use this for abort-aware nested work started by extension handlers, for example: +- `fetch(..., { signal: ctx.signal })` +- model calls that accept `signal` +- file or process helpers that accept `AbortSignal` + +`ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`. +It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle. + +```typescript +pi.on("tool_result", async (event, ctx) => { + const response = await fetch("https://example.com/api", { + method: "POST", + body: JSON.stringify(event), + signal: ctx.signal, + }); + + const data = await response.json(); + return { details: data }; +}); +``` + +### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() + +Control flow helpers. + +### ctx.shutdown() + +Request a graceful shutdown of pi. + +- **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages). +- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command). +- **Print mode:** No-op. The process exits automatically when all prompts are processed. + +Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts). + +```typescript +pi.on("tool_call", (event, ctx) => { + if (isFatal(event.input)) { + ctx.shutdown(); + } +}); +``` + +### ctx.getContextUsage() + +Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. + +```typescript +const usage = ctx.getContextUsage(); +if (usage && usage.tokens > 100_000) { + // ... +} +``` + +### ctx.compact() + +Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions. + +```typescript +ctx.compact({ + customInstructions: "Focus on recent changes", + onComplete: (result) => { + ctx.ui.notify("Compaction completed", "info"); + }, + onError: (error) => { + ctx.ui.notify(`Compaction failed: ${error.message}`, "error"); + }, +}); +``` + +### ctx.getSystemPrompt() + +Returns Pi's current system prompt string. + +- During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn. +- It does not include later `context` message mutations. +- It does not include `before_provider_request` payload rewrites. +- If later-loaded extensions run after yours, they can still change what is ultimately sent. + +```typescript +pi.on("before_agent_start", (event, ctx) => { + const prompt = ctx.getSystemPrompt(); + console.log(`System prompt length: ${prompt.length}`); +}); +``` + +## ExtensionCommandContext + +Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. + +### ctx.waitForIdle() + +Wait for the agent to finish streaming: + +```typescript +pi.registerCommand("my-cmd", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + // Agent is now idle, safe to modify session + }, +}); +``` + +### ctx.newSession(options?) + +Create a new session: + +```typescript +const parentSession = ctx.sessionManager.getSessionFile(); +const kickoff = "Continue in the replacement session"; + +const result = await ctx.newSession({ + parentSession, + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: "Context from previous session..." }], + timestamp: Date.now(), + }); + }, + withSession: async (ctx) => { + // Use only the replacement-session ctx here. + await ctx.sendUserMessage(kickoff); + }, +}); + +if (result.cancelled) { + // An extension cancelled the new session +} +``` + +Options: +- `parentSession`: parent session file to record in the new session header +- `setup`: mutate the new session's `SessionManager` before `withSession` runs +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +### ctx.fork(entryId, options?) + +Fork from a specific entry, creating a new session file: + +```typescript +const result = await ctx.fork("entry-id-123", { + withSession: async (ctx) => { + // Use only the replacement-session ctx here. + ctx.ui.notify("Now in the forked session", "info"); + }, +}); +if (result.cancelled) { + // An extension cancelled the fork +} + +const cloneResult = await ctx.fork("entry-id-456", { position: "at" }); +if (cloneResult.cancelled) { + // An extension cancelled the clone +} +``` + +Options: +- `position`: `"before"` (default) forks before the selected user message, restoring that prompt into the editor +- `position`: `"at"` duplicates the active path through the selected entry without restoring editor text +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +### ctx.navigateTree(targetId, options?) + +Navigate to a different point in the session tree: + +```typescript +const result = await ctx.navigateTree("entry-id-456", { + summarize: true, + customInstructions: "Focus on error handling changes", + replaceInstructions: false, // true = replace default prompt entirely + label: "review-checkpoint", +}); +``` + +Options: +- `summarize`: Whether to generate a summary of the abandoned branch +- `customInstructions`: Custom instructions for the summarizer +- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended +- `label`: Label to attach to the branch summary entry (or target entry if not summarizing) + +### ctx.switchSession(sessionPath, options?) + +Switch to a different session file: + +```typescript +const result = await ctx.switchSession("/path/to/session.jsonl", { + withSession: async (ctx) => { + await ctx.sendUserMessage("Resume work in the replacement session"); + }, +}); +if (result.cancelled) { + // An extension cancelled the switch via session_before_switch +} +``` + +Options: +- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns). + +To discover available sessions, use the static `SessionManager.list()` or `SessionManager.listAll()` methods: + +```typescript +import { SessionManager } from "@earendil-works/pi-coding-agent"; + +pi.registerCommand("switch", { + description: "Switch to another session", + handler: async (args, ctx) => { + const sessions = await SessionManager.list(ctx.cwd); + if (sessions.length === 0) return; + const choice = await ctx.ui.select( + "Pick session:", + sessions.map(s => s.file), + ); + if (choice) { + await ctx.switchSession(choice, { + withSession: async (ctx) => { + ctx.ui.notify("Switched session", "info"); + }, + }); + } + }, +}); +``` + +### Session replacement lifecycle and footguns + +`withSession` receives a fresh `ReplacedSessionContext`, which extends `ExtensionCommandContext` with async `sendMessage()` and `sendUserMessage()` helpers bound to the replacement session. + +Lifecycle and footguns: +- `withSession` runs only after the old session has emitted `session_shutdown`, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received `session_start`. +- The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before `withSession` starts. +- Captured old `pi` / old command `ctx` session-bound objects are stale after replacement and will throw if used. Use only the `ctx` passed to `withSession` for session-bound work. +- Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = ctx.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement. +- Code in `withSession` should assume any state invalidated by your `session_shutdown` handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config. + +Safe pattern: + +```typescript +pi.registerCommand("handoff", { + handler: async (_args, ctx) => { + const kickoff = "Continue from the replacement session"; + await ctx.newSession({ + withSession: async (ctx) => { + await ctx.sendUserMessage(kickoff); + }, + }); + }, +}); +``` + +Unsafe pattern: + +```typescript +pi.registerCommand("handoff", { + handler: async (_args, ctx) => { + const oldSessionManager = ctx.sessionManager; + await ctx.newSession({ + withSession: async (_ctx) => { + // stale old objects: do not do this + oldSessionManager.getSessionFile(); + pi.sendUserMessage("wrong"); + }, + }); + }, +}); +``` + +### ctx.reload() + +Run the same reload flow as `/reload`. + +```typescript +pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; + }, +}); +``` + +Important behavior: +- `await ctx.reload()` emits `session_shutdown` for the current extension runtime +- It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"` +- The currently running command handler still continues in the old call frame +- Code after `await ctx.reload()` still runs from the pre-reload version +- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid +- After the handler returns, future commands/events/tool calls use the new extension version + +For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`). + +Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message. + +Example tool the LLM can call to trigger reload: + +```typescript +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("reload-runtime", { + description: "Reload extensions, skills, prompts, and themes", + handler: async (_args, ctx) => { + await ctx.reload(); + return; + }, + }); + + pi.registerTool({ + name: "reload_runtime", + label: "Reload Runtime", + description: "Reload extensions, skills, prompts, and themes", + parameters: Type.Object({}), + async execute() { + pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); + return { + content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }], + }; + }, + }); +} +``` + +## ExtensionAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events) for event types and return values. + +### pi.registerTool(definition) + +Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. + +`pi.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in `pi.getAllTools()` and are callable by the LLM without `/reload`. + +Use `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime. + +Use `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active. + +**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead. + +See [dynamic-tools.ts](../examples/extensions/dynamic-tools.ts) for a full example. + +```typescript +import { Type } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does", + promptSnippet: "Summarize or transform text according to action", + promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."], + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String()), + }), + prepareArguments(args) { + // Optional compatibility shim. Runs before schema validation. + // Return the current schema shape, for example to fold legacy fields + // into the modern parameter object. + return args; + }, + + async execute(toolCallId, params, signal, onUpdate, ctx) { + // Stream progress + onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); + + return { + content: [{ type: "text", text: "Done" }], + details: { result: "..." }, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, +}); +``` + +### pi.sendMessage(message, options?) + +Inject a custom message into the session. + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Message text", + display: true, + details: { ... }, +}, { + triggerTurn: true, + deliverAs: "steer", +}); +``` + +**Options:** +- `deliverAs` - Delivery mode: + - `"steer"` (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. + - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls. + - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. +- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). + +### pi.sendUserMessage(content, options?) + +Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. + +```typescript +// Simple text message +pi.sendUserMessage("What is 2+2?"); + +// With content array (text + images) +pi.sendUserMessage([ + { type: "text", text: "Describe this image:" }, + { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }, +]); + +// During streaming - must specify delivery mode +pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); +pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); +``` + +**Options:** +- `deliverAs` - Required when agent is streaming: + - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls + - `"followUp"` - Waits for agent to finish all tools + +When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. + +See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example. + +### pi.appendEntry(customType, data?) + +Persist extension state (does NOT participate in LLM context). + +```typescript +pi.appendEntry("my-state", { count: 42 }); + +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + // Reconstruct from entry.data + } + } +}); +``` + +### pi.setSessionName(name) + +Set the session display name (shown in session selector instead of first message). + +```typescript +pi.setSessionName("Refactor auth module"); +``` + +### pi.getSessionName() + +Get the current session name, if set. + +```typescript +const name = pi.getSessionName(); +if (name) { + console.log(`Session: ${name}`); +} +``` + +### pi.setLabel(entryId, label) + +Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector). + +```typescript +// Set a label +pi.setLabel(entryId, "checkpoint-before-refactor"); + +// Clear a label +pi.setLabel(entryId, undefined); + +// Read labels via sessionManager +const label = ctx.sessionManager.getLabel(entryId); +``` + +Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree. + +### pi.registerCommand(name, options) + +Register a command. + +If multiple extensions register the same command name, pi keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`. + +```typescript +pi.registerCommand("stats", { + description: "Show session statistics", + handler: async (args, ctx) => { + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); + } +}); +``` + +Optional: add argument auto-completion for `/command ...`: + +```typescript +import type { AutocompleteItem } from "@earendil-works/pi-tui"; + +pi.registerCommand("deploy", { + description: "Deploy to an environment", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const envs = ["dev", "staging", "prod"]; + const items = envs.map((e) => ({ value: e, label: e })); + const filtered = items.filter((i) => i.value.startsWith(prefix)); + return filtered.length > 0 ? filtered : null; + }, + handler: async (args, ctx) => { + ctx.ui.notify(`Deploying: ${args}`, "info"); + }, +}); +``` + +### pi.getCommands() + +Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands. +The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills. + +```typescript +const commands = pi.getCommands(); +const bySource = commands.filter((command) => command.source === "extension"); +const userScoped = commands.filter((command) => command.sourceInfo.scope === "user"); +``` + +Each entry has this shape: + +```typescript +{ + name: string; // Invokable command name without the leading slash. May be suffixed like "review:1" + description?: string; + source: "extension" | "prompt" | "skill"; + sourceInfo: { + path: string; + source: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + baseDir?: string; + }; +} +``` + +Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing. + +Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive +mode and would not execute if sent via `prompt`. + +### pi.registerMessageRenderer(customType, renderer) + +Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut. See [keybindings.md](keybindings.md) for the shortcut format and built-in keybindings. + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + ctx.ui.notify("Toggled!"); + }, +}); +``` + +### pi.registerFlag(name, options) + +Register a CLI flag. + +```typescript +pi.registerFlag("plan", { + description: "Start in plan mode", + type: "boolean", + default: false, +}); + +// Check value +if (pi.getFlag("plan")) { + // Plan mode enabled +} +``` + +### pi.exec(command, args, options?) + +Execute a shell command. + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) + +Manage active tools. This works for both built-in tools and dynamically registered tools. + +```typescript +const active = pi.getActiveTools(); +const all = pi.getAllTools(); +// [{ +// name: "read", +// description: "Read file contents...", +// parameters: ..., +// sourceInfo: { path: "", source: "builtin", scope: "temporary", origin: "top-level" } +// }, ...] +const names = all.map(t => t.name); +const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin"); +const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk"); +pi.setActiveTools(["read", "bash"]); // Switch to read-only +``` + +`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`. + +Typical `sourceInfo.source` values: +- `builtin` for built-in tools +- `sdk` for tools passed via `createAgentSession({ customTools })` +- extension source metadata for tools registered by extensions + +### pi.setModel(model) + +Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models. + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); + if (!success) { + ctx.ui.notify("No API key for this model", "error"); + } +} +``` + +### pi.getThinkingLevel() / pi.setThinkingLevel(level) + +Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). Changes emit `thinking_level_select`. + +```typescript +const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); +``` + +### pi.events + +Shared event bus for communication between extensions: + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +### pi.registerProvider(name, config) + +Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations. + +Calls made during the extension factory function are queued and applied once the runner initialises. Calls made after that — for example from a command handler following a user setup flow — take effect immediately without requiring a `/reload`. + +If you need to discover models from a remote endpoint, prefer an async extension factory over deferring the fetch to `session_start`. pi waits for the factory before startup continues, so the registered models are available immediately, including to `pi --list-models`. + +```typescript +// Register a new provider with custom models +pi.registerProvider("my-proxy", { + name: "My Proxy", + baseUrl: "https://proxy.example.com", + apiKey: "PROXY_API_KEY", // env var name or literal + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude 4 Sonnet (proxy)", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384 + } + ] +}); + +// Override baseUrl for an existing provider (keeps all models) +pi.registerProvider("anthropic", { + baseUrl: "https://proxy.example.com" +}); + +// Register provider with OAuth support for /login +pi.registerProvider("corporate-ai", { + baseUrl: "https://ai.corp.com", + api: "openai-responses", + models: [...], + oauth: { + name: "Corporate AI (SSO)", + async login(callbacks) { + // Custom OAuth flow + callbacks.onAuth({ url: "https://sso.corp.com/..." }); + const code = await callbacks.onPrompt({ message: "Enter code:" }); + return { refresh: code, access: code, expires: Date.now() + 3600000 }; + }, + async refreshToken(credentials) { + // Refresh logic + return credentials; + }, + getApiKey(credentials) { + return credentials.access; + } + } +}); +``` + +**Config options:** +- `name` - Display name for the provider in UI such as `/login`. +- `baseUrl` - API endpoint URL. Required when defining models. +- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). +- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. +- `headers` - Custom headers to include in requests. +- `authHeader` - If true, adds `Authorization: Bearer` header automatically. +- `models` - Array of model definitions. If provided, replaces all existing models for this provider. Model definitions can set `baseUrl` to override the provider endpoint for that model. +- `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu. +- `streamSimple` - Custom streaming implementation for non-standard APIs. + +See [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference. + +### pi.unregisterProvider(name) + +Remove a previously registered provider and its models. Built-in models that were overridden by the provider are restored. Has no effect if the provider was not registered. + +Like `registerProvider`, this takes effect immediately when called after the initial load phase, so a `/reload` is not required. + +```typescript +pi.registerCommand("my-setup-teardown", { + description: "Remove the custom proxy provider", + handler: async (_args, _ctx) => { + pi.unregisterProvider("my-proxy"); + }, +}); +``` + +## State Management + +Extensions with state should store it in tool result `details` for proper branching support: + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session + pi.on("session_start", async (_event, ctx) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }); + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, signal, onUpdate, ctx) { + items.push("new item"); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // Store for reconstruction + }; + }, + }); +} +``` + +## Custom Tools + +Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering. + +Use `promptSnippet` for a short one-line entry in the `Available tools` section in the default system prompt. If omitted, custom tools are left out of that section. + +Use `promptGuidelines` to add tool-specific bullets to the default system prompt `Guidelines` section. These bullets are included only while the tool is active (for example, after `pi.setActiveTools([...])`). + +**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix or grouping. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead. + +Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well. + +If your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other. + +Example failure case: your custom tool edits `foo.ts` while built-in `edit` also changes `foo.ts` in the same assistant turn. If your tool does not participate in the queue, both can read the original `foo.ts`, apply separate changes, and one of those changes is lost. + +Pass the real target file path to `withFileMutationQueue()`, not the raw user argument. Resolve it to an absolute path first, relative to `ctx.cwd` or your tool's working directory. For existing files, the helper canonicalizes through `realpath()`, so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to `realpath()` yet. + +Queue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write. + +```typescript +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const absolutePath = resolve(ctx.cwd, params.path); + + return withFileMutationQueue(absolutePath, async () => { + await mkdir(dirname(absolutePath), { recursive: true }); + const current = await readFile(absolutePath, "utf8"); + const next = current.replace(params.oldText, params.newText); + await writeFile(absolutePath, next, "utf8"); + + return { + content: [{ type: "text", text: `Updated ${params.path}` }], + details: {}, + }; + }); +} +``` + +### Tool Definition + +```typescript +import { Type } from "typebox"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Text } from "@earendil-works/pi-tui"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (shown to LLM)", + promptSnippet: "List or add items in the project todo list", + promptGuidelines: [ + "Use my_tool for todo planning instead of direct file edits when the user asks for a task list." + ], + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility + text: Type.Optional(Type.String()), + }), + prepareArguments(args) { + if (!args || typeof args !== "object") return args; + const input = args as { action?: string; oldAction?: string }; + if (typeof input.oldAction === "string" && input.action === undefined) { + return { ...input, action: input.oldAction }; + } + return args; + }, + + async execute(toolCallId, params, signal, onUpdate, ctx) { + // Check for cancellation + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // Stream progress updates + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // Run commands via pi.exec (captured from extension closure) + const result = await pi.exec("some-command", [], { signal }); + + // Return result + return { + content: [{ type: "text", text: "Done" }], // Sent to LLM + details: { data: result }, // For rendering & state + // Optional: stop after this tool batch when every finalized tool result + // in the batch also returns terminate: true. + terminate: true, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme, context) { ... }, + renderResult(result, options, theme, context) { ... }, +}); +``` + +**Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object. + +**Early termination:** Return `terminate: true` from `execute()` to hint that the automatic follow-up LLM call should be skipped after the current tool batch. This only takes effect when every finalized tool result in that batch is terminating. See [examples/extensions/structured-output.ts](../examples/extensions/structured-output.ts) for a minimal example where the agent ends on a final structured-output tool call. + +```typescript +// Correct: throw to signal an error +async execute(toolCallId, params) { + if (!isValid(params.input)) { + throw new Error(`Invalid input: ${params.input}`); + } + return { content: [{ type: "text", text: "OK" }], details: {} }; +} +``` + +**Important:** Use `StringEnum` from `@earendil-works/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. + +**Argument preparation:** `prepareArguments(args)` is optional. If defined, it runs before schema validation and before `execute()`. Use it to mimic an older accepted input shape when pi resumes an older session whose stored tool call arguments no longer match the current schema. Return the object you want validated against `parameters`. Keep the public schema strict. Do not add deprecated compatibility fields to `parameters` just to keep old resumed sessions working. + +Example: an older session may contain an `edit` tool call with top-level `oldText` and `newText`, while the current schema only accepts `edits: [{ oldText, newText }]`. + +```typescript +pi.registerTool({ + name: "edit", + label: "Edit", + description: "Edit a single file using exact text replacement", + parameters: Type.Object({ + path: Type.String(), + edits: Type.Array( + Type.Object({ + oldText: Type.String(), + newText: Type.String(), + }), + ), + }), + prepareArguments(args) { + if (!args || typeof args !== "object") return args; + + const input = args as { + path?: string; + edits?: Array<{ oldText: string; newText: string }>; + oldText?: unknown; + newText?: unknown; + }; + + if (typeof input.oldText !== "string" || typeof input.newText !== "string") { + return args; + } + + return { + ...input, + edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }], + }; + }, + async execute(toolCallId, params, signal, onUpdate, ctx) { + // params now matches the current schema + return { + content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }], + details: {}, + }; + }, +}); +``` + +### Overriding Built-in Tools + +Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. + +```bash +# Extension's read tool replaces built-in read +pi -e ./tool-override.ts +``` + +Alternatively, use `--no-builtin-tools` to start without any built-in tools while keeping extension tools enabled: +```bash +# No built-in tools, only extension tools +pi --no-builtin-tools -e ./my-extension.ts +``` + +See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control. + +**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. + +**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly. + +**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. + +Built-in tool implementations: +- [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` +- [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) +- [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) +- [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` +- [find.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails` +- [ls.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails` + +### Remote Execution + +Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): + +```typescript +import { createReadTool, createBashTool, type ReadOperations } from "@earendil-works/pi-coding-agent"; + +// Create tool with custom operations +const remoteRead = createReadTool(cwd, { + operations: { + readFile: (path) => sshExec(remote, `cat ${path}`), + access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), + } +}); + +// Register, checking flag at execution time +pi.registerTool({ + ...remoteRead, + async execute(id, params, signal, onUpdate, _ctx) { + const ssh = getSshConfig(); + if (ssh) { + const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); + return tool.execute(id, params, signal, onUpdate); + } + return localRead.execute(id, params, signal, onUpdate); + }, +}); +``` + +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` + +For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. + +The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: + +```typescript +import { createBashTool } from "@earendil-works/pi-coding-agent"; + +const bashTool = createBashTool(cwd, { + spawnHook: ({ command, cwd, env }) => ({ + command: `source ~/.profile\n${command}`, + cwd: `/mnt/sandbox${cwd}`, + env: { ...env, CI: "1" }, + }), +}); +``` + +See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. + +### Output Truncation + +**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: +- Context overflow errors (prompt too long) +- Compaction failures +- Degraded model performance + +The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities: + +```typescript +import { + truncateHead, // Keep first N lines/bytes (good for file reads, search results) + truncateTail, // Keep last N lines/bytes (good for logs, command output) + truncateLine, // Truncate a single line to maxBytes with ellipsis + formatSize, // Human-readable size (e.g., "50KB", "1.5MB") + DEFAULT_MAX_BYTES, // 50KB + DEFAULT_MAX_LINES, // 2000 +} from "@earendil-works/pi-coding-agent"; + +async execute(toolCallId, params, signal, onUpdate, ctx) { + const output = await runCommand(); + + // Apply truncation + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + + if (truncation.truncated) { + // Write full output to temp file + const tempFile = writeTempFile(output); + + // Inform the LLM where to find complete output + result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; + result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + result += ` Full output saved to: ${tempFile}]`; + } + + return { content: [{ type: "text", text: result }] }; +} +``` + +**Key points:** +- Use `truncateHead` for content where the beginning matters (search results, file reads) +- Use `truncateTail` for content where the end matters (logs, command output) +- Always inform the LLM when output is truncated and where to find the full version +- Document the truncation limits in your tool's description + +See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation. + +### Multiple Tools + +One extension can register multiple tools with shared state: + +```typescript +export default function (pi: ExtensionAPI) { + let connection = null; + + pi.registerTool({ name: "db_connect", ... }); + pi.registerTool({ name: "db_query", ... }); + pi.registerTool({ name: "db_close", ... }); + + pi.on("session_shutdown", async () => { + connection?.close(); + }); +} +``` + +### Custom Rendering + +Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed. + +By default, tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot. + +Set `renderShell: "self"` when the tool should render its own shell instead of using the default `Box`. This is useful for tools that need complete control over framing or background behavior, for example large previews that must stay visually stable after the tool settles. + +```typescript +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "Custom shell example", + parameters: Type.Object({}), + renderShell: "self", + async execute() { + return { content: [{ type: "text", text: "ok" }], details: undefined }; + }, + renderCall(args, theme, context) { + return new Text(theme.fg("accent", "my custom shell"), 0, 0); + }, +}); +``` + +`renderCall` and `renderResult` each receive a `context` object with: +- `args` - the current tool call arguments +- `state` - shared row-local state across `renderCall` and `renderResult` +- `lastComponent` - the previously returned component for that slot, if any +- `invalidate()` - request a rerender of this tool row +- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError` + +Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders. + +#### renderCall + +Renders the tool call or header: + +```typescript +import { Text } from "@earendil-works/pi-tui"; + +renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + let content = theme.fg("toolTitle", theme.bold("my_tool ")); + content += theme.fg("muted", args.action); + if (args.text) { + content += " " + theme.fg("dim", `"${args.text}"`); + } + text.setText(content); + return text; +} +``` + +#### renderResult + +Renders the tool result or output: + +```typescript +renderResult(result, { expanded, isPartial }, theme, context) { + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + if (result.details?.error) { + return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); + } + + let text = theme.fg("success", "✓ Done"); + if (expanded && result.details?.items) { + for (const item of result.details.items) { + text += "\n " + theme.fg("dim", item); + } + } + return new Text(text, 0, 0); +} +``` + +If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`. + +#### Keybinding Hints + +Use `keyHint()` to display keybinding hints that respect the active keybinding configuration: + +```typescript +import { keyHint } from "@earendil-works/pi-coding-agent"; + +renderResult(result, { expanded }, theme, context) { + let text = theme.fg("success", "✓ Done"); + if (!expanded) { + text += ` (${keyHint("app.tools.expand", "to expand")})`; + } + return new Text(text, 0, 0); +} +``` + +Available functions: +- `keyHint(keybinding, description)` - Formats a configured keybinding id such as `"app.tools.expand"` or `"tui.select.confirm"` +- `keyText(keybinding)` - Returns the raw configured key text for a keybinding id +- `rawKeyHint(key, description)` - Format a raw key string + +Use namespaced keybinding ids: +- Coding-agent ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename` +- Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab` + +For the exhaustive list of keybinding ids and defaults, see [keybindings.md](keybindings.md). `keybindings.json` uses those same namespaced ids. + +Custom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`. + +#### Best Practices + +- Use `Text` with padding `(0, 0)`. The default Box handles padding. +- Use `\n` for multi-line content. +- Handle `isPartial` for streaming progress. +- Support `expanded` for detail on demand. +- Keep default view compact. +- Read `context.args` in `renderResult` instead of copying args into `context.state`. +- Use `context.state` only for data that must be shared across call and result slots. +- Reuse `context.lastComponent` when the same component instance can be updated in place. +- Use `renderShell: "self"` only when the default boxed shell gets in the way. In self-shell mode the tool is responsible for its own framing, padding, and background. + +#### Fallback + +If a slot renderer is not defined or throws: +- `renderCall`: Shows the tool name +- `renderResult`: Shows raw text from `content` + +## Custom UI + +Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. + +**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for: +- Selection dialogs (SelectList) +- Async operations with cancel (BorderedLoader) +- Settings toggles (SettingsList) +- Status indicators (setStatus) +- Working message, visibility, and indicator during streaming (`setWorkingMessage`, `setWorkingVisible`, `setWorkingIndicator`) +- Widgets above/below editor (setWidget) +- Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider) +- Custom footers (setFooter) + +### Dialogs + +```typescript +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); + +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); + +// Text input +const name = await ctx.ui.input("Name:", "placeholder"); + +// Multi-line editor +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Notification (non-blocking) +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" +``` + +#### Timed Dialogs with Countdown + +Dialogs support a `timeout` option that auto-dismisses with a live countdown display: + +```typescript +// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0 +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { timeout: 5000 } +); + +if (confirmed) { + // User confirmed +} else { + // User cancelled or timed out +} +``` + +**Return values on timeout:** +- `select()` returns `undefined` +- `confirm()` returns `false` +- `input()` returns `undefined` + +#### Manual Dismissal with AbortSignal + +For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`: + +```typescript +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); + +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal } +); + +clearTimeout(timeoutId); + +if (confirmed) { + // User confirmed +} else if (controller.signal.aborted) { + // Dialog timed out +} else { + // User cancelled (pressed Escape or selected "No") +} +``` + +See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples. + +### Widgets, Status, and Footer + +```typescript +// Status in footer (persistent until cleared) +ctx.ui.setStatus("my-ext", "Processing..."); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Working loader (shown during streaming) +ctx.ui.setWorkingMessage("Thinking deeply..."); +ctx.ui.setWorkingMessage(); // Restore default +ctx.ui.setWorkingVisible(false); // Hide the built-in working loader row entirely +ctx.ui.setWorkingVisible(true); // Show the built-in working loader row + +// Working indicator (shown during streaming) +ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Static dot +ctx.ui.setWorkingIndicator({ + frames: [ + ctx.ui.theme.fg("dim", "·"), + ctx.ui.theme.fg("muted", "•"), + ctx.ui.theme.fg("accent", "●"), + ctx.ui.theme.fg("muted", "•"), + ], + intervalMs: 120, +}); +ctx.ui.setWorkingIndicator({ frames: [] }); // Hide indicator +ctx.ui.setWorkingIndicator(); // Restore default spinner + +// Widget above editor (default) +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); +// Widget below editor +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" }); +ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); +ctx.ui.setWidget("my-widget", undefined); // Clear + +// Custom footer (replaces built-in footer entirely) +ctx.ui.setFooter((tui, theme) => ({ + render(width) { return [theme.fg("dim", "Custom footer")]; }, + invalidate() {}, +})); +ctx.ui.setFooter(undefined); // Restore built-in footer + +// Terminal title +ctx.ui.setTitle("pi - my-project"); + +// Editor text +ctx.ui.setEditorText("Prefill text"); +const current = ctx.ui.getEditorText(); + +// Paste into editor (triggers paste handling, including collapse for large content) +ctx.ui.pasteToEditor("pasted content"); + +// Stack custom autocomplete behavior on top of the built-in provider +ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, line, col, options) { + const beforeCursor = (lines[line] ?? "").slice(0, col); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, line, col, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }], + }; + }, + applyCompletion(lines, line, col, item, prefix) { + return current.applyCompletion(lines, line, col, item, prefix); + }, + shouldTriggerFileCompletion(lines, line, col) { + return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; + }, +})); + +// Tool output expansion +const wasExpanded = ctx.ui.getToolsExpanded(); +ctx.ui.setToolsExpanded(true); +ctx.ui.setToolsExpanded(wasExpanded); + +// Custom editor (vim mode, emacs mode, etc.) +ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings)); +const currentEditor = ctx.ui.getEditorComponent(); +ctx.ui.setEditorComponent((tui, theme, keybindings) => + new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings)) +); +ctx.ui.setEditorComponent(undefined); // Restore default editor + +// Theme management (see themes.md for creating themes) +const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...] +const lightTheme = ctx.ui.getTheme("light"); // Load without switching +const result = ctx.ui.setTheme("light"); // Switch by name +if (!result.success) { + ctx.ui.notify(`Failed: ${result.error}`, "error"); +} +ctx.ui.setTheme(lightTheme!); // Or switch by Theme object +ctx.ui.theme.fg("accent", "styled text"); // Access current theme +``` + +Custom working-indicator frames are rendered verbatim. If you want colors, add them to the frame strings yourself, for example with `ctx.ui.theme.fg(...)`. + +### Autocomplete Providers + +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. + +Typical pattern: + +- inspect the text before the cursor +- return your own suggestions when your extension-specific syntax matches +- otherwise delegate to `current.getSuggestions(...)` +- delegate `applyCompletion(...)` unless you need custom insertion behavior + +```typescript +pi.on("session_start", (_event, ctx) => { + ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + const line = lines[cursorLine] ?? ""; + const beforeCursor = line.slice(0, cursorCol); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [ + { value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" }, + { value: "#2753", label: "#2753", description: "Reload stale resource settings" }, + ], + }; + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + })); +}); +``` + +See [github-issue-autocomplete.ts](../examples/extensions/github-issue-autocomplete.ts) for a complete example that preloads the latest open GitHub issues with `gh issue list` and filters them locally for fast `#...` completion. It requires GitHub CLI (`gh`) and a GitHub repository checkout. + +### Custom Components + +For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: + +```typescript +import { Text, Component } from "@earendil-works/pi-tui"; + +const result = await ctx.ui.custom((tui, theme, keybindings, done) => { + const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); + + text.onKey = (key) => { + if (key === "return") done(true); + if (key === "escape") done(false); + return true; + }; + + return text; +}); + +if (result) { + // User pressed Enter +} +``` + +The callback receives: +- `tui` - TUI instance (for screen dimensions, focus management) +- `theme` - Current theme for styling +- `keybindings` - App keybinding manager (for checking shortcuts) +- `done(value)` - Call to close component and return value + +See [tui.md](tui.md) for the full component API. + +#### Overlay Mode (Experimental) + +Pass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { overlay: true } +); +``` + +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { + overlay: true, + overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, + onHandle: (handle) => { /* handle.setHidden(true/false) */ } + } +); +``` + +See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. + +### Custom Editor + +Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.): + +```typescript +import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { matchesKey } from "@earendil-works/pi-tui"; + +class VimEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + if (matchesKey(data, "escape") && this.mode === "insert") { + this.mode = "normal"; + return; + } + if (this.mode === "normal" && data === "i") { + this.mode = "insert"; + return; + } + super.handleInput(data); // App keybindings + text editing + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + ctx.ui.setEditorComponent((_tui, theme, keybindings) => + new VimEditor(theme, keybindings) + ); + }); +} +``` + +**Key points:** +- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching) +- Call `super.handleInput(data)` for keys you don't handle +- Factory receives `theme` and `keybindings` from the app +- Use `ctx.ui.getEditorComponent()` before `setEditorComponent()` to wrap the previously configured custom editor +- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)` + +To compose with another extension that already replaced the editor, capture the previous factory before setting yours: + +```typescript +const previous = ctx.ui.getEditorComponent(); +ctx.ui.setEditorComponent((tui, theme, keybindings) => + new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) }) +); +``` + +See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator. + +### Message Rendering + +Register a custom renderer for messages with your `customType`: + +```typescript +import { Text } from "@earendil-works/pi-tui"; + +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + const { expanded } = options; + let text = theme.fg("accent", `[${message.customType}] `); + text += message.content; + + if (expanded && message.details) { + text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); + } + + return new Text(text, 0, 0); +}); +``` + +Messages are sent via `pi.sendMessage()`: + +```typescript +pi.sendMessage({ + customType: "my-extension", // Matches registerMessageRenderer + content: "Status update", + display: true, // Show in TUI + details: { ... }, // Available in renderer +}); +``` + +### Theme Colors + +All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette. + +```typescript +// Foreground colors +theme.fg("toolTitle", text) // Tool names +theme.fg("accent", text) // Highlights +theme.fg("success", text) // Success (green) +theme.fg("error", text) // Errors (red) +theme.fg("warning", text) // Warnings (yellow) +theme.fg("muted", text) // Secondary text +theme.fg("dim", text) // Tertiary text + +// Text styles +theme.bold(text) +theme.italic(text) +theme.strikethrough(text) +``` + +For syntax highlighting in custom tool renderers: + +```typescript +import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent"; + +// Highlight code with explicit language +const highlighted = highlightCode("const x = 1;", "typescript", theme); + +// Auto-detect language from file path +const lang = getLanguageFromPath("/path/to/file.rs"); // "rust" +const highlighted = highlightCode(code, lang, theme); +``` + +## Error Handling + +- Extension errors are logged, agent continues +- `tool_call` errors block the tool (fail-safe) +- Tool `execute` errors must be signaled by throwing; the thrown error is caught, reported to the LLM with `isError: true`, and execution continues + +## Mode Behavior + +| Mode | UI Methods | Notes | +| -------------------- | ------------- | ---------------------------------------------- | +| Interactive | Full TUI | Normal operation | +| RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) | +| JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) | +| Print (`-p`) | No-op | Extensions run but can't prompt | + +In non-interactive modes, check `ctx.hasUI` before using UI methods. + +## Examples Reference + +All examples in [examples/extensions/](~/Clones/earendil-works/pi/packages/pi-coding-agent/examples/extensions/). + +| Example | Description | Key APIs | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **Tools** | | | +| `hello.ts` | Minimal tool registration | `registerTool` | +| `question.ts` | Tool with user interaction | `registerTool`, `ui.select` | +| `questionnaire.ts` | Multi-step wizard tool | `registerTool`, `ui.custom` | +| `todo.ts` | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult`, session events | +| `dynamic-tools.ts` | Register tools after startup and during commands | `registerTool`, `session_start`, `registerCommand` | +| `structured-output.ts` | Final structured-output tool with `terminate: true` | `registerTool`, terminating tool results | +| `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` | +| `tool-override.ts` | Override built-in read tool | `registerTool` (same name as built-in) | +| **Commands** | | | +| `pirate.ts` | Modify system prompt per-turn | `registerCommand`, `before_agent_start` | +| `summarize.ts` | Conversation summary command | `registerCommand`, `ui.custom` | +| `handoff.ts` | Cross-provider model handoff | `registerCommand`, `ui.editor`, `ui.custom` | +| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom`, `setEditorText` | +| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` | +| `reload-runtime.ts` | Reload command and LLM tool handoff | `registerCommand`, `ctx.reload()`, `sendUserMessage` | +| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | +| **Events & Gates** | | | +| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | +| `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | +| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | +| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | +| `input-transform.ts` | Transform user input | `on("input")` | +| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` | +| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` | +| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` | +| `claude-rules.ts` | Load rules from files | `on("session_start")`, `on("before_agent_start")` | +| `prompt-customizer.ts` | Add context-aware tool guidance using `systemPromptOptions` | `on("before_agent_start")`, `BuildSystemPromptOptions` | +| `file-trigger.ts` | File watcher triggers messages | `sendMessage` | +| **Compaction & Sessions** | | | +| `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | +| `trigger-compact.ts` | Trigger compaction manually | `compact()` | +| `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` | +| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | +| **UI Components** | | | +| `status-line.ts` | Footer status indicator | `setStatus`, session events | +| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` | +| `github-issue-autocomplete.ts` | Add `#1234` issue completions on top of built-in autocomplete by preloading recent open issues from `gh issue list` | `addAutocompleteProvider`, `on("session_start")`, `exec` | +| `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` | +| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` | +| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` | +| `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` | +| `widget-placement.ts` | Widget above/below editor | `setWidget` | +| `overlay-test.ts` | Overlay components | `ui.custom` with overlay options | +| `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options | +| `notify.ts` | Simple notifications | `ui.notify` | +| `timed-confirm.ts` | Dialogs with timeout | `ui.confirm` with timeout/signal | +| `mac-system-theme.ts` | Auto-switch theme | `setTheme`, `exec` | +| **Complex Extensions** | | | +| `plan-mode/` | Full plan mode implementation | All event types, `registerCommand`, `registerShortcut`, `registerFlag`, `setStatus`, `setWidget`, `sendMessage`, `setActiveTools` | +| `preset.ts` | Saveable presets (model, tools, thinking) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools`, `setThinkingLevel`, `appendEntry` | +| `tools.ts` | Toggle tools on/off UI | `registerCommand`, `setActiveTools`, `SettingsList`, session events | +| **Remote & Sandbox** | | | +| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations | +| `interactive-shell.ts` | Persistent shell session | `on("user_bash")` | +| `sandbox/` | Sandboxed tool execution | Tool operations | +| `subagent/` | Spawn sub-agents | `registerTool`, `exec` | +| **Games** | | | +| `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling | +| `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` | +| `doom-overlay/` | Doom in overlay | `ui.custom` with overlay | +| **Providers** | | | +| `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` | +| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth | +| **Messages & Communication** | | | +| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` | +| `event-bus.ts` | Inter-extension events | `pi.events` | +| **Session Metadata** | | | +| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` | +| `bookmark.ts` | Bookmark entries for /tree | `setLabel` | +| **Misc** | | | +| `inline-bash.ts` | Inline bash in tool calls | `on("tool_call")` | +| `bash-spawn-hook.ts` | Adjust bash command, cwd, and env before execution | `createBashTool`, `spawnHook` | +| `with-deps/` | Extension with npm dependencies | Package structure with `package.json` | diff --git a/memory/PLAN.md b/memory/PLAN.md index ab1be20f..3bbba0af 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -20,17 +20,17 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### Active -1. `graph-data-plane` — M4. SQLite-backed graph persistence; intent-plane nodes/edges; graph clock; change log; coherence-state homes. +1. `pi-ui-extension-patterns` — Continue FE-744 for the POC-critical offer-first custom UI loop: transcript-native structured offer → input-replacing custom response UI → persisted structured response → elicitation-exchange projection. ### Next -1. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. +1. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions. +2. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. ### Parallel / Low-conflict - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `pi-ui-extension-patterns` — Prove the Pi extension seams Brunch needs for lens/review-set UX and Brunch-owned startup/session selection. Command-containment and dynamic chrome evidence have landed. The live continuation is the workspace-switcher/startup-flow proof: a reusable decision UI over coordinator-provided inventory, coordinator activation for continue/open/new-session/new-spec decisions, a pre-Pi TUI gate that prevents implicit stale transcript resume, product-shell hardening for Pi startup noise/chrome metadata, and later an in-session switcher command via Pi modal/session-replacement seams. Can run in parallel with `graph-data-plane` and ahead of `agent-graph-integration`. ### Horizon @@ -221,15 +221,15 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; residual work is qualitative/manual product-shell review and any future Pi command-policy follow-up) -- **Objective:** Demonstrate that Pi's extension seams can host the UI affordances Brunch needs without forking Pi or building a parallel rendering substrate, including both downstream elicitation/review affordances and the immediate Brunch-owned startup/session-selection flow. Catalog and prototype: custom slash commands routed through Brunch handlers; persistent chrome with TUI styling/color/glyphs beyond the current minimal status line; modal/popover overlays for proposal review; radio/checkbox/select prompts for multi-choice answers and user-invoked orientation/selection affordances; clickable/navigable action buttons for accept/request-changes/reject affordances; picker/list-selection modals for spec/session/entity selection; ambient rendering of the latest `brunch.establishment_offer`; and a reusable workspace switcher whose pure UI returns decisions while the `WorkspaceSessionCoordinator` owns inventory, activation, session binding, and `.brunch/state.json` effects. The output is a feasibility matrix mapping each affordance to (a) the Pi seam(s) used, (b) Brunch-owned wrapper code required, (c) controllability cost for the agent-as-user driver, and (d) residual risks — plus minimum-viable wrappers that later frontiers can call directly. -- **Acceptance:** A short design memo (`docs/architecture/pi-ui-extension-patterns.md` or section in `pi-seam-extensions.md`) catalogs the affordance matrix with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`); the matrix distinguishes ambient establishment-offer rendering from any user-invoked orientation view and records that Brunch is not building a default exhaustive lens menu; a runnable demo wires at least one representative of each viable category through Brunch's TUI host (custom slash command, styled chrome element, modal/popover, multi-choice prompt, action button, picker modal, establishment-offer chrome rendering); workspace switcher implementation supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; generic Pi startup resource/update noise is suppressed or documented as residual product-shell risk; the agent-as-user driver can controllably exercise the multi-choice and action-button affordances (informs the controllability/cost answer in `D27-L` and reviewer-flow oracle design); the matrix explicitly records which affordances are unviable so downstream UX design does not assume them; SPEC.md and PLAN.md links to the memo are added where M5/M6/M7 verification depends on a charted affordance. +- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the offer-first custom UI loop) +- **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, an offer-first interaction loop where a system/assistant-originated structured custom entry acts as the assistant turn, renders as transcript-visible state, replaces the default input surface with single-choice / multi-choice / optional-freeform custom UI, and persists the user's structured response as session truth. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, branch policy, session-boundary binding, and `/brunch-workspace`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is an offer-first custom UI proof: a transcript-native unresolved offer can replace ambient free input, collect single-choice / multi-choice / optional-freeform answers, persist a linked structured response entry, project as an elicitation exchange, and expose an RPC/fixture-controllable semantic response path even though TUI `ctx.ui.custom()` itself is not RPC-controllable. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R19, R20, R21 / D2-L, D11-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L / I18-L, I19-L, I22-L / A10-L, A14-L, A17-L, A18-L -- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Workspace inventory, coordinator activation, pure decision UI, pre-Pi startup gate, deliberate chrome surface allocation, in-session `/brunch-workspace` command, startup no-resume pty oracle, and memo reconciliation have landed. Next FE-744 work, if any, should scope qualitative/manual product-shell review or an upstream Pi command/keybinding policy follow-up rather than continuing the exhausted implementation queue. +- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L / A10-L, A14-L, A17-L, A18-L +- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). +- **Current execution pointer:** Scope the offer-first custom UI loop. Use Pi's `question.ts` / `questionnaire.ts` examples and TUI editor-replacement docs as the implementation reference; prove transcript-native offer display, input replacement, response persistence, elicitation-exchange projection, and RPC/fixture semantic controllability before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 411780b7..b39e49d7 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -71,8 +71,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape -16. Brunch must keep sessions elicitation-first: at idle, the user is responding to a system/assistant-originated elicitation prompt rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as optional typed transcript entries, and must be able to project elicitation exchanges from Pi JSONL for observer extraction. +16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. +17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as typed transcript-backed interactions; in TUI mode a pending structured offer may replace the default input surface with custom UI, and other modes must answer the same semantic offer through product handlers or supported dialog fallbacks. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent-mode, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -156,6 +156,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. +- **D37-L — Offer-first custom UI is a transcript-driven input surface, not a side dialog.** A structured system/assistant offer may act as the assistant turn by being persisted as a Brunch custom entry, rendered in transcript history, and mounted as the active response surface while unresolved. In TUI mode, the response surface may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, and optional freeform input, following Pi's `question`/`questionnaire` custom-UI patterns. The user's answer is persisted as a linked structured response entry and projected as the response side of the elicitation exchange. RPC/web paths answer the same semantic pending offer through product handlers or supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L. Supersedes: treating structured prompt UI as optional polish or as an ephemeral dialog result detached from transcript truth. - **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (all system/assistant/tool-side entries since the previous user response, including any structured/internal prompt content) plus a response-side span (user text and/or structured action entries). Role/span alternation is the default projection in Brunch-supported linear sessions; typed markers are added only where structure/actions need deterministic replay. Depends on: D12-L, D24-L. Supersedes: —. - **D14-L — `#`-mentions are ID-anchored, with a session-scoped mention ledger.** Autocomplete may resolve by title but insertion always rewrites to ID-anchored. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: —. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent-mode, not separate agent-modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); agent-modes (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent-mode into one vocabulary axis. @@ -191,6 +192,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | +| I23-L | Every unresolved structured offer that owns the input surface has exactly one terminal response entry (`answered`, `skipped`, or `cancelled`) linked to the offer id before the next agent turn consumes it; response capture is persisted in Pi JSONL and projected as the user-response side of the elicitation exchange rather than held only in UI state. | planned (FE-744 offer-first custom UI tests + RPC/fixture response-path contract) | D12-L, D13-L, D17-L, D37-L | ## Future Direction Register @@ -265,7 +267,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | | **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior user response) plus response-side span (the user's text and/or structured action entries). This is the observer's default extraction unit. | -| **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | +| **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | +| **Structured offer** | A system/assistant-originated Brunch custom entry that acts as the current elicitation prompt and owns the response surface until answered, skipped, or cancelled. In TUI it may replace the default editor with custom UI; in RPC/web it is answered through product handlers over the same semantic payload. | +| **Offer response** | A linked Brunch custom entry recording the user's structured answer to a structured offer, including selected option ids and optional freeform text. It is transcript truth, not an ephemeral UI return value. | | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | A scoped sub-agent invocation whose result returns through the shared command layer. | @@ -395,6 +399,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | +| I23-L | FE-744 offer-first custom UI tests: pending offer mounts an input-replacing TUI response surface, single/multi/freeform answers persist as linked custom entries, RPC/fixture path submits the same semantic response, and elicitation-exchange projection pairs offer prompt side with response side. | ### Design Notes From ebca2825476d27d96983fa8e4ad690b106bac6e7 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 15:14:12 +0200 Subject: [PATCH 034/164] wip on brunch pi extensions --- .pi/components/cards.ts | 131 +++++++ .pi/extensions/brunch-autocomplete.ts | 190 +++++++++++ .pi/extensions/brunch-chrome.ts | 397 ++++++++++++++++++++++ .pi/extensions/brunch-commands.ts | 141 ++++++++ .pi/extensions/brunch-messages.ts | 327 ++++++++++++++++++ .pi/extensions/brunch-tags.json | 47 +++ memory/SPEC.md | 5 + package.json | 17 +- src/brunch-tui.test.ts | 11 +- src/brunch.test.ts | 14 +- src/elicitation-exchange.test.ts | 53 ++- src/fixture-capture.test.ts | 27 +- src/jsonl-session-viability.test.ts | 42 +-- src/rpc.test.ts | 41 +-- src/test-helpers.ts | 67 ++++ src/web-client/app.test.tsx | 8 +- src/web-client/rpc-client.test.ts | 2 +- src/web-host.test.ts | 28 +- src/workspace-session-coordinator.test.ts | 61 ++-- src/workspace-switcher.test.ts | 18 +- tsconfig.build.json | 13 + tsconfig.json | 20 +- 22 files changed, 1474 insertions(+), 186 deletions(-) create mode 100644 .pi/components/cards.ts create mode 100644 .pi/extensions/brunch-autocomplete.ts create mode 100644 .pi/extensions/brunch-chrome.ts create mode 100644 .pi/extensions/brunch-commands.ts create mode 100644 .pi/extensions/brunch-messages.ts create mode 100644 .pi/extensions/brunch-tags.json create mode 100644 src/test-helpers.ts create mode 100644 tsconfig.build.json diff --git a/.pi/components/cards.ts b/.pi/components/cards.ts new file mode 100644 index 00000000..f0d95974 --- /dev/null +++ b/.pi/components/cards.ts @@ -0,0 +1,131 @@ +/** + * Cards — pi-tui rendering primitives for bordered card layouts. + * + * Pure library module. Lives outside `.pi/extensions/` because it registers + * nothing with Pi; it is consumed by extensions (e.g. `brunch-messages.ts`) + * that compose these primitives into custom message renderers. + * + * Components here should remain stateless and stitch only pi-tui primitives. + */ + +import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent" +import { getMarkdownTheme } from "@earendil-works/pi-coding-agent" +import { + type Component, + Markdown, + visibleWidth, + truncateToWidth, +} from "@earendil-works/pi-tui" + +/** + * Lay components out side-by-side and fall back to vertical stacking once the + * per-column width drops below `minChildWidth`. + */ +export class ResponsiveColumns implements Component { + constructor( + private children: Component[], + private minChildWidth: number = 40, + private gap: number = 2, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + if (this.children.length === 0) return [] + if (this.children.length === 1) return this.children[0]!.render(width) + + const n = this.children.length + const totalGap = this.gap * (n - 1) + const perChild = Math.floor((width - totalGap) / n) + + // Too narrow for columns — stack vertically. + if (perChild < this.minChildWidth) { + const lines: string[] = [] + this.children.forEach((c, i) => { + if (i > 0) lines.push("") + lines.push(...c.render(width)) + }) + return lines + } + + const grids = this.children.map((c) => c.render(perChild)) + const rowCount = Math.max(...grids.map((g) => g.length)) + + // Pad shorter columns with blank lines so all columns share rowCount. + const blank = " ".repeat(perChild) + const padded = grids.map((g) => { + const result = [...g] + while (result.length < rowCount) result.push(blank) + return result + }) + + // Stitch rows. Each line is padded to perChild visible width before joining. + const gapStr = " ".repeat(this.gap) + const lines: string[] = [] + for (let r = 0; r < rowCount; r++) { + const parts = padded.map((g) => { + const line = g[r] ?? blank + const vis = visibleWidth(line) + const padding = vis < perChild ? " ".repeat(perChild - vis) : "" + return line + padding + }) + lines.push(parts.join(gapStr)) + } + return lines + } +} + +/** + * A titled, bordered card with a Markdown body. The title sits inside the top + * border and the body fills the inner column at the requested width. + */ +export class CardComponent implements Component { + constructor( + private title: string, + private body: string, + private theme: Theme, + private accent: ThemeColor = "accent", + ) {} + + invalidate(): void { + // Stateless render: nothing to invalidate. + } + + render(width: number): string[] { + // 4 = "│ " (2) + " │" (2). Markdown fills the inner column. + const innerWidth = Math.max(10, width - 4) + const bodyLines = new Markdown(this.body, 0, 0, getMarkdownTheme()).render( + innerWidth, + ) + + const c = (s: string) => this.theme.fg(this.accent, s) + const titleText = ` ${this.theme.bold(this.title)} ` + const titleVis = visibleWidth(titleText) + + // Top: ╭─ Title ──...──╮ + const topFiller = Math.max(0, width - 2 - 1 - titleVis) // border corners (2) + opening dash (1) + const top = c("╭─") + titleText + c("─".repeat(topFiller) + "╮") + + // Bottom: ╰────────────╯ + const bottom = c("╰" + "─".repeat(Math.max(0, width - 2)) + "╯") + + // Body: │ │ + const sided = bodyLines.map((line) => { + const vis = visibleWidth(line) + const padding = vis < innerWidth ? " ".repeat(innerWidth - vis) : "" + // If a markdown line exceeds innerWidth, truncate to avoid wrapping. + const safeLine = + vis > innerWidth ? truncateToWidth(line, innerWidth) : line + padding + return c("│ ") + safeLine + c(" │") + }) + + return [top, ...sided, bottom] + } +} + +/** Split an array into fixed-size chunks; last chunk may be shorter. */ +export function chunk(arr: T[], size: number): T[][] { + const out: T[][] = [] + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)) + return out +} diff --git a/.pi/extensions/brunch-autocomplete.ts b/.pi/extensions/brunch-autocomplete.ts new file mode 100644 index 00000000..be739d12 --- /dev/null +++ b/.pi/extensions/brunch-autocomplete.ts @@ -0,0 +1,190 @@ +/** + * Brunch — autocomplete (`#`-tag provider) + * + * Middleware-style autocomplete provider over `ctx.ui.addAutocompleteProvider`. + * Triggers on `#` tokens at the cursor; delegates everything else + * (file completion, slash commands, etc.) to the wrapped provider. + * + * TEMPORARY: tag candidates currently load from a co-located JSON file at + * /.pi/extensions/brunch-tags.json + * This is a stand-in until the autocomplete source is wired to brunch graph + * items (intent/oracle/design/plan nodes) and `#`-mentions become ID-anchored + * per SPEC.md D14-L / I9-L. Treat this file as throwaway scaffolding for the + * autocomplete seam; do not grow product semantics on top of the JSON store. + * + * Companion command: + * /brunch-tags-edit open the JSON tag list in `ctx.ui.editor()` + */ + +import { readFile, writeFile, access } from "node:fs/promises" +import { join } from "node:path" + +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent" +import type { + AutocompleteItem, + AutocompleteSuggestions, +} from "@earendil-works/pi-tui" + +interface BrunchTag { + value: string // inserted text (without the leading '#') + label: string // display label + description?: string +} + +const SEED_TAGS: BrunchTag[] = [ + { + value: "breakfast", + label: "Breakfast", + description: "First meal of the day", + }, + { value: "brunch", label: "Brunch", description: "Late morning treat" }, + { value: "coffee", label: "Coffee", description: "Morning fuel" }, + { value: "croissant", label: "Croissant", description: "Flaky pastry" }, + { + value: "eggs-benedict", + label: "Eggs Benedict", + description: "With hollandaise", + }, + { value: "mimosa", label: "Mimosa", description: "OJ + champagne" }, + { value: "pancakes", label: "Pancakes", description: "Fluffy stack" }, + { value: "toast", label: "Toast", description: "Crispy bread" }, + { value: "waffles", label: "Waffles", description: "Grid-shaped breakfast" }, +] + +// Co-located with the extension source so editing the file (in any editor) +// takes effect on the next autocomplete invocation. +function tagsPath(ctx: ExtensionContext): string { + return join(ctx.cwd, ".pi", "extensions", "brunch-tags.json") +} + +async function ensureTagsFile(ctx: ExtensionContext): Promise { + const path = tagsPath(ctx) + try { + await access(path) + } catch { + await writeFile(path, JSON.stringify(SEED_TAGS, null, 2), "utf8") + } +} + +async function loadTags(ctx: ExtensionContext): Promise { + try { + const raw = await readFile(tagsPath(ctx), "utf8") + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (t): t is BrunchTag => + t && typeof t.value === "string" && typeof t.label === "string", + ) + } catch { + return [] + } +} + +// Extract a `#` token at the cursor. Returns the matched prefix +// (including the `#`) or null if the cursor is not inside such a token. +function extractHashPrefix(line: string, cursorCol: number): string | null { + const before = line.slice(0, cursorCol) + // `#` preceded by start-of-line or whitespace, followed by [A-Za-z0-9_-]* + const match = before.match(/(?:^|\s)(#[\w-]*)$/) + return match?.[1] ?? null +} + +export default function brunchAutocomplete(pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + await ensureTagsFile(ctx) + + ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + const line = lines[cursorLine] ?? "" + const prefix = extractHashPrefix(line, cursorCol) + + if (prefix === null) { + // Not our trigger — hand off to the wrapped provider. + return current.getSuggestions(lines, cursorLine, cursorCol, options) + } + + const query = prefix.slice(1).toLowerCase() // strip leading '#' + const tags = await loadTags(ctx) // re-read JSON every time + + const filtered = + query.length === 0 + ? tags + : tags.filter((t) => t.value.toLowerCase().includes(query)) + + const items: AutocompleteItem[] = filtered.map((t) => ({ + value: `#${t.value}`, + label: `#${t.label}`, + ...(t.description !== undefined + ? { description: t.description } + : {}), + })) + + const result: AutocompleteSuggestions = { items, prefix } + return result + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + // If the prefix isn't a '#' token, let the wrapped provider handle it. + if (!prefix.startsWith("#")) { + return current.applyCompletion( + lines, + cursorLine, + cursorCol, + item, + prefix, + ) + } + + const line = lines[cursorLine] ?? "" + const before = line.slice(0, cursorCol) + const after = line.slice(cursorCol) + // Replace the trailing `prefix` (e.g. "#br") with the chosen value. + const newBefore = before.slice(0, -prefix.length) + item.value + const newLine = newBefore + after + + return { + lines: lines.map((l, i) => (i === cursorLine ? newLine : l)), + cursorLine, + cursorCol: newBefore.length, + } + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + // Never hijack file completion (the `@` trigger); + // delegate the decision to the wrapped provider. + return ( + current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? + false + ) + }, + })) + }) + + // Convenience: edit the tag JSON in the system editor without leaving pi. + pi.registerCommand("brunch-tags-edit", { + description: "Edit the brunch autocomplete tag list (JSON)", + handler: async (_args, ctx) => { + await ensureTagsFile(ctx) + const path = tagsPath(ctx) + const current = await readFile(path, "utf8") + const edited = await ctx.ui.editor(`Edit ${path}`, current) + if (edited === undefined) { + ctx.ui.notify("Edit cancelled", "info") + return + } + try { + const parsed = JSON.parse(edited) + if (!Array.isArray(parsed)) + throw new Error("top-level must be a JSON array") + } catch (err) { + ctx.ui.notify(`Invalid JSON: ${(err as Error).message}`, "error") + return + } + await writeFile(path, edited, "utf8") + ctx.ui.notify("Tags saved", "info") + }, + }) +} diff --git a/.pi/extensions/brunch-chrome.ts b/.pi/extensions/brunch-chrome.ts new file mode 100644 index 00000000..6c75db77 --- /dev/null +++ b/.pi/extensions/brunch-chrome.ts @@ -0,0 +1,397 @@ +/** + * Brunch — chrome (sandbox: header + footer) + * + * Owns Pi's header and footer surfaces as the only Brunch chrome wrapper. + * Deliberately scoped to what we can render *honestly* today, with no + * speculation about a Brunch state schema we haven't designed yet. + * + * Division of labor between Pi's chrome surfaces: + * + * HEADER = identity / "where am I". Static-ish; replaced rarely. + * Brand + version + cwd. Not for runtime telemetry. + * FOOTER = runtime telemetry / "what's happening". Updated on every render. + * Brunch workspace identity + current spec + git branch + model / + * thinking + context-window gauge + foreign status entries. + * STATUS = lateral contribution channel for *other* extensions and future + * dynamic Brunch state. This file does NOT call `setStatus`. The + * footer compositor merges `footerData.getExtensionStatuses()` so + * foreign keys surface in the footer without anyone needing to own + * the whole footer. + * TITLE / HIDDEN-THINKING-LABEL = deferred. See SPEC.md + * "Chrome surface evolution": both are state-indicative surfaces + * that require canonical Brunch state to drive them. We don't have + * that schema yet, so these stay at Pi defaults. + * + * What's NOT in this file (and why): + * - No `BrunchChromeState` snapshot. The coordinator's + * `WorkspaceSessionChromeState` (cwd / spec / phase / chatMode) is the + * only canonical chrome state with a real producer, and the sandbox does + * not currently wire the coordinator in. Until it does, this extension + * renders only `ctx`-derived facts. + * - No speculative fields (lens, coherence verdict, worker statuses, + * reconciliation needs, establishment offer summaries). Those correspond + * to subsystems that don't exist yet. + * - No mutation theater. Without a real producer there's nothing to mutate. + * + */ + +import { execSync } from "node:child_process" +import { readFileSync } from "node:fs" +import path from "node:path" + +import type { + ExtensionAPI, + ExtensionContext, + Theme, +} from "@earendil-works/pi-coding-agent" +import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" + +const SESSION_BINDING_TYPE = "brunch.session_binding" +const STATE_SCHEMA_VERSION = 1 +const CONTEXT_GAUGE_WIDTH = 12 +const BAR_FILLED = "━" +const BAR_EMPTY = "─" + +// Pre-generated with: cfonts "brunch" -f tiny -c candy +const BRUNCH_WORDMARK = + "\x1b[33m \x1b[39m\x1b[32m█▄▄\x1b[39m\x1b[33m \x1b[39m\x1b[95m█▀█\x1b[39m\x1b[33m \x1b[39m\x1b[95m█ █\x1b[39m\x1b[33m \x1b[39m\x1b[31m█▄ █\x1b[39m\x1b[33m \x1b[39m\x1b[94m█▀▀\x1b[39m\x1b[33m \x1b[39m\x1b[32m█ █\x1b[39m\n" + + "\x1b[96m \x1b[39m\x1b[91m█▄█\x1b[39m\x1b[96m \x1b[39m\x1b[93m█▀▄\x1b[39m\x1b[96m \x1b[39m\x1b[31m█▄█\x1b[39m\x1b[96m \x1b[39m\x1b[92m█ ▀█\x1b[39m\x1b[96m \x1b[39m\x1b[96m█▄▄\x1b[39m\x1b[96m \x1b[39m\x1b[96m█▀█\x1b[39m" + +const LOCAL_BUILD_TIME = formatBuildTime(new Date()) +const ESC = String.fromCharCode(27) + +type BrunchSpecIdentity = { + id: string + title: string +} + +type WorkspaceStateFile = { + schemaVersion?: unknown + currentSpec?: { + id?: unknown + title?: unknown + } +} + +type PackageJson = { + version?: unknown + private?: unknown +} + +function formatBuildTime(date: Date): string { + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, " UTC") +} + +function getGitSha(cwd: string): string { + try { + return execSync("git rev-parse --short=7 HEAD", { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() + } catch { + return "" + } +} + +function readPackage(cwd: string): PackageJson { + try { + return JSON.parse( + readFileSync(path.join(cwd, "package.json"), "utf8"), + ) as PackageJson + } catch { + return {} + } +} + +function brunchVersion(cwd: string): string { + const pkg = readPackage(cwd) + const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" + const isLocalDev = pkg.private === true || version === "0.0.0" + if (!isLocalDev) return `v${version}` + + const gitSha = getGitSha(cwd) + const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") + return `v${version} (${devMeta ? `dev ${devMeta}` : "dev"})` +} + +function readLogo(cwd: string): string[] { + try { + return readFileSync( + path.join(cwd, "assets", "brunch-logo-quad-56x18.ansi"), + "utf8", + ) + .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") + .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") + .split("\n") + .filter((line) => line.length > 0) + } catch { + return [] + } +} + +function shortenPath(p: string): string { + const home = process.env.HOME ?? process.env.USERPROFILE + if (home && p.startsWith(home)) return `~${p.slice(home.length)}` + return p +} + +function sanitizeStatusText(text: string): string { + return text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim() +} + +function formatTokens(count: number): string { + if (count < 1000) return count.toString() + if (count < 10000) return `${(count / 1000).toFixed(1)}k` + if (count < 1000000) return `${Math.round(count / 1000)}k` + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M` + return `${Math.round(count / 1000000)}M` +} + +function readWorkspaceSpec(cwd: string): BrunchSpecIdentity | null { + try { + const parsed = JSON.parse( + readFileSync(path.join(cwd, ".brunch", "state.json"), "utf8"), + ) as WorkspaceStateFile + if ( + parsed.schemaVersion === STATE_SCHEMA_VERSION && + typeof parsed.currentSpec?.id === "string" && + typeof parsed.currentSpec.title === "string" + ) { + return { id: parsed.currentSpec.id, title: parsed.currentSpec.title } + } + } catch { + // No selected Brunch workspace state yet. + } + return null +} + +function readSessionBindingSpec( + ctx: ExtensionContext, +): BrunchSpecIdentity | null { + const entries = ctx.sessionManager.getEntries() + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index] + if ( + entry?.type === "custom" && + entry.customType === SESSION_BINDING_TYPE && + typeof entry.data === "object" && + entry.data !== null && + typeof (entry.data as { specId?: unknown }).specId === "string" && + typeof (entry.data as { specTitle?: unknown }).specTitle === "string" + ) { + return { + id: (entry.data as { specId: string }).specId, + title: (entry.data as { specTitle: string }).specTitle, + } + } + } + return null +} + +function currentSpec(ctx: ExtensionContext): BrunchSpecIdentity | null { + return readWorkspaceSpec(ctx.cwd) ?? readSessionBindingSpec(ctx) +} + +function renderContextGauge(ctx: ExtensionContext, theme: Theme): string { + const usage = ctx.getContextUsage() + const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0 + const percent = usage?.percent ?? null + const tokens = usage?.tokens ?? null + + const clamped = Math.max(0, Math.min(100, percent ?? 0)) + const filled = + percent === null ? 0 : Math.round((clamped / 100) * CONTEXT_GAUGE_WIDTH) + const empty = CONTEXT_GAUGE_WIDTH - filled + const color = clamped >= 90 ? "error" : clamped >= 70 ? "warning" : "accent" + const bar = + theme.fg(color, BAR_FILLED.repeat(filled)) + + theme.fg("dim", BAR_EMPTY.repeat(empty)) + const percentText = percent === null ? "?%" : `${Math.round(clamped)}%` + const counts = + tokens === null || contextWindow === 0 + ? `?/${formatTokens(contextWindow)}` + : `${formatTokens(tokens)}/${formatTokens(contextWindow)}` + + return `${theme.fg("dim", "ctx ")}${bar} ${theme.fg("dim", `${percentText} ${counts}`)}` +} + +function rightAlign(left: string, right: string, width: number): string { + const leftWidth = visibleWidth(left) + const rightWidth = visibleWidth(right) + const minPadding = 2 + if (leftWidth + minPadding + rightWidth <= width) { + return left + " ".repeat(width - leftWidth - rightWidth) + right + } + + const availableForRight = width - leftWidth - minPadding + if (availableForRight <= 0) return truncateToWidth(left, width) + const truncatedRight = truncateToWidth(right, availableForRight, "") + return ( + left + + " ".repeat(Math.max(2, width - leftWidth - visibleWidth(truncatedRight))) + + truncatedRight + ) +} + +// ── Header ───────────────────────────────────────────────────────────── +function installHeader(ctx: ExtensionContext): void { + if (!ctx.hasUI) return + + const logoLines = readLogo(ctx.cwd) + const wordmarkLines = BRUNCH_WORDMARK.split("\n") + + ctx.ui.setHeader((_tui, theme) => ({ + render: (width: number) => { + const version = theme.fg("muted", brunchVersion(ctx.cwd)) + const piLine = theme.fg("dim", `built in Pi v${PI_VERSION}`) + const cwdLine = theme.fg( + "dim", + `cwd: ${shortenPath(path.resolve(ctx.cwd))}`, + ) + const textBlock = [ + ...wordmarkLines, + `${theme.fg("dim", "brunch")} ${version}`, + piLine, + cwdLine, + ] + + if (logoLines.length === 0 || width < 88) { + return [ + ...wordmarkLines.map((line) => truncateToWidth(line, width)), + truncateToWidth(`${theme.fg("dim", "brunch")} ${version}`, width), + truncateToWidth(piLine, width), + truncateToWidth(cwdLine, width), + "", + ] + } + + const logoWidth = Math.max(...logoLines.map((line) => visibleWidth(line))) + const gap = " " + const lines: string[] = [] + const maxLines = Math.max(logoLines.length, textBlock.length) + for (let index = 0; index < maxLines; index += 1) { + const logo = logoLines[index] ?? "" + const paddedLogo = + logo + " ".repeat(Math.max(0, logoWidth - visibleWidth(logo))) + const text = textBlock[index] ?? "" + lines.push(truncateToWidth(`${paddedLogo}${gap}${text}`, width)) + } + lines.push("") + return lines + }, + invalidate: () => {}, + })) +} + +// ── Footer ───────────────────────────────────────────────────────────── +function installFooter( + ctx: ExtensionContext, + pi: ExtensionAPI, + setRequestFooterRender: (requestRender: (() => void) | null) => void, +): void { + if (!ctx.hasUI) return + + ctx.ui.setFooter((tui, theme, footerData) => { + // Re-render whenever the git branch changes — free signal Pi already + // provides. Model/thinking changes are handled by extension-level event + // listeners below. + setRequestFooterRender(() => tui.requestRender()) + const unsub = footerData.onBranchChange(() => tui.requestRender()) + + return { + dispose: () => { + unsub() + setRequestFooterRender(null) + }, + invalidate: () => {}, + render: (width: number): string[] => { + const branch = footerData.getGitBranch() + const spec = currentSpec(ctx) + const locationParts = [ + theme.fg("accent", shortenPath(path.resolve(ctx.cwd))), + spec + ? `${theme.fg("dim", "spec:")} ${theme.fg("muted", spec.title)}` + : theme.fg("dim", "spec: none"), + branch + ? `${theme.fg("dim", "branch:")} ${theme.fg("muted", branch)}` + : "", + ].filter(Boolean) + const locationLine = truncateToWidth( + locationParts.join(theme.fg("dim", " · ")), + width, + theme.fg("dim", "..."), + ) + + const modelName = ctx.model?.id ?? "no-model" + const thinkingLevel = pi.getThinkingLevel() + let modelLabel = modelName + if (ctx.model?.reasoning) { + modelLabel = + thinkingLevel === "off" + ? `${modelName} • thinking off` + : `${modelName} • ${thinkingLevel}` + } + if (footerData.getAvailableProviderCount() > 1 && ctx.model) { + modelLabel = `(${ctx.model.provider}) ${modelLabel}` + } + + const context = renderContextGauge(ctx, theme) + const telemetryLine = rightAlign( + context, + theme.fg("dim", modelLabel), + width, + ) + + const lines = [locationLine, telemetryLine] + + const extensionStatuses = footerData.getExtensionStatuses() + if (extensionStatuses.size > 0) { + const statusLine = Array.from(extensionStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => sanitizeStatusText(text)) + .filter(Boolean) + .join(" ") + if (statusLine.length > 0) { + lines.push( + truncateToWidth(statusLine, width, theme.fg("dim", "...")), + ) + } + } + + return lines + }, + } + }) +} + +// ── Extension entry ──────────────────────────────────────────────────── +export default function brunchChrome(pi: ExtensionAPI) { + let requestFooterRender: (() => void) | null = null + + pi.on("session_start", async (_event, ctx) => { + installHeader(ctx) + installFooter(ctx, pi, (requestRender) => { + requestFooterRender = requestRender + }) + }) + + pi.on("model_select", async () => { + requestFooterRender?.() + }) + + pi.on("thinking_level_select", async () => { + requestFooterRender?.() + }) + + pi.on("turn_end", async () => { + requestFooterRender?.() + }) +} diff --git a/.pi/extensions/brunch-commands.ts b/.pi/extensions/brunch-commands.ts new file mode 100644 index 00000000..57ad5fa0 --- /dev/null +++ b/.pi/extensions/brunch-commands.ts @@ -0,0 +1,141 @@ +/** + * Brunch — commands + * + * Slash commands and shortcuts. Currently exercises Pi's `ctx.ui.custom()` + * with the shipped `SettingsList` widget as a placeholder for richer Brunch + * dialogs. State is module-scoped, which means it resets on `/reload`; if/when + * persistence matters, write a custom session entry on change and rehydrate on + * `session_start`. + * + * Activate via: + * /brunch slash command + * ctrl+shift+b keyboard shortcut + * + * (The previous `ctrl+b` alias has been removed because it collided with + * `tui.editor.cursorLeft`.) + */ + +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent" +import { getSettingsListTheme } from "@earendil-works/pi-coding-agent" +import { SettingsList, type SettingItem } from "@earendil-works/pi-tui" + +interface BrunchState { + drink: string + eggs: string + toast: string + hashBrowns: string + mood: string +} + +export default function brunchCommands(pi: ExtensionAPI) { + // Module-scoped — reset on `/reload`. See header comment. + const state: BrunchState = { + drink: "Coffee", + eggs: "Scrambled", + toast: "Sourdough", + hashBrowns: "Yes", + mood: "Leisurely", + } + + function buildItems(): SettingItem[] { + return [ + { + id: "drink", + label: "Drink", + description: "What's in your glass or mug?", + currentValue: state.drink, + values: ["Coffee", "Tea", "Juice", "Mimosa", "Water"], + }, + { + id: "eggs", + label: "Eggs", + description: "How would you like your eggs?", + currentValue: state.eggs, + values: [ + "Scrambled", + "Poached", + "Fried", + "Over Easy", + "Omelette", + "None", + ], + }, + { + id: "toast", + label: "Toast", + description: "Bread choice", + currentValue: state.toast, + values: ["Sourdough", "White", "Rye", "Multigrain", "None"], + }, + { + id: "hashBrowns", + label: "Hash Browns", + description: "Always a good idea", + currentValue: state.hashBrowns, + values: ["Yes", "No"], + }, + { + id: "mood", + label: "Mood", + description: "Pacing for the meal", + currentValue: state.mood, + values: ["Leisurely", "Focused", "Chatty", "Quiet"], + }, + ] + } + + function summarize(): string { + return `🥐 ${state.drink} · ${state.eggs} eggs · ${state.toast} · Hash browns: ${state.hashBrowns} · ${state.mood}` + } + + async function openBrunch(ctx: ExtensionContext) { + if (!ctx.hasUI) { + ctx.ui?.notify?.("Brunch settings require UI mode", "warning") + return + } + + await ctx.ui.custom((_tui, _theme, _kb, done) => { + const items = buildItems() + const list = new SettingsList( + items, + 10, // maxVisible: rows shown at once + getSettingsListTheme(), + (id, newValue) => { + // Mirror the picked value into module state. The list updates its + // own currentValue display internally. + if (id === "drink") state.drink = newValue + else if (id === "eggs") state.eggs = newValue + else if (id === "toast") state.toast = newValue + else if (id === "hashBrowns") state.hashBrowns = newValue + else if (id === "mood") state.mood = newValue + }, + () => done(), + { enableSearch: true }, + ) + + return { + render: (width: number) => list.render(width), + invalidate: () => list.invalidate(), + handleInput: (data: string) => list.handleInput(data), + } + }) + + // After dismissal, surface the current selection as a transient toast. + // Persistent chrome (status/widget/header/footer) is deliberately not + // touched from here — it lives in `brunch-chrome.ts`. + ctx.ui.notify(summarize(), "info") + } + + pi.registerCommand("brunch", { + description: "Open the brunch settings selector", + handler: async (_args, ctx) => openBrunch(ctx), + }) + + pi.registerShortcut("ctrl+shift+b", { + description: "Open brunch settings", + handler: async (ctx) => openBrunch(ctx), + }) +} diff --git a/.pi/extensions/brunch-messages.ts b/.pi/extensions/brunch-messages.ts new file mode 100644 index 00000000..908ee1e6 --- /dev/null +++ b/.pi/extensions/brunch-messages.ts @@ -0,0 +1,327 @@ +/** + * Brunch — custom messages + * + * Owns the `alternatives-card-set` custom message type end-to-end: + * - registerMessageRenderer to draw bordered cards in the transcript + * - registerTool (`present_alternatives`) so the LLM can emit a card set + * - demo slash commands that emit card sets directly for visual smoke + * + * Compared with an ephemeral picker (e.g. `ctx.ui.custom`), this surface + * PRESENTS alternatives via `pi.sendMessage` — persistent, returns + * immediately, no UI focus stolen — and is the closest existing precedent for + * the offer-first transcript-native loop tracked under FE-744 (D37-L / I23-L). + * + * Activate: + * /cards-demo three sample alternatives + * /cards-columns-demo four cards in a 2-column layout + * /cards-flavors one card per flavor (accent/success/warning/muted) + */ + +import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent" +import { Container, Text } from "@earendil-works/pi-tui" +import { StringEnum } from "@earendil-works/pi-ai" +import { Type } from "typebox" + +import { CardComponent, ResponsiveColumns, chunk } from "../components/cards.js" + +// ── Types & schema ───────────────────────────────────────────────────── +const FLAVOR = StringEnum(["accent", "success", "warning", "muted"] as const) +type Flavor = "accent" | "success" | "warning" | "muted" + +interface Alternative { + title: string + body: string + flavor?: Flavor +} + +type Layout = "stack" | "columns" + +interface AlternativesDetails { + headline?: string | undefined + alternatives: Alternative[] + layout?: Layout | undefined + columnCount?: number | undefined + minColumnWidth?: number | undefined +} + +const AlternativeSchema = Type.Object({ + title: Type.String({ description: "Short label for the card header" }), + body: Type.String({ + description: "Markdown content rendered inside the card", + }), + flavor: Type.Optional(FLAVOR), +}) + +const LAYOUT = StringEnum(["stack", "columns"] as const) + +const PresentAlternativesParams = Type.Object({ + headline: Type.Optional( + Type.String({ description: "Optional headline shown above the cards" }), + ), + alternatives: Type.Array(AlternativeSchema, { minItems: 1, maxItems: 6 }), + layout: Type.Optional(LAYOUT), + columnCount: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 4, + description: "Cards per row when layout is 'columns'. Default 2.", + }), + ), + minColumnWidth: Type.Optional( + Type.Integer({ + minimum: 20, + maximum: 200, + description: + "Minimum width per card before falling back to vertical stack. Default 40.", + }), + ), +}) + +function flavorToColor(flavor: Flavor | undefined): ThemeColor { + switch (flavor) { + case "success": + return "success" + case "warning": + return "warning" + case "muted": + return "muted" + default: + return "accent" + } +} + +// Plain-markdown fallback so RPC clients without the renderer still see +// coherent content. Also persisted as the message `content` field. +function alternativesToMarkdown(details: AlternativesDetails): string { + const sections: string[] = [] + if (details.headline) sections.push(`## ${details.headline}`) + for (const alt of details.alternatives) { + sections.push(`### ${alt.title}\n\n${alt.body}`) + } + return sections.join("\n\n---\n\n") +} + +export default function brunchMessages(pi: ExtensionAPI) { + // ── Renderer ──────────────────────────────────────────────────────── + pi.registerMessageRenderer( + "alternatives-card-set", + (message, _opts, theme) => { + const details = message.details as AlternativesDetails | undefined + if (!details) { + // Fallback: if details is missing, render the raw content string. + return new Text( + typeof message.content === "string" ? message.content : "", + 0, + 0, + ) + } + + const container = new Container() + if (details.headline) { + container.addChild( + new Text( + theme.fg("customMessageLabel", theme.bold(details.headline)), + 1, + 1, + ), + ) + } + + const layout = details.layout ?? "stack" + const columnCount = Math.max(1, Math.min(4, details.columnCount ?? 2)) + const minColumnWidth = details.minColumnWidth ?? 40 + + const makeCard = (alt: Alternative) => + new CardComponent(alt.title, alt.body, theme, flavorToColor(alt.flavor)) + + if (layout === "columns" && details.alternatives.length > 1) { + const groups = chunk(details.alternatives, columnCount) + groups.forEach((group, gi) => { + container.addChild( + new ResponsiveColumns(group.map(makeCard), minColumnWidth), + ) + if (gi < groups.length - 1) container.addChild(new Text("", 0, 0)) + }) + } else { + details.alternatives.forEach((alt, i) => { + container.addChild(makeCard(alt)) + if (i < details.alternatives.length - 1) + container.addChild(new Text("", 0, 0)) + }) + } + return container + }, + ) + + // ── Tool ──────────────────────────────────────────────────────────── + pi.registerTool({ + name: "present_alternatives", + label: "Present Alternatives", + description: + "Present 1–6 alternative options to the user as bordered cards. Each alternative has a short title and a markdown body. Optional `flavor` (accent/success/warning/muted) styles the card border. Use when comparing options, surfacing draft variants, or laying out trade-offs.", + promptSnippet: + "Present comparable alternatives as bordered cards in the transcript", + promptGuidelines: [ + "Use present_alternatives when the user needs to compare 2–6 options side by side.", + "Each alternative's body should be self-contained markdown — headings, lists, code blocks all work.", + "After present_alternatives, ask the user which one they prefer rather than picking yourself.", + ], + parameters: PresentAlternativesParams, + + async execute(_toolCallId, params) { + const details: AlternativesDetails = { + headline: params.headline, + alternatives: params.alternatives, + layout: params.layout, + columnCount: params.columnCount, + minColumnWidth: params.minColumnWidth, + } + + pi.sendMessage({ + customType: "alternatives-card-set", + content: alternativesToMarkdown(details), // fallback / replay + display: true, + details, + }) + + return { + content: [ + { + type: "text", + text: `Presented ${params.alternatives.length} alternative${ + params.alternatives.length === 1 ? "" : "s" + }.`, + }, + ], + details: { count: params.alternatives.length }, + terminate: true, + } + }, + }) + + // ── Demo commands ─────────────────────────────────────────────────── + pi.registerCommand("cards-demo", { + description: "Render three sample alternative cards in the transcript", + handler: async (_args, _ctx) => { + const details: AlternativesDetails = { + headline: "Three approaches to caching", + alternatives: [ + { + title: "In-memory LRU", + flavor: "accent", + body: [ + "**Pros**", + "- Zero deploy overhead", + "- Sub-millisecond access", + "", + "**Cons**", + "- Lost on restart", + "- Not shared across replicas", + "", + "```ts", + "const cache = new LRU({ max: 1000 });", + "```", + ].join("\n"), + }, + { + title: "Redis", + flavor: "success", + body: [ + "**Pros**", + "- Survives restarts", + "- Shared across replicas", + "- Battle-tested", + "", + "**Cons**", + "- New infra to operate", + "- Network hop on every read", + ].join("\n"), + }, + { + title: "Filesystem", + flavor: "warning", + body: [ + "**Pros**", + "- Cheap, no new infra", + "", + "**Cons**", + "- Slow", + "- Concurrency tricky", + "- Not great for hot data", + ].join("\n"), + }, + ], + } + + pi.sendMessage({ + customType: "alternatives-card-set", + content: alternativesToMarkdown(details), + display: true, + details, + }) + }, + }) + + pi.registerCommand("cards-columns-demo", { + description: "Render four alternative cards in a 2-column layout", + handler: async (_args, _ctx) => { + const details: AlternativesDetails = { + headline: "Four ways to ship the feature", + layout: "columns", + columnCount: 2, + minColumnWidth: 40, + alternatives: [ + { + title: "Vertical slice", + flavor: "accent", + body: "Build one thin path end-to-end.\n\n- Fast feedback\n- High confidence\n- Real integration", + }, + { + title: "Horizontal layers", + flavor: "warning", + body: "Build each layer fully before the next.\n\n- Easier coordination\n- Riskier integration\n- Late surprises", + }, + { + title: "Feature flag", + flavor: "success", + body: "Ship behind a toggle and dark-launch.\n\n- Safe rollout\n- Production validation\n- Flag debt", + }, + { + title: "Spike first", + flavor: "muted", + body: "Throw-away prototype to retire risk.\n\n- Cheap learning\n- Discard the code\n- Plan the real build after", + }, + ], + } + pi.sendMessage({ + customType: "alternatives-card-set", + content: alternativesToMarkdown(details), + display: true, + details, + }) + }, + }) + + pi.registerCommand("cards-flavors", { + description: "Show one card per flavor to compare colors", + handler: async (_args, _ctx) => { + const details: AlternativesDetails = { + headline: "Flavor palette", + alternatives: (["accent", "success", "warning", "muted"] as const).map( + (flavor) => ({ + title: flavor, + flavor, + body: `This is a **${flavor}** card. Its border, title accents, and any inline emphasis use the \`${flavor}\` theme color.`, + }), + ), + } + + pi.sendMessage({ + customType: "alternatives-card-set", + content: alternativesToMarkdown(details), + display: true, + details, + }) + }, + }) +} diff --git a/.pi/extensions/brunch-tags.json b/.pi/extensions/brunch-tags.json new file mode 100644 index 00000000..c7746223 --- /dev/null +++ b/.pi/extensions/brunch-tags.json @@ -0,0 +1,47 @@ +[ + { + "value": "breakfast", + "label": "Breakfast", + "description": "First meal of the day" + }, + { + "value": "brunch", + "label": "Brunch", + "description": "Late morning treat" + }, + { + "value": "coffee", + "label": "Coffee", + "description": "Morning fuel" + }, + { + "value": "croissant", + "label": "Croissant", + "description": "Flaky pastry" + }, + { + "value": "eggs-benedict", + "label": "Eggs Benedict", + "description": "With hollandaise" + }, + { + "value": "mimosa", + "label": "Mimosa", + "description": "OJ + champagne" + }, + { + "value": "pancakes", + "label": "Pancakes", + "description": "Fluffy stack" + }, + { + "value": "toast", + "label": "Toast", + "description": "Crispy bread" + }, + { + "value": "waffles", + "label": "Waffles", + "description": "Grid-shaped breakfast" + } +] diff --git a/memory/SPEC.md b/memory/SPEC.md index b39e49d7..58679f5e 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -232,6 +232,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - Brunch's durable state is intentionally split across four semantic substrates: graph truth (nodes/edges), `change_log` audit/history, `coherence_state` verdict, and `reconciliation_need` actionable semantic queue. Routine async work such as observer jobs may use a separate operational queue; if later generalized, table naming may become `work_item` with subtypes, but the POC should not make every observer job a reconciliation need. +### Chrome surface evolution + +- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch defers wiring them until the question of *what state they should indicate* is sharper. Candidate signals once a canonical chrome-state snapshot exists: terminal title carries spec/session identity with optional working-state tied to the active agent-mode (e.g. eliciting / observing / reviewing / reconciling) rather than raw `agent_start`/`agent_end`; hidden-thinking label varies by agent-mode or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…"). Both depend on stable producers for those signals — the chrome wrapper must not synthesize state it doesn't have, so wiring is deferred until the relevant subsystems (agent-mode dispatcher, lens registry) land. Until then, Brunch's chrome owns header and footer projection only; title and hidden-thinking-label remain Pi defaults. +- **Status keys as the dynamic contribution channel.** `ctx.ui.setStatus(key, text)` remains the multi-extension-friendly seam for other Brunch extensions and future dynamic Brunch state to surface in the footer's status row. Brunch's chrome wrapper does not contribute its own status key by default; it merges all foreign status entries via `footerData.getExtensionStatuses()` into the footer's right column so contributions surface without anyone owning the whole footer. + ## Lexicon | Term | Definition | diff --git a/package.json b/package.json index 0d549849..7874dfc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "brunch", "version": "0.0.0", - "description": "Brunch — opinionated specification-workspace product over pi-coding-agent.", + "description": "Brunch \u2014 opinionated specification-workspace product over pi-coding-agent.", "private": true, "type": "module", "main": "./dist/brunch.js", @@ -17,17 +17,18 @@ ], "scripts": { "dev": "tsx src/brunch.ts", - "build": "tsc -p tsconfig.json && npm run build:web", + "build": "tsc -p tsconfig.build.json && npm run build:web", "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", - "lint": "oxlint src", - "lint:fix": "oxlint --fix src", - "fmt": "oxfmt src", - "fmt:check": "oxfmt --check src", + "lint": "oxlint src .pi/extensions .pi/components", + "lint:fix": "oxlint --fix src .pi/extensions .pi/components", + "fmt": "oxfmt src .pi/extensions .pi/components", + "fmt:check": "oxfmt --check src .pi/extensions .pi/components", "fix": "npm run lint:fix && npm run fmt", - "check": "npm run fmt:check && npm run lint", - "verify": "npm run check && npm run test && npm run build" + "check": "npm run fmt:check && npm run lint && npm run typecheck", + "verify": "npm run check && npm run test && npm run build", + "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index b2ba6812..38c9a6c4 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -1,3 +1,4 @@ +import { userMessage } from "./test-helpers.js" import { mkdtemp, readFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -153,10 +154,7 @@ describe("Brunch TUI boot", () => { const first = await coordinator.createSetupSession({ specTitle: "Spec One", }) - first.session.manager.appendMessage({ - role: "user", - content: "stale transcript", - }) + first.session.manager.appendMessage(userMessage("stale transcript")) const firstContent = await readFile(first.session.file, "utf8") let launchedSessionFile: string | undefined @@ -378,7 +376,8 @@ describe("Brunch TUI boot", () => { }, )({ on: (_event: string, _handler: unknown) => {}, - registerCommand: (name, options) => commands.set(name, options), + registerCommand: (name: string, opts: unknown) => + commands.set(name, opts as never), } as never) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( @@ -681,7 +680,7 @@ function fakeCommandContext(options: { ...ctx, ui: options.replacementUi ?? ui, sessionManager: { getSessionFile: () => sessionPath }, - } as ExtensionCommandContext) + } as never) return { cancelled: false } }, } diff --git a/src/brunch.test.ts b/src/brunch.test.ts index a31f79c1..e5f1861f 100644 --- a/src/brunch.test.ts +++ b/src/brunch.test.ts @@ -7,8 +7,9 @@ import { describe, expect, it } from "vitest" import { SessionManager } from "@earendil-works/pi-coding-agent" -import { runBrunchCli } from "./brunch.js" +import { runBrunchCli, type WebHostRunnerOptions } from "./brunch.js" import { createSessionBindingData } from "./session-binding.js" +import { assistantMessage, userMessage } from "./test-helpers.js" import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, @@ -58,7 +59,7 @@ function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { async deriveDefaultChromeState() { throw new Error("not used") }, - } + } as unknown as WorkspaceSessionCoordinator } function rpcRequest(method: string, id = 1): PassThrough { @@ -75,10 +76,7 @@ function collectStream(stream: PassThrough): string[] { describe("Brunch CLI dispatch", () => { it("routes --mode web through an injectable web host runner", async () => { - let launchedWith: { - cwd: string - coordinator: WorkspaceSessionCoordinator - } | null = null + let launchedWith: WebHostRunnerOptions | null = null const code = await runBrunchCli({ argv: ["--mode=web"], @@ -121,8 +119,8 @@ describe("Brunch CLI dispatch", () => { specTitle: "Spec", }), ) - manager.appendMessage({ role: "assistant", content: "Question" }) - manager.appendMessage({ role: "user", content: "Answer" }) + manager.appendMessage(assistantMessage("Question")) + manager.appendMessage(userMessage("Answer")) const stdout = new PassThrough() const chunks = collectStream(stdout) diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index ca4b73c1..948e6eac 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest" import { SessionManager } from "@earendil-works/pi-coding-agent" import { createSessionBindingData } from "./session-binding.js" +import { assistantMessage, userMessage } from "./test-helpers.js" import { loadJsonlTranscriptEntries, loadLinearElicitationExchangeProjection, @@ -18,7 +19,7 @@ import { const assistant = { id: "a1", type: "message", - message: { role: "assistant", content: "Pick one" }, + message: assistantMessage("Pick one"), } const structuredPrompt = { id: "p1", @@ -40,7 +41,7 @@ const toolResult = { const user = { id: "u1", type: "message", - message: { role: "user", content: "A" }, + message: userMessage("A"), } const structuredResponse = { id: "r1", @@ -70,12 +71,12 @@ describe("elicitation exchange projection", () => { { id: "a2", type: "message", - message: { role: "assistant", content: "Why?" }, + message: assistantMessage("Why?"), }, { id: "u2", type: "message", - message: { role: "user", content: "Because" }, + message: userMessage("Because"), }, ]) @@ -190,7 +191,7 @@ describe("elicitation exchange projection", () => { { id: "a2", type: "message", - message: { role: "assistant", content: "Later prompt" }, + message: assistantMessage("Later prompt"), }, ]) @@ -208,8 +209,8 @@ describe("elicitation exchange projection", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-jsonl-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) - manager.appendMessage({ role: "assistant", content: "Question" }) - manager.appendMessage({ role: "user", content: "Answer" }) + manager.appendMessage(assistantMessage("Question")) + manager.appendMessage(userMessage("Answer")) const projection = await loadLinearElicitationExchangeProjection( manager.getSessionFile()!, @@ -229,8 +230,8 @@ describe("elicitation exchange projection", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-display-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) - manager.appendMessage({ role: "assistant", content: "Question" }) - manager.appendMessage({ role: "user", content: "Answer" }) + manager.appendMessage(assistantMessage("Question")) + manager.appendMessage(userMessage("Answer")) const projection = await loadLinearTranscriptDisplayProjection( manager.getSessionFile()!, @@ -251,11 +252,8 @@ describe("elicitation exchange projection", () => { "Choose the better framing.", true, ) - manager.appendMessage({ - role: "assistant", - content: "Persistence sentinel", - }) - manager.appendMessage({ role: "user", content: "Option A" }) + manager.appendMessage(assistantMessage("Persistence sentinel")) + manager.appendMessage(userMessage("Option A")) const projection = await loadLinearTranscriptDisplayProjection( manager.getSessionFile()!, @@ -312,10 +310,10 @@ describe("elicitation exchange projection", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-helper-branch-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) - manager.appendMessage({ role: "assistant", content: "Abandoned prompt" }) - manager.appendMessage({ role: "user", content: "Abandoned answer" }) + manager.appendMessage(assistantMessage("Abandoned prompt")) + manager.appendMessage(userMessage("Abandoned answer")) manager.resetLeaf() - manager.appendMessage({ role: "assistant", content: "Active prompt" }) + manager.appendMessage(assistantMessage("Active prompt")) await expect( loadLinearElicitationExchangeProjection(manager.getSessionFile()!), @@ -325,11 +323,11 @@ describe("elicitation exchange projection", () => { it("rejects a Pi JSONL file with multiple children from one parent", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-branch-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) - manager.appendMessage({ role: "assistant", content: "Abandoned prompt" }) - manager.appendMessage({ role: "user", content: "Abandoned answer" }) + manager.appendMessage(assistantMessage("Abandoned prompt")) + manager.appendMessage(userMessage("Abandoned answer")) manager.resetLeaf() - manager.appendMessage({ role: "assistant", content: "Active prompt" }) - manager.appendMessage({ role: "user", content: "Active answer" }) + manager.appendMessage(assistantMessage("Active prompt")) + manager.appendMessage(userMessage("Active answer")) await expect( loadJsonlTranscriptEntries(manager.getSessionFile()!), @@ -339,13 +337,12 @@ describe("elicitation exchange projection", () => { it("rejects a Pi JSONL file with branched sibling responses", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-branch-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) - const sharedPromptId = manager.appendMessage({ - role: "assistant", - content: "Choose a path", - }) - manager.appendMessage({ role: "user", content: "Old path" }) + const sharedPromptId = manager.appendMessage( + assistantMessage("Choose a path"), + ) + manager.appendMessage(userMessage("Old path")) manager.branch(sharedPromptId) - manager.appendMessage({ role: "user", content: "Selected path" }) + manager.appendMessage(userMessage("Selected path")) await expect( loadJsonlTranscriptEntries(manager.getSessionFile()!), @@ -424,7 +421,7 @@ describe("elicitation exchange projection", () => { { id: "u1", type: "message", - message: { role: "user", content: "A" }, + message: userMessage("A"), }, )}\n`, ) diff --git a/src/fixture-capture.test.ts b/src/fixture-capture.test.ts index 042ce63b..e76d758c 100644 --- a/src/fixture-capture.test.ts +++ b/src/fixture-capture.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest" import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" import { loadLinearElicitationExchangeProjection } from "./elicitation-exchange.js" +import { assistantMessage, userMessage } from "./test-helpers.js" import { captureDeterministicBriefRuns, captureFixtureRun, @@ -19,14 +20,10 @@ describe("fixture capture", () => { }).createSetupSession({ specTitle: "Fixture spec", }) - workspace.session.manager.appendMessage({ - role: "assistant", - content: "Real selected question", - }) - workspace.session.manager.appendMessage({ - role: "user", - content: "Real selected answer", - }) + workspace.session.manager.appendMessage( + assistantMessage("Real selected question"), + ) + workspace.session.manager.appendMessage(userMessage("Real selected answer")) const result = await captureFixtureRun({ cwd, @@ -68,11 +65,8 @@ describe("fixture capture", () => { }).createSetupSession({ specTitle: "Fixture spec", }) - workspace.session.manager.appendMessage({ - role: "assistant", - content: "Question", - }) - workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) + workspace.session.manager.appendMessage(assistantMessage("Question")) + workspace.session.manager.appendMessage(userMessage("Answer")) const result = await captureFixtureRun({ cwd, @@ -98,11 +92,8 @@ describe("fixture capture", () => { }).createSetupSession({ specTitle: "Fixture spec", }) - workspace.session.manager.appendMessage({ - role: "assistant", - content: "Question", - }) - workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) + workspace.session.manager.appendMessage(assistantMessage("Question")) + workspace.session.manager.appendMessage(userMessage("Answer")) const coordinator: DefaultWorkspaceCoordinator = { async openDefaultWorkspace() { diff --git a/src/jsonl-session-viability.test.ts b/src/jsonl-session-viability.test.ts index 28f292e0..b7a04c76 100644 --- a/src/jsonl-session-viability.test.ts +++ b/src/jsonl-session-viability.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest" import { SessionManager, + type CustomEntry, type CustomMessageEntry, type SessionEntry, type SessionMessageEntry, @@ -17,6 +18,7 @@ import { type ElicitationExchangeProjection, } from "./elicitation-exchange.js" import { isSessionBindingEntry } from "./session-binding.js" +import { assistantMessage, userMessage } from "./test-helpers.js" const M1_FIXTURE_IDS = ["brief-001", "brief-002", "brief-003"] as const const M1_RUN_ID = "scripted-001" @@ -60,25 +62,26 @@ interface M1FixtureBundle { describe("Pi JSONL transcript viability", () => { it("jsonl raw user assistant payload survival", async () => { const { file, manager } = createPersistedSession() - const userContent = [ - { type: "text" as const, text: "Describe this image" }, - { - type: "image" as const, - image: "data:image/png;base64,ZmFrZQ==", - mimeType: "image/png", - }, - ] - const assistantContent = [ - { type: "text" as const, text: "Here is a structured answer." }, + const userContent: (import("@earendil-works/pi-ai").TextContent | import("@earendil-works/pi-ai").ImageContent)[] = + [ + { type: "text", text: "Describe this image" }, + { + type: "image", + data: "data:image/png;base64,ZmFrZQ==", + mimeType: "image/png", + }, + ] + const assistantContent: import("@earendil-works/pi-ai").TextContent[] = [ + { type: "text", text: "Here is a structured answer." }, ] - manager.appendMessage({ role: "user", content: userContent }) - manager.appendMessage({ role: "assistant", content: assistantContent }) + manager.appendMessage(userMessage(userContent)) + manager.appendMessage(assistantMessage(assistantContent)) const reloaded = SessionManager.open(file) const messages = reloaded.getEntries().filter(isMessageEntry) - expect(messages.map((entry) => entry.message)).toEqual([ + expect(messages.map((entry) => entry.message)).toMatchObject([ { role: "user", content: userContent }, { role: "assistant", content: assistantContent }, ]) @@ -225,10 +228,9 @@ describe("Pi JSONL transcript viability", () => { it("jsonl continuity metadata survival", async () => { const { file, manager } = createPersistedSession() - const anchorEntryId = manager.appendMessage({ - role: "assistant", - content: "Anchor before compaction", - }) + const anchorEntryId = manager.appendMessage( + assistantMessage("Anchor before compaction"), + ) const continuity = { lastSeenLsn: 42, interestSet: ["node-a", "node-b"], @@ -275,7 +277,7 @@ describe("Pi JSONL transcript viability", () => { true, promptDetails, ) - manager.appendMessage({ role: "user", content: "I choose safety." }) + manager.appendMessage(userMessage("I choose safety.")) manager.appendCustomEntry("brunch.elicitation_response", responseData) flushPreAssistantEntries(manager) @@ -300,7 +302,7 @@ describe("Pi JSONL transcript viability", () => { }) expect(ordinaryUser).toMatchObject({ type: "message", - message: { role: "user", content: "I choose safety." }, + message: userMessage("I choose safety."), }) expect(structuredResponse).toMatchObject({ type: "custom", @@ -415,7 +417,7 @@ function createPersistedSession(): PersistedSessionFixture { } function flushPreAssistantEntries(manager: SessionManager): void { - manager.appendMessage({ role: "assistant", content: "Persistence sentinel" }) + manager.appendMessage(assistantMessage("Persistence sentinel")) } function isMessageEntry(entry: SessionEntry): entry is SessionMessageEntry { diff --git a/src/rpc.test.ts b/src/rpc.test.ts index aed98176..bb268488 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -9,6 +9,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { createSessionBindingData } from "./session-binding.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import { assistantMessage, userMessage } from "./test-helpers.js" import type { DefaultWorkspaceCoordinator, WorkspaceSessionState, @@ -62,8 +63,8 @@ async function createSessionFile(): Promise { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-session-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) - manager.appendMessage({ role: "assistant", content: "Question" }) - manager.appendMessage({ role: "user", content: "Answer" }) + manager.appendMessage(assistantMessage("Question")) + manager.appendMessage(userMessage("Answer")) return manager.getSessionFile()! } @@ -71,11 +72,11 @@ async function createBranchedSessionFile(): Promise { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-branch-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) - manager.appendMessage({ role: "assistant", content: "Abandoned prompt" }) - manager.appendMessage({ role: "user", content: "Abandoned answer" }) + manager.appendMessage(assistantMessage("Abandoned prompt")) + manager.appendMessage(userMessage("Abandoned answer")) manager.resetLeaf() - manager.appendMessage({ role: "assistant", content: "Active prompt" }) - manager.appendMessage({ role: "user", content: "Active answer" }) + manager.appendMessage(assistantMessage("Active prompt")) + manager.appendMessage(userMessage("Active answer")) return manager.getSessionFile()! } @@ -192,14 +193,8 @@ describe("JSON-RPC handlers", () => { const first = await coordinatorInstance.createSetupSession({ specTitle: "Explicit spec", }) - first.session.manager.appendMessage({ - role: "assistant", - content: "First question", - }) - first.session.manager.appendMessage({ - role: "user", - content: "First answer", - }) + first.session.manager.appendMessage(assistantMessage("First question")) + first.session.manager.appendMessage(userMessage("First answer")) const second = await coordinatorInstance.createSetupSessionForCurrentSpec() if (second.status !== "ready") { throw new Error("expected a ready second session") @@ -237,14 +232,10 @@ describe("JSON-RPC handlers", () => { const workspace = await coordinatorInstance.createSetupSession({ specTitle: "Display spec", }) - workspace.session.manager.appendMessage({ - role: "assistant", - content: "Display question", - }) - workspace.session.manager.appendMessage({ - role: "user", - content: "Display answer", - }) + workspace.session.manager.appendMessage( + assistantMessage("Display question"), + ) + workspace.session.manager.appendMessage(userMessage("Display answer")) const handlers = createRpcHandlers({ coordinator: { ...coordinatorInstance, @@ -446,10 +437,10 @@ describe("JSON-RPC handlers", () => { specTitle: "Explicit branch spec", }) const manager = SessionManager.open(workspace.session.file) - manager.appendMessage({ role: "assistant", content: "Abandoned prompt" }) - manager.appendMessage({ role: "user", content: "Abandoned answer" }) + manager.appendMessage(assistantMessage("Abandoned prompt")) + manager.appendMessage(userMessage("Abandoned answer")) manager.resetLeaf() - manager.appendMessage({ role: "assistant", content: "Active prompt" }) + manager.appendMessage(assistantMessage("Active prompt")) const handlers = createRpcHandlers({ coordinator: coordinatorInstance, cwd, diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 00000000..aa6ce216 --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,67 @@ +/** + * Test helpers — typed factory functions for fixture construction. + * + * Tests historically built `Message` objects inline as `{ role, content }` + * which omits required fields like `timestamp` and (for assistants) wraps + * string content where the canonical type wants `(TextContent | ...)[]`. The + * runtime tolerated this; strict TS does not. These factories produce + * canonical messages so test fixtures stay aligned with production types. + */ + +import type { + AssistantMessage, + ImageContent, + TextContent, + ThinkingContent, + ToolCall, + UserMessage, +} from "@earendil-works/pi-ai" +import type { + CustomEntry, + CustomMessageEntry, + SessionEntry, +} from "@earendil-works/pi-coding-agent" + +const ZERO_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +} as const + +export function userMessage( + content: string | (TextContent | ImageContent)[], + timestamp = 0, +): UserMessage { + return { role: "user", content, timestamp } +} + +export function assistantMessage( + text: string | (TextContent | ThinkingContent | ToolCall)[], + timestamp = 0, +): AssistantMessage { + const content: (TextContent | ThinkingContent | ToolCall)[] = + typeof text === "string" ? [{ type: "text", text }] : text + return { + role: "assistant", + content, + api: "openai-completions", + provider: "openai", + model: "test-model", + usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } }, + stopReason: "stop", + timestamp, + } +} + +export function isCustomEntry(entry: SessionEntry): entry is CustomEntry { + return entry.type === "custom" +} + +export function isCustomMessageEntry( + entry: SessionEntry, +): entry is CustomMessageEntry { + return entry.type === "custom_message" +} diff --git a/src/web-client/app.test.tsx b/src/web-client/app.test.tsx index 681e5756..f648793b 100644 --- a/src/web-client/app.test.tsx +++ b/src/web-client/app.test.tsx @@ -52,21 +52,21 @@ function rpcClient(options?: { const projection = options?.projection ?? readyProjection const calls = options?.calls return { - async request(method, params) { + async request(method: string, params?: unknown): Promise { calls?.push(params === undefined ? { method } : { method, params }) if (method === "workspace.snapshot") { - return snapshot + return snapshot as T } if (method === "session.transcriptDisplay") { if (options?.projectionError) { throw options.projectionError } - return projection + return projection as T } throw new Error(`unexpected RPC method ${method}`) }, close: vi.fn(), - } + } as unknown as WebSocketRpcClient } afterEach(() => cleanup()) diff --git a/src/web-client/rpc-client.test.ts b/src/web-client/rpc-client.test.ts index 13403990..f4a9b6ab 100644 --- a/src/web-client/rpc-client.test.ts +++ b/src/web-client/rpc-client.test.ts @@ -31,7 +31,7 @@ class FakeWebSocket { emit(event: string, data?: string) { for (const listener of this.listeners.get(event) ?? []) { - listener({ data }) + listener(data === undefined ? {} : { data }) } } } diff --git a/src/web-host.test.ts b/src/web-host.test.ts index cbf6e24b..2319dc53 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -12,6 +12,7 @@ import { type DefaultWorkspaceCoordinator, } from "./workspace-session-coordinator.js" import { startWebHost } from "./web-host.js" +import { assistantMessage, userMessage } from "./test-helpers.js" function text(response: Response): Promise { return response.text() @@ -33,7 +34,9 @@ async function rawGet(url: string, path: string): Promise { res.on("end", () => { resolve( new Response(Buffer.concat(chunks), { - status: res.statusCode, + ...(res.statusCode !== undefined + ? { status: res.statusCode } + : {}), headers: res.headers as Record, }), ) @@ -173,11 +176,8 @@ describe("web host", () => { }).createSetupSession({ specTitle: "Web spec", }) - workspace.session.manager.appendMessage({ - role: "assistant", - content: "Question", - }) - workspace.session.manager.appendMessage({ role: "user", content: "Answer" }) + workspace.session.manager.appendMessage(assistantMessage("Question")) + workspace.session.manager.appendMessage(userMessage("Answer")) const host = await startWebHost({ cwd, port: 0, @@ -219,19 +219,13 @@ describe("web host", () => { const first = await coordinator.createSetupSession({ specTitle: "Explicit web spec", }) - first.session.manager.appendMessage({ - role: "assistant", - content: "First question", - }) + first.session.manager.appendMessage(assistantMessage("First question")) first.session.manager.appendCustomMessageEntry( "brunch.elicitation_prompt", "Pick an explicit session direction.", true, ) - first.session.manager.appendMessage({ - role: "user", - content: "First answer", - }) + first.session.manager.appendMessage(userMessage("First answer")) await coordinator.createSetupSessionForCurrentSpec() const host = await startWebHost({ cwd, @@ -382,10 +376,10 @@ describe("web host", () => { specTitle: "Branch spec", }) const manager = SessionManager.open(workspace.session.file) - manager.appendMessage({ role: "assistant", content: "Abandoned prompt" }) - manager.appendMessage({ role: "user", content: "Abandoned answer" }) + manager.appendMessage(assistantMessage("Abandoned prompt")) + manager.appendMessage(userMessage("Abandoned answer")) manager.resetLeaf() - manager.appendMessage({ role: "assistant", content: "Active prompt" }) + manager.appendMessage(assistantMessage("Active prompt")) const host = await startWebHost({ cwd, port: 0, diff --git a/src/workspace-session-coordinator.test.ts b/src/workspace-session-coordinator.test.ts index 63fbafcf..81b864fd 100644 --- a/src/workspace-session-coordinator.test.ts +++ b/src/workspace-session-coordinator.test.ts @@ -4,10 +4,14 @@ import { join } from "node:path" import { describe, expect, it } from "vitest" -import { SessionManager } from "@earendil-works/pi-coding-agent" +import { + SessionManager, + type SessionEntry, +} from "@earendil-works/pi-coding-agent" import { projectElicitationExchanges } from "./elicitation-exchange.js" import { SESSION_BINDING_TYPE } from "./session-binding.js" +import { assistantMessage, userMessage, isCustomEntry } from "./test-helpers.js" import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, @@ -76,10 +80,16 @@ describe("WorkspaceSessionCoordinator", () => { ) const firstBinding = reloadedFirst .getEntries() - .find((entry) => entry.customType === SESSION_BINDING_TYPE) + .find( + (entry) => + isCustomEntry(entry) && entry.customType === SESSION_BINDING_TYPE, + ) const secondBinding = reloadedSecond .getEntries() - .find((entry) => entry.customType === SESSION_BINDING_TYPE) + .find( + (entry) => + isCustomEntry(entry) && entry.customType === SESSION_BINDING_TYPE, + ) expect(firstBinding).toMatchObject({ data: { specId: first.spec.id, specTitle: "Scratch spec" }, @@ -116,7 +126,10 @@ describe("WorkspaceSessionCoordinator", () => { const reloaded = SessionManager.open(result.session.file, undefined, cwd) const bindings = reloaded .getEntries() - .filter((entry) => entry.customType === SESSION_BINDING_TYPE) + .filter( + (entry) => + isCustomEntry(entry) && entry.customType === SESSION_BINDING_TYPE, + ) expect(bindings).toHaveLength(1) expect(bindings[0]).toMatchObject({ @@ -137,8 +150,8 @@ describe("WorkspaceSessionCoordinator", () => { specTitle: "Scratch spec", }) const reloaded = SessionManager.open(result.session.file, undefined, cwd) - reloaded.appendMessage({ role: "assistant", content: "hello" }) - reloaded.appendMessage({ role: "user", content: "hi" }) + reloaded.appendMessage(assistantMessage("hello")) + reloaded.appendMessage(userMessage("hi")) const content = await readFile(result.session.file, "utf8") const lines = content @@ -148,7 +161,11 @@ describe("WorkspaceSessionCoordinator", () => { expect(lines.filter((entry) => entry.type === "session")).toHaveLength(1) expect( - lines.filter((entry) => entry.customType === SESSION_BINDING_TYPE), + lines.filter( + (entry) => + isCustomEntry(entry as unknown as SessionEntry) && + (entry as JsonlLine).customType === SESSION_BINDING_TYPE, + ), ).toHaveLength(1) }) @@ -159,16 +176,16 @@ describe("WorkspaceSessionCoordinator", () => { const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) - result.session.manager.appendMessage({ - role: "assistant", - content: "hello", - }) - result.session.manager.appendMessage({ role: "user", content: "answer" }) + result.session.manager.appendMessage(assistantMessage("hello")) + result.session.manager.appendMessage(userMessage("answer")) const reloaded = SessionManager.open(result.session.file, undefined, cwd) const bindings = reloaded .getEntries() - .filter((entry) => entry.customType === SESSION_BINDING_TYPE) + .filter( + (entry) => + isCustomEntry(entry) && entry.customType === SESSION_BINDING_TYPE, + ) expect(bindings).toHaveLength(1) expect(bindings[0]).toMatchObject({ @@ -192,11 +209,11 @@ describe("WorkspaceSessionCoordinator", () => { await coordinator.bindCurrentSpecToReplacementSession( result.session.manager, ) - result.session.manager.appendMessage({ role: "user", content: "hello" }) + result.session.manager.appendMessage(userMessage("hello")) await coordinator.bindCurrentSpecToReplacementSession( result.session.manager, ) - result.session.manager.appendMessage({ role: "assistant", content: "hi" }) + result.session.manager.appendMessage(assistantMessage("hi")) const content = await readFile(result.session.file, "utf8") const sessionHeaderCount = content @@ -221,11 +238,8 @@ describe("WorkspaceSessionCoordinator", () => { const result = await coordinator.createSetupSession({ specTitle: "Scratch spec", }) - result.session.manager.appendMessage({ - role: "assistant", - content: "Question", - }) - result.session.manager.appendMessage({ role: "user", content: "Answer" }) + result.session.manager.appendMessage(assistantMessage("Question")) + result.session.manager.appendMessage(userMessage("Answer")) const beforeReload = projectElicitationExchanges( result.session.manager.getBranch(), @@ -273,7 +287,7 @@ describe("WorkspaceSessionCoordinator", () => { const coordinator = createWorkspaceSessionCoordinator({ cwd }) const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) - first.session.manager.appendMessage({ role: "user", content: "first" }) + first.session.manager.appendMessage(userMessage("first")) const second = await coordinator.createSetupSession({ specTitle: "Beta", createNewSpec: true, @@ -439,10 +453,7 @@ describe("WorkspaceSessionCoordinator", () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-ws-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) const first = await coordinator.createSetupSession({ specTitle: "Alpha" }) - first.session.manager.appendMessage({ - role: "user", - content: "preserve me", - }) + first.session.manager.appendMessage(userMessage("preserve me")) const beforeFirst = await readFile(first.session.file, "utf8") const created = await coordinator.activateWorkspace({ diff --git a/src/workspace-switcher.test.ts b/src/workspace-switcher.test.ts index 309ed431..553f43c2 100644 --- a/src/workspace-switcher.test.ts +++ b/src/workspace-switcher.test.ts @@ -48,10 +48,10 @@ describe("workspace switcher", () => { onDecision: (decision) => decisions.push(decision), }) - component.handleInput("\r") - component.handleInput("\x1B[B") - component.handleInput("\x1B[B") - component.handleInput("\r") + component.handleInput!("\r") + component.handleInput!("\x1B[B") + component.handleInput!("\x1B[B") + component.handleInput!("\r") expect(decisions).toEqual([ { @@ -75,18 +75,18 @@ describe("workspace switcher", () => { }) for (let index = 0; index < 5; index += 1) { - component.handleInput("\x1B[B") + component.handleInput!("\x1B[B") } - component.handleInput("\r") + component.handleInput!("\r") for (const char of "Gamma") { - component.handleInput(char) + component.handleInput!(char) } - component.handleInput("\r") + component.handleInput!("\r") const cancelComponent = createWorkspaceSwitchComponent({ inventory: inventory(), onDecision: (decision) => decisions.push(decision), }) - cancelComponent.handleInput("\x1B") + cancelComponent.handleInput!("\x1B") expect(decisions).toEqual([ { action: "newSpec", title: "Gamma" }, diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..76f63146 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "archive", "src/**/*.test.ts", "src/**/*.test.tsx", ".pi"] +} diff --git a/tsconfig.json b/tsconfig.json index 21d28cf6..fb8bcd42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,7 @@ "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", - "outDir": "./dist", - "rootDir": "./src", + "noEmit": true, "strict": true, "noImplicitAny": true, "noImplicitOverride": true, @@ -19,22 +18,9 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, "isolatedModules": true, "verbatimModuleSyntax": true }, - "include": ["src/**/*"], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "node_modules", - "dist", - "archive", - "docs", - "memory", - "scripts", - ".brunch-fixtures" - ] + "include": ["src/**/*", ".pi/extensions/**/*.ts", ".pi/components/**/*.ts"], + "exclude": ["node_modules", "dist", "archive"] } From dad4c86b81a6487a58db942ce0ae79f4988a7525 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 15:53:04 +0200 Subject: [PATCH 035/164] header and footer looking reasonable --- .pi/extensions/brunch-chrome.ts | 203 +++++++++++++++++++++----------- .pi/settings.json | 3 + 2 files changed, 136 insertions(+), 70 deletions(-) create mode 100644 .pi/settings.json diff --git a/.pi/extensions/brunch-chrome.ts b/.pi/extensions/brunch-chrome.ts index 6c75db77..f971e3f3 100644 --- a/.pi/extensions/brunch-chrome.ts +++ b/.pi/extensions/brunch-chrome.ts @@ -53,13 +53,13 @@ const CONTEXT_GAUGE_WIDTH = 12 const BAR_FILLED = "━" const BAR_EMPTY = "─" -// Pre-generated with: cfonts "brunch" -f tiny -c candy -const BRUNCH_WORDMARK = - "\x1b[33m \x1b[39m\x1b[32m█▄▄\x1b[39m\x1b[33m \x1b[39m\x1b[95m█▀█\x1b[39m\x1b[33m \x1b[39m\x1b[95m█ █\x1b[39m\x1b[33m \x1b[39m\x1b[31m█▄ █\x1b[39m\x1b[33m \x1b[39m\x1b[94m█▀▀\x1b[39m\x1b[33m \x1b[39m\x1b[32m█ █\x1b[39m\n" + - "\x1b[96m \x1b[39m\x1b[91m█▄█\x1b[39m\x1b[96m \x1b[39m\x1b[93m█▀▄\x1b[39m\x1b[96m \x1b[39m\x1b[31m█▄█\x1b[39m\x1b[96m \x1b[39m\x1b[92m█ ▀█\x1b[39m\x1b[96m \x1b[39m\x1b[96m█▄▄\x1b[39m\x1b[96m \x1b[39m\x1b[96m█▀█\x1b[39m" +// Letterform copied from: cfonts "brunch" -f tiny -c candy +// Colors are intentionally applied through the active Pi theme at render time. +const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] const LOCAL_BUILD_TIME = formatBuildTime(new Date()) const ESC = String.fromCharCode(27) +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) type BrunchSpecIdentity = { id: string @@ -79,6 +79,11 @@ type PackageJson = { private?: unknown } +type BrunchVersionInfo = { + version: string + dev: string | null +} + function formatBuildTime(date: Date): string { return date .toISOString() @@ -108,27 +113,87 @@ function readPackage(cwd: string): PackageJson { } } -function brunchVersion(cwd: string): string { +function brunchVersion(cwd: string): BrunchVersionInfo { const pkg = readPackage(cwd) const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" const isLocalDev = pkg.private === true || version === "0.0.0" - if (!isLocalDev) return `v${version}` + if (!isLocalDev) return { version: `v${version}`, dev: null } const gitSha = getGitSha(cwd) const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") - return `v${version} (${devMeta ? `dev ${devMeta}` : "dev"})` + return { version: `v${version}`, dev: devMeta ? `(dev ${devMeta})` : "(dev)" } +} + +function stripAnsi(text: string): string { + return text.replace(new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g"), "") +} + +function visibleLeadingSpaces(line: string): number { + const plain = stripAnsi(line) + const match = plain.match(/^ */) + return match?.[0].length ?? 0 +} + +function removeVisibleColumns(line: string, columns: number): string { + if (columns <= 0) return line + + let output = "" + let removed = 0 + for (let index = 0; index < line.length; index += 1) { + if (line[index] === ESC) { + const match = line.slice(index).match(ANSI_SEQUENCE) + if (match) { + output += match[0] + index += match[0].length - 1 + continue + } + } + + if (removed < columns) { + removed += 1 + continue + } + output += line[index]! + } + return output +} + +function cropLogo(lines: string[]): string[] { + const cropped = [...lines] + while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) + cropped.shift() + while ( + cropped.length > 0 && + stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 + ) + cropped.pop() + if (cropped.length === 0) return [] + + const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) + return cropped.map((line) => removeVisibleColumns(line, commonLeft)) +} + +function supportsTruecolor(): boolean { + const colorterm = process.env.COLORTERM?.toLowerCase() ?? "" + const term = process.env.TERM?.toLowerCase() ?? "" + return ( + colorterm === "truecolor" || + colorterm === "24bit" || + term.includes("truecolor") + ) } function readLogo(cwd: string): string[] { + const asset = supportsTruecolor() + ? "brunch-logo-quad-56x18.ansi" + : "brunch-logo-quad-56x18-240.ansi" try { - return readFileSync( - path.join(cwd, "assets", "brunch-logo-quad-56x18.ansi"), - "utf8", + return cropLogo( + readFileSync(path.join(cwd, "assets", asset), "utf8") + .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") + .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") + .split("\n"), ) - .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") - .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") - .split("\n") - .filter((line) => line.length > 0) } catch { return [] } @@ -210,17 +275,14 @@ function renderContextGauge(ctx: ExtensionContext, theme: Theme): string { const filled = percent === null ? 0 : Math.round((clamped / 100) * CONTEXT_GAUGE_WIDTH) const empty = CONTEXT_GAUGE_WIDTH - filled - const color = clamped >= 90 ? "error" : clamped >= 70 ? "warning" : "accent" - const bar = - theme.fg(color, BAR_FILLED.repeat(filled)) + - theme.fg("dim", BAR_EMPTY.repeat(empty)) + const bar = BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(empty) const percentText = percent === null ? "?%" : `${Math.round(clamped)}%` const counts = tokens === null || contextWindow === 0 ? `?/${formatTokens(contextWindow)}` : `${formatTokens(tokens)}/${formatTokens(contextWindow)}` - return `${theme.fg("dim", "ctx ")}${bar} ${theme.fg("dim", `${percentText} ${counts}`)}` + return theme.fg("dim", `${bar} ${percentText} ${counts}`) } function rightAlign(left: string, right: string, width: number): string { @@ -241,51 +303,51 @@ function rightAlign(left: string, right: string, width: number): string { ) } +function projectName(cwd: string): string { + return path.basename(path.resolve(cwd)) +} + +function paddedHeaderLine(content: string, width: number): string { + if (width <= 2) return truncateToWidth(content, width) + const inner = truncateToWidth(content, width - 2) + return ` ${inner}${" ".repeat(Math.max(0, width - 1 - visibleWidth(inner)))}` +} + +function emptyHeaderLine(width: number): string { + return " ".repeat(Math.max(0, width)) +} + // ── Header ───────────────────────────────────────────────────────────── function installHeader(ctx: ExtensionContext): void { if (!ctx.hasUI) return const logoLines = readLogo(ctx.cwd) - const wordmarkLines = BRUNCH_WORDMARK.split("\n") ctx.ui.setHeader((_tui, theme) => ({ render: (width: number) => { - const version = theme.fg("muted", brunchVersion(ctx.cwd)) - const piLine = theme.fg("dim", `built in Pi v${PI_VERSION}`) - const cwdLine = theme.fg( + const versionInfo = brunchVersion(ctx.cwd) + const versionLine = + theme.fg("accent", `brunch ${versionInfo.version}`) + + (versionInfo.dev ? ` ${theme.fg("success", versionInfo.dev)}` : "") + const piLine = theme.fg("dim", `built on Pi v${PI_VERSION}`) + const projectRootLine = theme.fg( "dim", - `cwd: ${shortenPath(path.resolve(ctx.cwd))}`, + `project root: ${shortenPath(path.resolve(ctx.cwd))}`, ) - const textBlock = [ - ...wordmarkLines, - `${theme.fg("dim", "brunch")} ${version}`, - piLine, - cwdLine, - ] - - if (logoLines.length === 0 || width < 88) { - return [ - ...wordmarkLines.map((line) => truncateToWidth(line, width)), - truncateToWidth(`${theme.fg("dim", "brunch")} ${version}`, width), - truncateToWidth(piLine, width), - truncateToWidth(cwdLine, width), - "", - ] - } - const logoWidth = Math.max(...logoLines.map((line) => visibleWidth(line))) - const gap = " " - const lines: string[] = [] - const maxLines = Math.max(logoLines.length, textBlock.length) - for (let index = 0; index < maxLines; index += 1) { - const logo = logoLines[index] ?? "" - const paddedLogo = - logo + " ".repeat(Math.max(0, logoWidth - visibleWidth(logo))) - const text = textBlock[index] ?? "" - lines.push(truncateToWidth(`${paddedLogo}${gap}${text}`, width)) - } - lines.push("") - return lines + return [ + emptyHeaderLine(width), + ...logoLines.map((line) => paddedHeaderLine(line, width)), + emptyHeaderLine(width), + ...BRUNCH_WORDMARK.map((line) => + paddedHeaderLine(theme.fg("muted", line), width), + ), + emptyHeaderLine(width), + paddedHeaderLine(versionLine, width), + paddedHeaderLine(piLine, width), + paddedHeaderLine(projectRootLine, width), + emptyHeaderLine(width), + ] }, invalidate: () => {}, })) @@ -313,21 +375,14 @@ function installFooter( }, invalidate: () => {}, render: (width: number): string[] => { - const branch = footerData.getGitBranch() + const branch = footerData.getGitBranch() ?? "no branch" const spec = currentSpec(ctx) - const locationParts = [ - theme.fg("accent", shortenPath(path.resolve(ctx.cwd))), - spec - ? `${theme.fg("dim", "spec:")} ${theme.fg("muted", spec.title)}` - : theme.fg("dim", "spec: none"), - branch - ? `${theme.fg("dim", "branch:")} ${theme.fg("muted", branch)}` - : "", - ].filter(Boolean) - const locationLine = truncateToWidth( - locationParts.join(theme.fg("dim", " · ")), + const specTitle = spec?.title ?? "none" + + const projectLine = rightAlign( + `${theme.fg("accent", "project:")} ${theme.fg("success", projectName(ctx.cwd))}`, + `${theme.fg("accent", "specification:")} ${theme.fg("success", specTitle)}`, width, - theme.fg("dim", "..."), ) const modelName = ctx.model?.id ?? "no-model" @@ -343,14 +398,18 @@ function installFooter( modelLabel = `(${ctx.model.provider}) ${modelLabel}` } - const context = renderContextGauge(ctx, theme) - const telemetryLine = rightAlign( - context, + const rootLine = rightAlign( + theme.fg("dim", shortenPath(path.resolve(ctx.cwd))), theme.fg("dim", modelLabel), width, ) + const branchLine = rightAlign( + theme.fg("dim", branch), + renderContextGauge(ctx, theme), + width, + ) - const lines = [locationLine, telemetryLine] + const lines = [projectLine, rootLine, branchLine] const extensionStatuses = footerData.getExtensionStatuses() if (extensionStatuses.size > 0) { @@ -366,6 +425,10 @@ function installFooter( } } + // One trailing row keeps VS Code's terminal from visually pinning the + // footer against the bottom edge; Ghostty already adds some external + // breathing room, so a single blank row is the least surprising shim. + lines.push("") return lines }, } diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000..81b4d225 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,3 @@ +{ + "quietStartup": true +} From 6ac47ab72e062439def9f3fa71e93950e55afd92 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 16:26:48 +0200 Subject: [PATCH 036/164] brunch tool restrictions + autocomplete system prompt inject + docs updates --- .pi/extensions/brunch-autocomplete.ts | 10 + .pi/extensions/brunch-tools.ts | 263 ++++++++++++++++++ docs/architecture/pi-seam-extensions.md | 15 +- docs/architecture/pi-ui-extension-patterns.md | 8 + memory/SPEC.md | 4 +- 5 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 .pi/extensions/brunch-tools.ts diff --git a/.pi/extensions/brunch-autocomplete.ts b/.pi/extensions/brunch-autocomplete.ts index be739d12..c6a1d13b 100644 --- a/.pi/extensions/brunch-autocomplete.ts +++ b/.pi/extensions/brunch-autocomplete.ts @@ -93,6 +93,16 @@ function extractHashPrefix(line: string, cursorCol: number): string | null { } export default function brunchAutocomplete(pi: ExtensionAPI) { + pi.on("before_agent_start", async (event) => ({ + systemPrompt: + event.systemPrompt + + `\n\n[Brunch fixture references]\n` + + `- Tokens like #breakfast or #coffee may be inserted by the Brunch autocomplete fixture extension.\n` + + `- Treat these as fixture-backed Brunch reference handles for testing the #mention interaction, not as Markdown hashtags.\n` + + `- Pi autocomplete persists only the inserted handle text in the transcript; popup labels/descriptions are UI-only and are not hidden metadata.\n` + + `- There is not yet a Brunch graph lookup tool in this prototype extension. Use the visible handle text only, and ask the user if deeper fixture/entity details are needed.`, + })) + pi.on("session_start", async (_event, ctx) => { await ensureTagsFile(ctx) diff --git a/.pi/extensions/brunch-tools.ts b/.pi/extensions/brunch-tools.ts new file mode 100644 index 00000000..0af88b0e --- /dev/null +++ b/.pi/extensions/brunch-tools.ts @@ -0,0 +1,263 @@ +/** + * Brunch — tools + * + * Product-facing tool policy for the Brunch Pi wrapper prototype: + * - hard-enforce read-only tool access (`read`, `grep`, `find`, `ls`) + * - block every side-effecting tool, including `bash`, `edit`, and `write` + * - render the standard read-only tools in a deliberately tiny TUI form + * + * This is not a toggle. Brunch is testing a narrower tool surface than Pi's + * default coding-agent harness, so loading this extension means Brunch tool + * policy is active for the session. + */ + +import { homedir } from "node:os" + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { + createFindTool, + createGrepTool, + createLsTool, + createReadTool, +} from "@earendil-works/pi-coding-agent" +import { Text } from "@earendil-works/pi-tui" + +const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const +type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] + +function shortenPath(path: string): string { + const home = homedir() + if (path.startsWith(home)) return `~${path.slice(home.length)}` + return path +} + +function availableReadOnlyToolNames(pi: ExtensionAPI): ReadOnlyToolName[] { + const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name)) + return READ_ONLY_TOOLS.filter((name) => allToolNames.has(name)) +} + +function applyBrunchToolPolicy(pi: ExtensionAPI): void { + pi.setActiveTools(availableReadOnlyToolNames(pi)) +} + +interface TextLikeContent { + type: string + text?: string +} + +interface TextToolResultLike { + content?: TextLikeContent[] +} + +interface TextContent { + type: "text" + text: string +} + +function firstText(result: TextToolResultLike): TextContent | undefined { + return result.content?.find( + (content): content is TextContent => + content.type === "text" && typeof content.text === "string", + ) +} + +function nonEmptyLineCount(text: string): number { + return text + .trim() + .split("\n") + .filter((line) => line.trim().length > 0).length +} + +function emptyResult() { + return new Text("", 0, 0) +} + +const toolCache = new Map>() + +function createReadOnlyTools(cwd: string) { + return { + read: createReadTool(cwd), + grep: createGrepTool(cwd), + find: createFindTool(cwd), + ls: createLsTool(cwd), + } +} + +function getReadOnlyTools(cwd: string) { + let tools = toolCache.get(cwd) + if (!tools) { + tools = createReadOnlyTools(cwd) + toolCache.set(cwd, tools) + } + return tools +} + +export default function brunchTools(pi: ExtensionAPI) { + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).read, + label: "read", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).read.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || "") + const range = + args.offset !== undefined || args.limit !== undefined + ? theme.fg( + "muted", + `:${args.offset ?? 1}${ + args.limit !== undefined + ? `-${(args.offset ?? 1) + args.limit - 1}` + : "" + }`, + ) + : "" + return new Text( + `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path || "…")}${range}`, + 0, + 0, + ) + }, + renderResult() { + return emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).grep, + label: "grep", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).grep.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + const glob = args.glob ? theme.fg("muted", ` ${args.glob}`) : "" + return new Text( + `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${args.pattern || "…"}/`)} ${theme.fg("muted", path)}${glob}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} matches`), 0, 0) + : emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).find, + label: "find", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).find.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + return new Text( + `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", args.pattern || "…")} ${theme.fg("muted", path)}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} files`), 0, 0) + : emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).ls, + label: "ls", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).ls.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + return new Text( + `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} entries`), 0, 0) + : emptyResult() + }, + }) + + pi.on("session_start", async () => { + applyBrunchToolPolicy(pi) + }) + + pi.on("before_agent_start", async (event) => { + applyBrunchToolPolicy(pi) + + const tools = availableReadOnlyToolNames(pi).join(", ") || "none" + return { + systemPrompt: + event.systemPrompt + + `\n\n[Brunch tool policy]\n` + + `- Brunch exposes only read-only tools: ${tools}.\n` + + `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + + `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.`, + } + }) + + pi.on("tool_call", async (event) => { + const allowedToolNames = new Set(availableReadOnlyToolNames(pi)) + if (allowedToolNames.has(event.toolName as ReadOnlyToolName)) return + + return { + block: true, + reason: + `Brunch tool policy blocks "${event.toolName}". ` + + `Allowed tools: ${Array.from(allowedToolNames).join(", ") || "none"}.`, + } + }) + + pi.on("user_bash", (event) => ({ + result: { + output: `Brunch tool policy blocks shell commands: ${event.command}`, + exitCode: 1, + cancelled: false, + truncated: false, + }, + })) +} diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 1262c755..c520e052 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -203,27 +203,28 @@ The user (and the agent, on the user's behalf) should be able to refer to graph ### Pi seams used -- `pi-tui` input components (the prompt-editor surface), augmented with a Brunch-owned `MentionAutocompleteOverlay` mounted via `ExtensionUIContext.custom(...)`. -- The custom-entry transcript surface (`pi.appendEntry`, `pi.registerMessageRenderer`) for representing mentions inside user messages as structured spans rather than as raw text. +- `ctx.ui.addAutocompleteProvider((current) => ...)` over Pi's prompt editor. The autocomplete item's `value` is inserted into the editor; Pi does not persist hidden autocomplete metadata. +- `before_agent_start` system-prompt injection for teaching the active agent how to interpret Brunch `#` handles and when to call a lookup/re-read tool. The inserted handle is just transcript text unless Brunch adds a later parser/indexer. +- Brunch custom transcript entries (`pi.appendEntry`, `pi.registerMessageRenderer`) for future mention ledger/staleness records and resolved entity snapshots; these are separate from the autocomplete insertion itself. - `prepareNextTurn` for injecting mention-staleness hints into the agent's next-turn context, alongside the existing `worldUpdate` flow. - The reconciliation-need substrate and global LSN (see §Reconciliation-need substrate and §Graph clock) for comparing the LSN at which a mention was last *snapshotted into the model's working context* against the entity's current LSN. ### Brunch-owned work -- A `MentionAutocompleteOverlay` triggered by `#` in the input area, sourced from `SpecRegistry` + current spec's graph index, that resolves either against stable graph `ID` or (fallback) against the entity's current `title`. ID resolution is canonical; title resolution is a UX affordance that always rewrites to an ID-anchored mention on insertion. -- A `brunch.mention` payload shape attached to user message entries (e.g. as a span array in the message custom payload): `{ id: NodeId, title_at_mention: string, lsn_at_mention: number }`. The `title_at_mention` and `lsn_at_mention` are frozen at insertion time so the transcript carries the historical reference even if the entity is later renamed. -- A renderer (per mode: TUI, web, RPC) that displays mentions as `#` (current title, not the frozen one) with an indicator when the current title differs from the frozen one. +- A `#` autocomplete provider sourced from `SpecRegistry` + current spec's graph index. It may search current titles and descriptions, but the inserted `value` must be a stable handle such as `#A12` or `#<node-id>`; popup `label`/`description` are UI-only and are not session metadata. +- A Brunch mention indexer that parses user/assistant text for stable `#` handles after input and resolves them to `{ id: NodeId, title_at_mention: string, lsn_at_mention: number }` for the session mention ledger. This parsing/indexing step, not Pi autocomplete, is what creates structured mention state. +- A graph lookup/re-read tool (for example `brunch.entity_reread`) whose prompt guidance tells the agent to resolve `#A12` by passing the handle without the `#` when deeper entity detail matters. - A `SessionMentionLedger` in the session-scoped state: for each `id` ever mentioned in this session, the highest `snapshotted_lsn` — i.e. the LSN at which the agent most recently received the full entity payload (either via initial context, a `worldUpdate` cascade, or an explicit re-read tool call). The ledger persists with the session and survives compaction. - A staleness check executed during `prepareNextTurn`: 1. Walk the session's `SessionMentionLedger`. 2. For every entry where the entity's current LSN > `snapshotted_lsn`, the entity is **stale-in-context** for this session. 3. Brunch synthesizes a `brunch.mention_staleness_hint` entry (custom message, `deliverAs: "nextTurn"`) summarising the stale set. The hint is **discretionary advice to the agent**, not a forced re-read: it tells the agent "if you intend to reason over `#foo` again, re-read it; the snapshot you have is from LSN 412, current is LSN 487." 4. The agent decides whether to invoke a re-read tool (which then updates `snapshotted_lsn`) or to proceed with the existing snapshot, accepting the staleness. -- A `brunch.entity_reread` command (through the shared command layer) that re-snapshots a named entity and updates `snapshotted_lsn` to the LSN observed at re-read. +- A `brunch.entity_reread` command/tool (through the shared command layer) that re-snapshots a named entity and updates `snapshotted_lsn` to the LSN observed at re-read. ### Posture -- Mentions are anchored to stable IDs, never to titles. Title-based autocomplete is a UX affordance only. +- Mentions are anchored to stable handles/IDs, never to titles. Title-based autocomplete is a UX affordance only; the transcript persists the inserted textual handle, not the popup label/description. - The mention ledger is **session-scoped**, not transcript-scoped: the question "what has this agent seen at what LSN" is a per-session model-context question, and crossing sessions (via `switchSession`) legitimately resets it. - Staleness hints are **discretionary**. The agent's autonomy over its own context is preserved; Brunch merely surfaces the gap. The product stance is that re-read is cheap and worth doing when in doubt, but the framework does not mandate it. - Staleness hints reuse the same `worldUpdate` machinery and the same global LSN as the rest of the change-log / reconciliation substrate; this is not a parallel staleness mechanism. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index cee05ce3..40d17305 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -78,6 +78,14 @@ Policy buckets: **Limit:** this is visibility suppression only. It does not change exact slash execution. +### Autocomplete persistence and reference interpretation + +Pi autocomplete persists only the text inserted into the editor. For both file completion and custom providers such as Pi's `github-issue-autocomplete.ts`, the `AutocompleteItem.value` becomes ordinary user-message text in the session transcript; the popup `label` and `description` are display-only and do not become hidden session metadata. The GitHub example inserts `#123`; it does not persist issue title/state, nor provide a resolver tool by itself. + +Brunch `#` mentions must therefore use a stable inserted handle (`#A12`, `#I7`, or a stable node id) as the durable transcript reference. If the agent needs deeper detail, Brunch must teach that convention through `before_agent_start` system-prompt injection and provide a read-only lookup/re-read tool that resolves the handle against the local graph DB. Any structured mention ledger or staleness state is Brunch-owned parsing/indexing work layered after insertion; it is not supplied by Pi autocomplete. + +The current `.pi/extensions/brunch-autocomplete.ts` fixture extension follows this model: it inserts fixture handles, explains via `before_agent_start` that labels/descriptions are UI-only, and explicitly says no graph lookup tool exists yet. + ### Exact slash execution `InteractiveMode.setupEditorSubmitHandler()` handles built-ins directly before normal `AgentSession.prompt()` flow. `AgentSession.prompt()` handles extension commands first, then emits `input`, then expands skills/templates. Therefore extension `input` interception cannot reliably block exact interactive built-ins such as `/settings`, `/model`, `/fork`, `/tree`, `/new`, `/compact`, `/resume`, or `/quit`, because they have already been consumed by interactive mode. diff --git a/memory/SPEC.md b/memory/SPEC.md index 58679f5e..97721dbc 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -158,7 +158,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. - **D37-L — Offer-first custom UI is a transcript-driven input surface, not a side dialog.** A structured system/assistant offer may act as the assistant turn by being persisted as a Brunch custom entry, rendered in transcript history, and mounted as the active response surface while unresolved. In TUI mode, the response surface may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, and optional freeform input, following Pi's `question`/`questionnaire` custom-UI patterns. The user's answer is persisted as a linked structured response entry and projected as the response side of the elicitation exchange. RPC/web paths answer the same semantic pending offer through product handlers or supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L. Supersedes: treating structured prompt UI as optional polish or as an ephemeral dialog result detached from transcript truth. - **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (all system/assistant/tool-side entries since the previous user response, including any structured/internal prompt content) plus a response-side span (user text and/or structured action entries). Role/span alternation is the default projection in Brunch-supported linear sessions; typed markers are added only where structure/actions need deterministic replay. Depends on: D12-L, D24-L. Supersedes: —. -- **D14-L — `#`-mentions are ID-anchored, with a session-scoped mention ledger.** Autocomplete may resolve by title but insertion always rewrites to ID-anchored. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: —. +- **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent-mode, not separate agent-modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); agent-modes (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent-mode into one vocabulary axis. - **D26-L — Lenses split into *extractive* and *generative* families by capture mechanism.** Extractive lenses produce single-exchange interactions whose implicit content is captured by the `observer` agent-mode post-exchange (e.g. `step-by-step`, `disambiguate-via-examples`). Generative lenses produce batch proposals whose entity-draft payloads are captured by the elicitor *at proposal time*, with the `reviewer` agent-mode running advisory analysis post-acceptance (e.g. `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`). The family distinction is durable; the specific lens list is expected to evolve. Depends on: D18-L, D25-L. Supersedes: a single uniform "agent asks questions" mental model. - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. @@ -178,7 +178,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I6-L | Every reconciliation need has `created_at_lsn ≤` current global LSN; `kind='impasse'` needs reference at least two graph nodes; resolved needs carry a strictly later `resolved_at_lsn`. | planned (M8 property test) | D8-L, I1-L | | I7-L | Every `framing_as` value belongs to the allowed matrix for that node's base kind. | planned (fixture property check) | D7-L | | I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only runbook checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | -| I9-L | Every `brunch.mention` payload is anchored to a stable `id`; the ledger never stores title-anchored references. | planned (M7 invariant) | D14-L | +| I9-L | Every `brunch.mention` payload resolves a transcript `#` handle to a stable graph entity id; the ledger never stores title-anchored references or relies on autocomplete popup metadata. | planned (M7 invariant) | D14-L | | I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported elicitation exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | | I11-L | No durable graph mutation path — including migrations, maintenance scripts, observer-job writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | | I12-L | Side-task results are delivered only at turn boundaries; no side-task result may steer or mutate the active turn outside the next-turn delivery path. | planned (M7 side-task delivery invariant) | D15-L | From c2dc8cb1193608b5cbeee2dd08dc7dda68c001f7 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 11:44:07 +0200 Subject: [PATCH 037/164] FE-744: Reconcile structured elicitation planning --- .pi/settings.json | 11 ++- docs/architecture/fixture-strategy.md | 6 +- docs/architecture/pi-seam-extensions.md | 95 ++++++++++--------- ...-ui-extension-patterns-provisional-plan.md | 91 +++++++++--------- docs/architecture/pi-ui-extension-patterns.md | 23 ++--- .../docs => docs}/archive/PLAN_HISTORY.md | 0 memory/PLAN.md | 18 ++-- memory/SPEC.md | 72 +++++++++++--- tsconfig.json | 18 +++- 9 files changed, 202 insertions(+), 132 deletions(-) rename {archive/docs => docs}/archive/PLAN_HISTORY.md (100%) diff --git a/.pi/settings.json b/.pi/settings.json index 81b4d225..b16a28e3 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,3 +1,10 @@ { - "quietStartup": true -} + "quietStartup": true, + "extensions": [ + "-extensions/brunch-tools.ts" + ], + "skills": [ + "-skills/d3k/SKILL.md", + "-skills/planning-pr/SKILL.md" + ] +} \ No newline at end of file diff --git a/docs/architecture/fixture-strategy.md b/docs/architecture/fixture-strategy.md index 94c24cbe..aa2eb0f7 100644 --- a/docs/architecture/fixture-strategy.md +++ b/docs/architecture/fixture-strategy.md @@ -159,8 +159,8 @@ A run for brief #7 that terminates with kernels active but with none of `product The agent-as-user is a thin driver that exercises the JSON-RPC stdio surface end to end. It does three things: 1. Opens a JSON-RPC stdio connection to `brunch --mode rpc`. -2. Subscribes to the session's offer stream (`brunch.offer` custom messages per [pi-seam-extensions §4](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#4-assistant--and-system-offer-first-interaction-with-multi-choice-answers)). -3. For each offer, calls an LLM with the brief, the persona dials, and the offer envelope; collects the response (`brunch.offer_response`); posts it back over RPC. +2. Subscribes to Brunch's pending structured-interaction stream (structured-question tool calls/results and product-native offer/proposal entries per [pi-seam-extensions §4](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#4-assistant--and-system-offer-first-structured-interaction)). +3. For each pending interaction, calls an LLM with the brief, the persona dials, and the interaction payload; collects a terminal structured response; posts it back over Brunch RPC (or through the private Pi-RPC extension UI relay when the driver is proving that seam). ### Termination conditions @@ -200,7 +200,7 @@ A captured run produces four artefacts under `.brunch-fixtures/<brief-id>/<run-i | File | Contents | | --- | --- | -| `<run-id>.jsonl` | The full pi JSONL session transcript including all custom entries (`brunch.offer`, `brunch.offer_response`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.kernel_activation`, `brunch.side_task_result`, `worldUpdate`) | +| `<run-id>.jsonl` | The full pi JSONL session transcript including structured-question tool results and Brunch custom entries (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.elicitor_intent_hint`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.kernel_activation`, `brunch.side_task_result`, `worldUpdate`) | | `<run-id>.graph.json` | A snapshot of all spec-workspace graph planes at run termination: nodes, edges, per-entity versions, current graph LSN | | `<run-id>.coherence.json` | Coherence verdict at termination, including per-plane status and any open violations | | `<run-id>.meta.json` | Run metadata: brief id, persona dials, model, timestamps, total turns, total tokens, terminal reason, agent-as-user prompt hash | diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index c520e052..c6f96e56 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -7,7 +7,7 @@ The four affordances: 1. Async "side-chain" sub-agents whose results return at a later turn boundary. 2. Switchable lenses / strategies for the primary interviewing agent. 3. A TUI spec selector for opening or switching between specifications. -4. An assistant-/system-offer-first interaction model with multi-choice answers. +4. An assistant-/system-offer-first structured interaction model with typed answers. For each one this document records the pi seams it relies on, the Brunch-owned work it forces, and the residual risks. @@ -128,72 +128,73 @@ Implications: - Pi's `SessionManager` is one-directory-per-process. If the POC needs spec-roots outside `.brunch/sessions/`, Brunch must either reconfigure `SessionManager.create(cwd, customDir)` per spec or maintain its own indirection layer above pi's session resolution. This couples directly to the JSONL viability proof in M2. - The selector overlay competes with other overlays (model picker, confirmation dialogs). Brunch must own a small overlay-priority policy so a spec switch does not stomp an in-flight confirmation. -## 4. Assistant- and system-offer-first interaction with multi-choice answers +## 4. Assistant- and system-offer-first structured interaction ### Need -Every Brunch session should open with a concrete action or answer surface rather than an empty prompt. The user should always be able to either choose from offered actions or answer an offered question, where answers may be single-choice, multi-choice, or freeform-plus-choice. This is a product stance: Brunch is a guided-elicitation product, not an open chat. +Every Brunch session should open with a concrete action or answer surface rather than an empty prompt. The user should always be responding to a system/assistant-originated question, questionnaire, offer, or proposal, where answers may be single-choice, multi-choice, questionnaire, or freeform-plus-choice. This is a product stance: Brunch is a guided-elicitation product, not an open chat. ### Pi seams used -- `pi.registerMessageRenderer(customType, renderer)` for rendering a Brunch offer envelope inline in the transcript across TUI, web, and RPC. -- `pi.sendMessage(...)` and `pi.appendEntry(...)` with `deliverAs: "followUp"` for posting the user's selection back into the active turn without inventing a new transport. -- `ExtensionUIContext.select`, `confirm`, `input` for the simple cases. -- `ExtensionUIContext.custom<T>(...)` for the multi-select and freeform-plus-choice cases. The API is generic on `T`, so a multi-select overlay legitimately returns `string[]`. -- The RPC mode's `extension_ui_request` channel for routing the same UI requests to the web client. +- Registered Pi tools for basic structured questions/questionnaires. The assistant `toolCall` supplies causal/positional prompt context; the toolResult `content` supplies the model-readable summary; the toolResult `details` can carry Brunch's self-contained structured response payload. +- `ExtensionUIContext.select`, `confirm`, and `input` for simple RPC-compatible cases. +- `ExtensionUIContext.custom<T>(...)` for rich TUI response surfaces. Pi's `question.ts` and `questionnaire.ts` examples prove editor-area replacement for single-choice, optional freeform, and tabbed questionnaire flows. +- `ExtensionUIContext.editor(...)` as the raw Pi-RPC fallback for complex shapes: the tool can send schema-tagged JSON prefill and validate the returned JSON. +- The RPC mode's documented `extension_ui_request` / `extension_ui_response` channel for routing supported Pi UI requests through a private Brunch adapter. +- `pi.registerMessageRenderer(customType, renderer)` and Brunch custom entries remain available for establishment offers, review-set proposals, annotations, and product-native displays where a tool result is not the thinnest transcript representation. ### Brunch-owned work -- A `brunch.offer` custom-message envelope: `{ kind: "actions" | "question", prompt?, options: [{ id, label, value }], multi: boolean, freeform: boolean, allowSkip: boolean, expiresOn?: TurnId | Timestamp, captureHint?: TurnCaptureHint }`. -- A `brunch.offer_response` custom-message envelope with the user's selection, freeform text, or skip outcome. -- A single Brunch-owned renderer for `brunch.offer` per mode: TUI overlay, web component, RPC `extension_ui_request` extension method. -- A `MultiSelectOverlay` component built once on `pi-tui` primitives, returning `string[]`. The same overlay machinery covers both interaction shapes: a **radio** variant (`multi: false`, exactly one selection enforced) and a **checkbox** variant (`multi: true`, any subset including empty if `allowSkip: true`). These are not separate overlays; the visual affordance (`◉ / ◯` vs `☑ / ☐`) is driven by the `multi` field on `brunch.offer`. Keybindings, freeform-plus-choice composition, and `expiresOn` handling are identical across the two variants. -- A `session_start` hook that synthesizes an initial offer when no transcript history exists, so every fresh session opens with a surface. -- A protocol extension to the RPC `extension_ui_request` family for `multiSelect` and `freeformWithChoice`, with a corresponding web client implementation. This is additive, not a replacement. +- A structured-question result details payload carrying enough projection data to stand alone: schema/version, status (`answered | skipped | cancelled | unavailable`), mode, prompt/questions, options, answers, and transport metadata. +- A Brunch-owned TUI helper built on Pi custom UI patterns for radio, checkbox, questionnaire, and optional freeform input. +- JSON-prefill / validation helpers for RPC editor fallback. This is a compatibility seam over Pi RPC, not a second Brunch product API. +- A private Pi RPC adapter that translates `extension_ui_request(editor)` into product-shaped pending elicitation state for Brunch public clients, then translates the product response back into Pi's documented `extension_ui_response`. +- Elicitation-exchange projection that treats terminal structured-question toolResults as response-side entries when their details carry the typed Brunch payload; ordinary toolResults remain prompt-side by default. +- Brunch custom entry schemas for product-native offers that are not ordinary questions, such as `brunch.establishment_offer`, `brunch.review_set_proposal`, and later review-cycle responses. -### Capture-aware offer envelope +### Capture-aware response payload -The `captureHint` field on `brunch.offer` is a **private side-channel** the interviewer attaches to substantive questions so the observer (the graph-capture pass that processes the user's response) has explicit priors instead of free-associating over the whole graph. The hint is invisible to the user but visible in the transcript. +For substantive elicitation questions, the structured result may carry observer priors so the graph-capture pass can process the user's response without free-associating over the whole graph. These hints are advisory and visible in transcript truth when present; they are not commands. ```ts -type TurnCaptureHint = { - expectedKinds: IntentKind[]; // kinds the response is likely to produce - candidateRelations?: RelationKind[]; // edges the response may motivate - targetItems?: NodeRef[]; // graph items the question is about - captureMode: - | 'new_item' - | 'clarify_existing' - | 'choose_option' - | 'rank_priority' - | 'resolve_need' - | 'provide_example'; - resolvesNeedId?: string; // reconciliation_need this offer is resolving - options?: Array<{ - label: string; // mirrors the user-visible option label - mapsTo?: { - nodeKind?: IntentKind; - relationKind?: RelationKind; - targetRef?: NodeRef; - framingAs?: string; // see Product-framing modality - }; +type StructuredQuestionResultDetails = { + schema: "brunch.structured_question_result"; + version: 1; + status: "answered" | "skipped" | "cancelled" | "unavailable"; + mode: "single" | "multiple" | "questionnaire" | "freeform_plus_choice"; + prompt?: string; + questions?: Array<{ + id: string; + prompt: string; + options?: Array<{ id: string; label: string; description?: string }>; }>; + answers: Array<{ + questionId?: string; + selectedOptionIds?: string[]; + freeform?: string; + }>; + captureHint?: TurnCaptureHint; + transport: { + surface: "tui_custom" | "rpc_select" | "rpc_input" | "rpc_editor_json" | "product_relay" | "none"; + }; }; ``` -The observer treats hints as priors, not commands. The user retains escape hatches (`allowSkip`, freeform) and the observer's abstention rule still applies — if the response does not match any hint, the observer may emit zero mutations rather than force a fit. +The observer treats `captureHint` as priors, not authority. The user retains escape hatches (`skipped`, `cancelled`, freeform), and the observer's abstention rule still applies — if the response does not match any hint, the observer may emit zero mutations rather than force a fit. ### Posture -- The offer envelope is durable transcript truth, not ephemeral UI state. Selections are written back as custom messages so the agent can reason over them on the next turn and the transcript reload faithfully reproduces what was offered and what was chosen. -- The agent is allowed to refuse to chat without an offer. The Brunch system prompt should require the agent to either produce an offer or emit `brunch.needs_human` for cases the agent cannot resolve. -- In print mode an offer either resolves via an explicit auto-policy or returns a structured `needs_human` outcome. It does not block. -- Multi-choice answers are first-class. Single-choice is a degenerate multi-choice with `multi: false`. -- Capture hints are advisory. The observer must abstain rather than force a graph mutation when the user's response does not match the hint. +- Structured interaction is durable transcript truth, not ephemeral UI state. For ordinary questions/questionnaires, self-contained toolResult details may be the canonical structured response. For product-native offers/proposals, Brunch custom entries remain appropriate. +- The agent is allowed to refuse ambient chat without an elicitation surface. The Brunch system prompt should require the agent to either produce an elicitation prompt/offer or emit `brunch.needs_human` for cases the agent cannot resolve. +- In print mode a structured interaction either resolves via an explicit auto-policy or returns a structured `needs_human` / unavailable outcome. It does not block. +- Multi-choice answers are first-class. Single-choice is a degenerate multi-choice with one selected option. +- Public clients speak Brunch RPC method families; raw Pi RPC is hidden behind the adapter used for agent-loop mechanics and extension UI. ### Residual risks -- The offer envelope risks being treated as a replacement for the LLM's natural narrative. Brunch should keep offers as the *interaction* surface while the assistant's prose remains the *explanation* surface. A lens that bypasses offers is allowed only for explicitly free-chat moments. -- Pi's RPC `extension_ui_request` types are currently fixed. Adding `multiSelect` and `freeformWithChoice` is a Brunch-side protocol extension that the web client must agree on; this is small but non-zero coupling that should be tracked. +- ToolResult details can become too heavy if every result repeats full prompt/question/option state. Default to self-contained payloads for POC projection clarity; trim only after projection helpers prove a safe prompt-side correlation rule. +- Schema-tagged JSON in `ctx.ui.editor()` is a compatibility fallback, not acceptable final UX for Brunch-aware clients. The public Brunch relay should render native forms where possible. +- The offer/proposal envelope risks being treated as a replacement for the LLM's natural narrative. Brunch should keep offers as the *interaction* surface while assistant prose remains the *explanation* surface. A lens that bypasses offers is allowed only for explicitly free-chat moments. ## 5. Graph-entity mentions and mention staleness @@ -529,7 +530,7 @@ Concretely, Flue has **no equivalent** for any of: - `prepareNextTurn` injection of `worldUpdate` between turns. - `pi.appendEntry({ deliverAs: "nextTurn" })` for side-chain result delivery. Flue's `session.task()` is awaited inline. -- Custom-message entry types + `registerMessageRenderer` for `brunch.offer`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.side_task_result`. +- Custom-message/tool-result transcript types plus renderers for Brunch structured interaction state (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.side_task_result`, and structured-question toolResult details). - `pi.registerCommand` for `/lens`, `/spec`, `/compact`-style affordances. - `ExtensionUIContext.select | confirm | input | custom` for confirmation-gated writes and overlay UIs. - `pi-tui` primitives, including `SessionSelectorComponent` as a model for `SpecSelectorComponent`. @@ -789,9 +790,9 @@ By M5/M6, if formal-verification consumers need to query, rebind, or review reas 1. Whether a lens may register its own pi tools at load time or must declare them up front. Up-front declaration keeps `setActiveTools` sufficient but constrains lens authorship. 2. Whether spec switching is always a session switch or whether one transcript may span several spec roots with lens-mediated framing. -3. Whether the offer envelope should be a single `brunch.offer` type with a `kind` discriminator or several types (`brunch.action_menu`, `brunch.question`, `brunch.question_freeform`) for sharper renderer typing. +3. Which product-native structured offers still deserve Brunch custom-entry types now that ordinary questions/questionnaires can use self-contained toolResult details. 4. Whether side-task results should always go through the shared command layer or whether read-only "advice" side tasks are allowed to produce custom-message results without touching graph state. -5. Whether the RPC `multiSelect` and `freeformWithChoice` protocol extensions should live in Brunch's own JSON-RPC surface from day one rather than as an extension of pi's `extension_ui_request` family. +5. What the thinnest Brunch public method/event family should be for relaying Pi extension UI requests (`elicitation.*`, `agent.ui.*`, or another scoped family), while keeping raw Pi RPC private. 6. Whether before-images should be stored from M4 to simplify later coherence work, accepting the doubled write-time read cost, or deferred to M8 when their consumers exist. 7. Whether the change-log `op` payload should be free-form JSON keyed only by `target_kind`, or a discriminated union of typed op shapes per graph plane to make change-log replay strongly typed. 8. When (M-number or brief-count signal) to promote framings from `framing_as` to first-class node kinds. The current rule is "only when a framing repeatedly demands unique relation-policy or coherence behaviour across multiple briefs"; the operational signal for this remains under-specified. diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md index cc5a9b5a..99ddcbcb 100644 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -1,4 +1,4 @@ -# Pi UI Extension Patterns — Offer-First Custom UI Working Plan +# Pi UI Extension Patterns — Structured Elicitation Working Plan This file is a trimmed working inventory for the remaining FE-744 gap. It is not canonical product contract; durable conclusions belong in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md`. @@ -6,83 +6,86 @@ This file is a trimmed working inventory for the remaining FE-744 gap. It is not Command containment, Brunch chrome, startup no-resume, and `/brunch-workspace` are proven enough for now. The unresolved POC seam is different: -> Brunch sessions must work offer-first: a system/assistant-originated structured offer should act like the assistant turn, render as custom UI in place of the default input surface, and persist the user's structured response before the next agent turn. +> Brunch sessions must work elicitation-first: a system/assistant-originated question, questionnaire, or offer should own the response surface, persist a terminal structured result in Pi JSONL, and be projectable as a prompt/response elicitation exchange before the next agent turn. -This is not generic UI polish. It is the mechanism behind elicitation-first sessions, typed responses, review-cycle decisions, and fixture-controllable prompt/response exchanges. +The latest planning decision narrows the first proof away from a Brunch-only `brunch.offer` envelope. Basic structured questions should use Pi's registered-tool transcript seam when it is thinner: assistant `toolCall` for causal/positional context, toolResult `content` for the model-readable answer summary, and toolResult `details` as Brunch's self-contained structured response payload. Brunch custom entries remain valid for establishment offers, review-set proposals, annotations, and shapes that are not naturally tool questions. ## Pi evidence already relevant - `docs/usage.md`: the editor can be replaced temporarily by built-in UI or custom extension UI. - `docs/tui.md`: `ctx.ui.custom<T>()` can replace the editor area with a custom component and return typed data; overlays are optional, not required. -- `docs/tui.md` Pattern 7: `ctx.ui.setEditorComponent()` can replace the main input editor with a custom editor implementation. -- `examples/extensions/question.ts`: single-choice options plus a "Type something" escape hatch using `ctx.ui.custom()` and `Editor`. -- `examples/extensions/questionnaire.ts`: multi-question/tabbed choice UI with optional custom text answers. -- `examples/extensions/message-renderer.ts`: `registerMessageRenderer()` displays custom messages, but display rendering alone does not collect a response. -- `docs/rpc.md` / extension docs: `ctx.ui.custom()` is TUI-only/degraded in RPC, so semantic pending-offer state must have an RPC/web response path independent of the TUI component. +- `docs/tui.md` Pattern 7: `ctx.ui.setEditorComponent()` can replace the main input editor with a custom editor implementation if a future persistent pending-interaction surface needs it. +- `examples/extensions/question.ts`: single-choice options plus a "Type something" escape hatch using `ctx.ui.custom()`, returning answer data in `toolResult.details`. +- `examples/extensions/questionnaire.ts`: multi-question/tabbed choice UI with optional custom text answers, returning a full questionnaire result in `toolResult.details`. +- `examples/extensions/rpc-demo.ts`: `ctx.ui.editor()` emits Pi RPC `extension_ui_request` / `extension_ui_response` traffic. +- `examples/rpc-extension-ui.ts`: a non-Pi client can translate Pi RPC extension UI requests into its own prompt/dialog components and respond through the documented protocol. +- `examples/extensions/message-renderer.ts`: custom transcript display is available, but display rendering alone does not collect a response. ## Target seam to prove -### Offer-first custom interaction loop +### Structured-question result + JSON-editor RPC fallback -1. Brunch appends or sends a structured custom message/entry representing an unresolved offer, for example `brunch.offer` / `brunch.establishment_offer` / `brunch.review_set_proposal`. -2. The custom entry is visible in the transcript through a message renderer or transcript row. -3. While that offer is unresolved, Brunch replaces the default input surface with an offer-response UI. -4. The response UI supports the POC interaction kernel: +1. A registered Pi tool asks a structured Brunch question or questionnaire. +2. The assistant tool call is preserved as prompt-side transcript context; it is not the only semantic source for projection. +3. In TUI mode, the tool replaces the default input surface with Brunch-owned custom UI supporting the POC interaction kernels: - single-choice selection, - multi-choice selection, + - questionnaire / multiple questions, - optional freeform additional input, - - cancel/skip where allowed. -5. The user's response is persisted as a structured custom entry, not just returned from ephemeral UI. -6. The response either triggers the next agent turn or is available to `prepareNextTurn` / the next prompt path as the user's response to the offer. -7. RPC/web answer the same semantic pending offer through product methods or supported dialog fallbacks; they do not depend on TUI-only `ctx.ui.custom()`. + - cancel/skip/unavailable where allowed. +4. In raw Pi RPC mode, complex shapes degrade through `ctx.ui.editor()` with schema-tagged JSON prefill; simple shapes may use Pi-supported `select`, `confirm`, or `input` where sufficient. +5. A Brunch-aware public client can render the pending interaction as a product form and translate the answer back into Pi's documented `extension_ui_response`. +6. The tool returns one terminal result whose `content` is generated from the same details and whose `details` are self-contained: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. +7. Elicitation-exchange projection classifies terminal structured-question toolResults as response-side entries, while ordinary toolResults remain prompt-side unless typed markers say otherwise. +8. No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. ## Active slice candidate -**Name:** Offer-first custom UI loop +**Name:** Structured-question result + JSON-editor RPC fallback -**Goal:** Prove that a transcript-native unresolved offer can replace ambient free input with a typed custom response surface and persist the response as session truth. +**Goal:** Prove that a transcript-native structured question can replace ambient free input in TUI, stay controllable over Pi RPC, and persist a response payload that Brunch can project without rehydrating semantics solely from assistant tool-call arguments. **Likely implementation shape:** -- Define a minimal offer payload type with `id`, `lens`, prompt text, response mode (`single | multiple | freeform-plus-choice`), options, and response policy. -- Add a Brunch-owned TUI helper, e.g. `requestOfferResponse(ctx, offer)`, modeled on Pi's `question.ts` / `questionnaire.ts` examples. -- Add a renderer for the offer custom entry so the assistant/system offer appears as the current prompt in transcript history. -- Add response persistence as a Brunch custom entry, e.g. `brunch.offer_response`, tied to the offer id. -- For RPC/fixture paths, expose a product method or supported built-in dialog fallback that submits the same response payload. +- Define a minimal structured-question result details payload with `schema`, `status`, `mode`, `prompt` or `questions`, `options`, `answers`, and `transport`. +- Add a Brunch-owned TUI helper modeled on Pi's `question.ts` / `questionnaire.ts` examples. +- Add JSON-prefill / validation helpers for RPC editor fallback. +- Add a Brunch Pi-RPC relay shim that maps Pi `extension_ui_request(editor)` to public Brunch pending-elicitation events/methods and maps the product answer back to `extension_ui_response`. +- Update elicitation-exchange projection to recognize typed terminal structured-question toolResults as response-side entries. **Acceptance:** -- A fixture/demo session can start with no ambient user prompt and present an assistant/system offer first. -- The default freeform editor is replaced while the offer is pending. -- The user can choose one option, choose multiple options, or choose/type optional additional text depending on offer mode. -- The response persists in Pi JSONL as a structured Brunch custom entry linked to the offer id. -- Elicitation exchange projection treats the offer entry as the prompt side and the response entry as the response side. -- RPC/fixture driver can answer the offer through a semantic path even if rich TUI custom UI is unavailable. -- No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. +- A fixture/demo session can ask a system/assistant-originated structured question with no ambient user prompt. +- The default freeform editor is replaced while the question is pending in TUI. +- The user can answer single-choice, multi-choice, questionnaire, and optional-freeform shapes. +- Raw Pi RPC can round-trip a complex response through schema-tagged JSON over `ctx.ui.editor()`. +- The terminal Pi JSONL toolResult includes self-contained structured details and model-readable content derived from those details. +- Elicitation exchange projection treats the prompt-side tool/custom entry and terminal structured result as one exchange. +- Public Brunch clients do not coordinate raw Pi RPC and Brunch RPC as two product APIs; raw Pi RPC remains behind an adapter. ## Residual catalog still carried forward | Need | Status after current evidence | Carry-forward | | --- | --- | --- | -| Single-choice offer UI | Pi example-proven; Brunch offer loop not yet proven | Active slice | -| Multi-choice offer UI | Pi example can be adapted; Brunch semantics not yet proven | Active slice or immediate follow-up | +| Single-choice question UI | Pi example-proven; Brunch loop not yet proven | Active slice | +| Multi-choice UI | Needs Brunch helper; Pi questionnaire patterns can be adapted | Active slice | +| Questionnaire | Pi example-proven; Brunch details schema/projection not yet proven | Active slice | | Freeform-plus-choice | Pi `question.ts` proves the pattern | Active slice | -| Structured offer custom entries | Transcript/persistence model exists; offer-response loop not yet wired | Active slice | -| Message rendering for offers | Pi `message-renderer.ts` proves display; response collection is separate | Active slice | -| Review-set approve/request/reject | Depends on offer-response loop | M5 follow-up when `acceptReviewSet` exists | -| Establishment-offer orientation expansion | Depends on offer-response loop; must remain user-invoked, not default exhaustive menu | M5/M7 follow-up | -| RPC controllability | `ctx.ui.custom()` gap is known | Active slice must provide semantic response path | +| JSON-editor fallback | Pi RPC editor evidence exists; Brunch schema/relay not yet proven | Active slice | +| Structured custom entries | Still valid for establishment offers, review sets, and product-native displays | Use only where thinner than toolResult details | +| Review-set approve/request/reject | Depends on terminal structured-response discipline and graph commands | M5 follow-up when `acceptReviewSet` exists | +| Establishment-offer orientation expansion | Must remain user-invoked, not a default exhaustive menu | M5/M7 follow-up | | Mouse-clickable action buttons | Unproven and not required for POC if keyboard navigation works | Defer | | Strict built-in command suppression | Requires Pi command/keybinding policy | Separate follow-up, not this slice | ## Open questions -- Should the first offer UI use transient `ctx.ui.custom()` only, or should Brunch replace the editor component while a pending offer exists and restore it after response? -- Which custom entry name is canonical for generic responses: `brunch.offer_response`, `brunch.elicitation_response`, or a more specific family? -- Does submitting an offer response call `pi.sendUserMessage()` with a textual summary, append a context-participating custom message, or both? -- How much of the offer is visible to the LLM as structured context versus displayed only to the user? -- What is the thinnest RPC method family for pending-offer discovery and response submission? +- Which details schema name/version should become canonical for structured-question toolResults? +- Does every structured toolResult carry all options, or can simple cases store only selected options while richer projection references a prompt-side entry? Current SPEC posture says self-contained enough for projection, so default to carrying all prompt/question/option data until evidence says it is too heavy. +- Should unavailable/no-UI contexts return `status: "unavailable"` instead of an error-shaped content string? +- What is the thinnest Brunch method/event family for pending elicitation discovery and response submission: `elicitation.pending/respond`, `agent.ui.*`, or a private relay under `agent.*`? +- How much of the schema-tagged JSON editor prefill should be user-visible in raw Pi RPC versus hidden by Brunch-aware clients? ## Retirement rule -Retire this file only after the offer-first custom UI loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. +Retire this file only after the structured-question / RPC-relay loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 40d17305..c24c1a8e 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -14,7 +14,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | | Startup workspace switcher | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | | In-session workspace switcher command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable switcher beyond startup | Brunch extension command tests + coordinator store oracle | -| Typed custom UI (`ctx.ui.custom`) | feasible/proven for Brunch workspace decisions; richer question/questionnaire surfaces remain Pi-example evidence only | informs M5 review/lens affordances | Brunch command tests + Pi docs/examples | +| Structured-question response loop | feasible but not Brunch-proven | required before M5 lens/review affordances depend on structured elicitation | Pi `question`/`questionnaire` examples + RPC UI demo; Brunch proof pending | ## Evidence inventory @@ -224,28 +224,29 @@ allowedBuiltInCommands: ["compact", "reload", "quit"] The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. -## Offer-first custom UI gap +## Structured-question / RPC-relay gap -The remaining live FE-744 gap is not generic UI polish. Brunch still needs an offer-first interaction loop: a system/assistant-originated structured offer should act like the assistant turn, render as transcript-visible custom message state, replace the default input surface with custom response UI, and persist the user's structured response before the next agent turn. +The remaining live FE-744 gap is not generic UI polish. Brunch still needs a structured elicitation loop: a system/assistant-originated question or questionnaire should be transcript truth, replace the default TUI input surface when rich UI is available, degrade over Pi RPC through supported extension UI dialogs (notably schema-tagged JSON over `ctx.ui.editor` for complex shapes), and persist a self-contained terminal structured result before the next agent turn consumes it. Pi source/docs already give strong evidence for the primitive: - `docs/usage.md` states that the editor can be temporarily replaced by custom extension UI. - `docs/tui.md` documents `ctx.ui.custom<T>()` for editor-area replacement and `ctx.ui.setEditorComponent()` for replacing the main input editor. -- `examples/extensions/question.ts` proves single-choice plus optional freeform input. -- `examples/extensions/questionnaire.ts` proves multi-question/multi-step choice UI with custom answers. +- `examples/extensions/question.ts` proves a registered tool can ask a single-choice question with optional freeform input and persist the answer in `toolResult.details`. +- `examples/extensions/questionnaire.ts` proves a registered tool can ask a multi-question questionnaire and persist the full answer set in `toolResult.details`. +- `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch must still prove is the composition: transcript-native unresolved offer → input-replacing custom UI → persisted structured response → projection as an elicitation exchange. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that loop is implemented or deliberately moved into a named M5 slice. +The seam Brunch must still prove is the composition: assistant tool/custom prompt → input-replacing TUI UI or JSON-editor RPC fallback → self-contained structured result in Pi JSONL → projection as the response side of an elicitation exchange. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that loop is implemented or deliberately moved into a named M5 slice. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | -| Offer-first session loop | Missing and POC-critical. | A session can begin from a system/assistant offer without ambient user chat; unresolved offers own the input surface until answered. | -| Structured custom message as UI driver | Display is Pi-example-proven; response collection still needs Brunch composition. | Persist the offer as a Brunch custom entry, render it in transcript history, and mount response UI from the pending offer state. | -| Single-choice / multi-choice / freeform-plus-choice response | Pi examples prove the component patterns. | Build a Brunch-owned response helper over those patterns and persist `brunch.offer_response`-shaped data. | -| Review-set decisions | Depends on the offer-response loop. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a response entry. | +| Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | +| Registered structured-question tool seam | Pi examples prove tool-call / `toolResult.details` capture; Brunch projection does not yet classify terminal structured tool results as response-side entries. | Prefer the thinnest Pi-supported transcript seam for basic questions/questionnaires; make `toolResult.details` self-contained enough for Brunch projection. | +| TUI input replacement | Pi examples prove `ctx.ui.custom()` component replacement; Brunch has proven it only for workspace decisions. | Build a Brunch-owned response helper over single-select, multi-select, questionnaire, and freeform-plus-choice patterns. | +| JSON-editor RPC fallback | Pi RPC supports `editor`; Brunch has not yet wrapped schema-tagged JSON editor requests as product pending-elicitation state. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | +| Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | -| RPC/fixture controllability | `ctx.ui.custom()` is not automatically RPC-controllable. | Critical fixture paths need Brunch RPC methods or built-in dialog fallbacks over the same semantic pending offer. | | Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | ## Downstream posture diff --git a/archive/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md similarity index 100% rename from archive/docs/archive/PLAN_HISTORY.md rename to docs/archive/PLAN_HISTORY.md diff --git a/memory/PLAN.md b/memory/PLAN.md index 3bbba0af..eaa0fbd3 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -20,7 +20,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### Active -1. `pi-ui-extension-patterns` — Continue FE-744 for the POC-critical offer-first custom UI loop: transcript-native structured offer → input-replacing custom response UI → persisted structured response → elicitation-exchange projection. +1. `pi-ui-extension-patterns` — Continue FE-744 for the POC-critical structured elicitation loop: Pi-native structured question/tool exchange → input-replacing TUI custom UI or Pi-RPC JSON-editor fallback → self-contained `toolResult.details` / linked structured response → elicitation-exchange projection through Brunch's single public RPC surface. ### Next @@ -221,15 +221,15 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the offer-first custom UI loop) -- **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, an offer-first interaction loop where a system/assistant-originated structured custom entry acts as the assistant turn, renders as transcript-visible state, replaces the default input surface with single-choice / multi-choice / optional-freeform custom UI, and persists the user's structured response as session truth. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, branch policy, session-boundary binding, and `/brunch-workspace`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is an offer-first custom UI proof: a transcript-native unresolved offer can replace ambient free input, collect single-choice / multi-choice / optional-freeform answers, persist a linked structured response entry, project as an elicitation exchange, and expose an RPC/fixture-controllable semantic response path even though TUI `ctx.ui.custom()` itself is not RPC-controllable. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Multi-choice affordances must integrate with the existing capture-aware offer envelope (`pi-seam-extensions.md §4`) and the structured elicitation-entry shape. Slash commands and action buttons must route writes through the `CommandExecutor`. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). +- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) +- **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, branch policy, session-boundary binding, and `/brunch-workspace`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L / A10-L, A14-L, A17-L, A18-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L / A10-L, A14-L, A17-L, A18-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Scope the offer-first custom UI loop. Use Pi's `question.ts` / `questionnaire.ts` examples and TUI editor-replacement docs as the implementation reference; prove transcript-native offer display, input replacement, response persistence, elicitation-exchange projection, and RPC/fixture semantic controllability before returning to `graph-data-plane`. +- **Current execution pointer:** Scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption @@ -284,7 +284,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - 2026-05-21 `jsonl-session-viability` — Done: Pi JSONL reload preserves coordinator-created binding-only sessions, first assistant/user flushes without duplicate prefixes, `/new` same-spec bindings, raw user/assistant payloads, representative Brunch custom entries, context-participating custom messages, continuity/compaction metadata, structured elicitation entries, defensive active-branch projection behavior, and M1 bundle-local replay parity for briefs #1–#3. Verified: `npm run verify` after each slice. Watch: M2 validates JSONL as sufficient for Brunch-supported linear sessions on current POC terms; branch-aware Brunch sessions are intentionally unsupported per D24-L, and later side-task, mention, and continuity frontiers still own their final payload semantics. - 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.brunch-fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./runbooks/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. -Older history: `archive/docs/archive/PLAN_HISTORY.md` +Older history: `docs/archive/PLAN_HISTORY.md` ## Dependencies diff --git a/memory/SPEC.md b/memory/SPEC.md index 97721dbc..1f62dfb6 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -72,7 +72,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), and freeform-plus-choice response surfaces as typed transcript-backed interactions; in TUI mode a pending structured offer may replace the default input surface with custom UI, and other modes must answer the same semantic offer through product handlers or supported dialog fallbacks. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction. +17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent-mode, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -133,13 +133,52 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Transport & client -- **D5-L — JSON-RPC is the primary product protocol.** Same command surface over stdio (RPC mode), WebSocket (browser), and in-process handler calls (TUI/agent tools). HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The RPC stdio surface is also the agent-as-user fixture-capture interface. Depends on: A5-L. Supersedes: —. +- **D5-L — Brunch JSON-RPC is the single public product protocol.** Brunch exposes one public product RPC surface over stdio, WebSocket, and in-process handlers. Product clients — web UI, CLI probes, TUI adapters, and future relays — call Brunch method families and should not coordinate raw Pi RPC plus Brunch product RPC themselves. Pi RPC may be used behind a Brunch adapter for agent-loop mechanics and Pi extension UI, but it is not a second public product API. HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The Brunch stdio surface is also the agent-as-user fixture-capture interface, even when that driver internally relays Pi RPC events. Depends on: A5-L. Supersedes: treating raw Pi RPC as the product API for Brunch data. - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. -- **D17-L — Brunch semantics ride one event substrate, not parallel channels.** Custom-message transcript entries plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: —. -- **D19-L — Keep transport/read architecture thin: named RPC method families over projection handlers.** Brunch exposes named method families such as `session.*`, `workspace.*`, `graph.*`, and `coherence.*`; each handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log). Subscriptions are first-class and may provide initial state plus updates, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model. +- **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. +- **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, and later `elicitation.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. - **D23-L — Transport modes are distinct from agent modes and lenses.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Agent modes are coarse operational strategies such as `elicitor`, `observer`, `reviewer`, `reconciler`, or future `generalist`; lenses are narrower perspectives such as technical-design, verification-design, or disambiguation that may later be skill-driven. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until agent-mode selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L. Supersedes: overloading “mode” to mean both transport and agent strategy. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/state.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. + Product RPC / Pi relay model: + + ```text + Web UI / CLI probe / TUI adapter + │ + ▼ + Brunch public JSON-RPC surface + ├─ workspace.* / session.* / graph.* / coherence.* reads + ├─ command.* named mutations ──► CommandExecutor ──► SQLite graph/change log + └─ agent.* / elicitation UI relay ──► Pi adapter + └─► Pi AgentSession or pi --mode rpc + └─► Pi JSONL transcript + ``` + + Pi extension UI relay for complex questions: + + ```text + Assistant tool call asks a structured question + │ + ├─ TUI: tool uses ctx.ui.custom() for rich input replacement + │ + └─ Pi RPC: tool uses ctx.ui.editor(prefill = schema-tagged JSON) + │ + ▼ + Brunch Pi adapter receives extension_ui_request(editor) + │ + ▼ + Brunch public surface exposes product-shaped pending elicitation + │ + ▼ + Web/CLI responds through Brunch (e.g. elicitation.respond) + │ + ▼ + Adapter replies to Pi with extension_ui_response(value = JSON) + │ + ▼ + Tool returns toolResult.content + self-contained toolResult.details + ``` + #### Persistence - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. @@ -156,8 +195,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Offer-first custom UI is a transcript-driven input surface, not a side dialog.** A structured system/assistant offer may act as the assistant turn by being persisted as a Brunch custom entry, rendered in transcript history, and mounted as the active response surface while unresolved. In TUI mode, the response surface may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, and optional freeform input, following Pi's `question`/`questionnaire` custom-UI patterns. The user's answer is persisted as a linked structured response entry and projected as the response side of the elicitation exchange. RPC/web paths answer the same semantic pending offer through product handlers or supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L. Supersedes: treating structured prompt UI as optional polish or as an ephemeral dialog result detached from transcript truth. -- **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (all system/assistant/tool-side entries since the previous user response, including any structured/internal prompt content) plus a response-side span (user text and/or structured action entries). Role/span alternation is the default projection in Brunch-supported linear sessions; typed markers are added only where structure/actions need deterministic replay. Depends on: D12-L, D24-L. Supersedes: —. +- **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured questions and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies causal/positional context, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured question. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries or as ephemeral dialog results detached from transcript truth. +- **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-question tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. +- **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-question toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-question results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent-mode, not separate agent-modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); agent-modes (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent-mode into one vocabulary axis. - **D26-L — Lenses split into *extractive* and *generative* families by capture mechanism.** Extractive lenses produce single-exchange interactions whose implicit content is captured by the `observer` agent-mode post-exchange (e.g. `step-by-step`, `disambiguate-via-examples`). Generative lenses produce batch proposals whose entity-draft payloads are captured by the elicitor *at proposal time*, with the `reviewer` agent-mode running advisory analysis post-acceptance (e.g. `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`). The family distinction is durable; the specific lens list is expected to evolve. Depends on: D18-L, D25-L. Supersedes: a single uniform "agent asks questions" mental model. @@ -192,7 +232,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every unresolved structured offer that owns the input surface has exactly one terminal response entry (`answered`, `skipped`, or `cancelled`) linked to the offer id before the next agent turn consumes it; response capture is persisted in Pi JSONL and projected as the user-response side of the elicitation exchange rather than held only in UI state. | planned (FE-744 offer-first custom UI tests + RPC/fixture response-path contract) | D12-L, D13-L, D17-L, D37-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | ## Future Direction Register @@ -264,17 +304,23 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Coherence verdict** | Per-spec product state (`coherent` / `incoherent`) emitted by validators and visible to both UI and agent. | | **Command layer** | The single Brunch-owned mutation surface. Validates, gates concurrency, audits, emits events, triggers coherence. Its public mutation entry point is the `CommandExecutor`, not direct ORM calls or caller-side authority gates. | | **Command executor** | The deep module that accepts Brunch product commands plus execution context and returns structured command results (`ok`, `needs_human`, `policy_blocked`, `version_conflict`, `structural_illegal`). It hides attribution, minimal pre-M6 authority classification, validation, transaction, LSN, change-log, and coherence-trigger mechanics from callers. | -| **RPC method family** | A named group of JSON-RPC methods (`session.*`, `workspace.*`, `graph.*`, `coherence.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second API surface. | +| **Brunch public RPC surface** | The one product-facing JSON-RPC surface exposed over stdio, WebSocket, and in-process handlers. Product clients use this surface for workspace, session, graph, coherence, command, agent, and elicitation behavior; raw Pi RPC is hidden behind adapters when needed. | +| **RPC method family** | A named group of Brunch JSON-RPC methods (`workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, later `elicitation.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second public API surface. | | **Projection handler** | A thin handler that reads or subscribes to a canonical store and returns product-shaped state for a mode/client. It is not a canonical store itself. | | **Subscription** | A long-lived RPC operation that delivers live updates, often with an initial snapshot, for views that must stay current with session, workspace, graph, or coherence state. | -| **Transport adapter** | The stdio, WebSocket, HTTP-shim, or in-process wrapper around the same Brunch handlers. Transport adapters do not own product semantics. | +| **Transport adapter** | The stdio, WebSocket, HTTP-shim, Pi-RPC relay, or in-process wrapper around the same Brunch handlers. Transport adapters do not own product semantics. | +| **Pi RPC adapter** | A private Brunch adapter that speaks Pi's RPC protocol for agent-loop mechanics and extension UI requests, translating Pi events/dialogs into Brunch product-shaped events or method results for public clients. | | **Canonical store** | The persistence surface that owns a fact: Pi JSONL for session transcript truth, `.brunch/state.json` for lightweight workspace binding state, SQLite graph/change log for graph truth and coherence substrates. | | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | -| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior user response) plus response-side span (the user's text and/or structured action entries). This is the observer's default extraction unit. | +| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-question results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-question toolResult details). This is the observer's default extraction unit. | | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | -| **Structured offer** | A system/assistant-originated Brunch custom entry that acts as the current elicitation prompt and owns the response surface until answered, skipped, or cancelled. In TUI it may replace the default editor with custom UI; in RPC/web it is answered through product handlers over the same semantic payload. | -| **Offer response** | A linked Brunch custom entry recording the user's structured answer to a structured offer, including selected option ids and optional freeform text. It is transcript truth, not an ephemeral UI return value. | +| **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | +| **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | +| **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | +| **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-question tools. It is transcript truth, not an ephemeral UI return value. | +| **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | +| **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | A scoped sub-agent invocation whose result returns through the shared command layer. | @@ -404,7 +450,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | -| I23-L | FE-744 offer-first custom UI tests: pending offer mounts an input-replacing TUI response surface, single/multi/freeform answers persist as linked custom entries, RPC/fixture path submits the same semantic response, and elicitation-exchange projection pairs offer prompt side with response side. | +| I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | ### Design Notes diff --git a/tsconfig.json b/tsconfig.json index fb8bcd42..ee401bfc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,11 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], "jsx": "react-jsx", "noEmit": true, "strict": true, @@ -21,6 +25,14 @@ "isolatedModules": true, "verbatimModuleSyntax": true }, - "include": ["src/**/*", ".pi/extensions/**/*.ts", ".pi/components/**/*.ts"], - "exclude": ["node_modules", "dist", "archive"] + "include": [ + "src/**/*", + ".pi/extensions/**/*.ts", + ".pi/components/**/*.ts", + ], + "exclude": [ + "node_modules", + "dist", + "archive" + ] } From 5164034fdce77a3ab9a68be8abf71f9451e34621 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 12:16:01 +0200 Subject: [PATCH 038/164] add project identity discovery module --- src/project-identity.test.ts | 147 +++++++++++++++++++++++++++++++ src/project-identity.ts | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/project-identity.test.ts create mode 100644 src/project-identity.ts diff --git a/src/project-identity.test.ts b/src/project-identity.test.ts new file mode 100644 index 00000000..169bdd69 --- /dev/null +++ b/src/project-identity.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { discoverProjectIdentity, slugify } from "./project-identity.js" + +describe("slugify", () => { + it("lowercases and collapses non-alphanumeric runs to single dashes", () => { + expect(slugify("Acme Control Plane")).toBe("acme-control-plane") + expect(slugify("Foo___Bar Baz!!")).toBe("foo-bar-baz") + }) + + it("strips leading and trailing dashes", () => { + expect(slugify("---wrap-around---")).toBe("wrap-around") + }) + + it("handles scoped npm package names", () => { + expect(slugify("@hashintel/brunch")).toBe("hashintel-brunch") + }) + + it("returns 'project' for inputs with no alphanumerics", () => { + expect(slugify("!!!")).toBe("project") + expect(slugify("")).toBe("project") + }) +}) + +describe("discoverProjectIdentity", () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "brunch-project-identity-")) + }) + + afterEach(() => { + // Temp dirs are reaped by the OS; leaving them is acceptable for tests. + }) + + it("prefers package.json over every other signal", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "@hashintel/brunch" }), + ) + await writeFile( + join(dir, "pyproject.toml"), + '[project]\nname = "pythonic"\n', + ) + await writeFile(join(dir, "Cargo.toml"), '[package]\nname = "rusty"\n') + await writeFile(join(dir, "go.mod"), "module example.com/golang\n") + + const identity = await discoverProjectIdentity(dir) + + expect(identity).toEqual({ + name: "@hashintel/brunch", + slug: "hashintel-brunch", + source: "package.json", + }) + }) + + it("reads pyproject.toml [project].name when package.json is absent", async () => { + await writeFile( + join(dir, "pyproject.toml"), + '# comment\n[build-system]\nrequires = ["hatch"]\n\n[project]\nname = "snake_case_app"\nversion = "0.1.0"\n', + ) + + const identity = await discoverProjectIdentity(dir) + + expect(identity).toEqual({ + name: "snake_case_app", + slug: "snake-case-app", + source: "pyproject.toml", + }) + }) + + it("falls back to [tool.poetry].name in pyproject.toml", async () => { + await writeFile( + join(dir, "pyproject.toml"), + '[tool.poetry]\nname = "poetry-app"\n', + ) + + const identity = await discoverProjectIdentity(dir) + + expect(identity.name).toBe("poetry-app") + expect(identity.source).toBe("pyproject.toml") + }) + + it("reads Cargo.toml [package].name", async () => { + await writeFile( + join(dir, "Cargo.toml"), + '[package]\nname = "rustacean"\nversion = "0.1.0"\nedition = "2021"\n', + ) + + const identity = await discoverProjectIdentity(dir) + + expect(identity).toEqual({ + name: "rustacean", + slug: "rustacean", + source: "cargo.toml", + }) + }) + + it("uses the final segment of the module path in go.mod", async () => { + await writeFile( + join(dir, "go.mod"), + "module github.com/hashintel/widget-service\n\ngo 1.22\n", + ) + + const identity = await discoverProjectIdentity(dir) + + expect(identity).toEqual({ + name: "widget-service", + slug: "widget-service", + source: "go.mod", + }) + }) + + it("falls back to the directory basename when no manifest is present", async () => { + const identity = await discoverProjectIdentity(dir) + + expect(identity.source).toBe("directory") + expect(identity.name).toBe(dir.split("/").pop()) + expect(identity.slug.length).toBeGreaterThan(0) + }) + + it("falls back past a malformed package.json to the next signal", async () => { + await writeFile(join(dir, "package.json"), "{ this is not json") + await writeFile(join(dir, "Cargo.toml"), '[package]\nname = "rusty"\n') + + const identity = await discoverProjectIdentity(dir) + + expect(identity.name).toBe("rusty") + expect(identity.source).toBe("cargo.toml") + }) + + it("ignores package.json with a missing or empty name field", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ version: "1.0.0" }), + ) + await writeFile(join(dir, "go.mod"), "module example.com/fallback\n") + + const identity = await discoverProjectIdentity(dir) + + expect(identity.name).toBe("fallback") + expect(identity.source).toBe("go.mod") + }) +}) diff --git a/src/project-identity.ts b/src/project-identity.ts new file mode 100644 index 00000000..b419ae1d --- /dev/null +++ b/src/project-identity.ts @@ -0,0 +1,164 @@ +import { readFile } from "node:fs/promises" +import { basename, join } from "node:path" + +export type ProjectIdentitySource = "package.json" | "pyproject.toml" | "cargo.toml" | "go.mod" | "directory" + +export interface ProjectIdentity { + /** Human-facing project name, as written in the source artifact. */ + name: string + /** Stable, filesystem/URL-safe identifier derived from `name`. */ + slug: string + /** Which artifact in `cwd` produced `name`. */ + source: ProjectIdentitySource +} + +/** + * Discover the identity of the project rooted at `cwd`. + * + * The search is intentionally shallow — only files directly in `cwd` are + * consulted, and the directory basename is the final fallback. Brunch treats + * the launch directory as the project boundary and does not support monorepo + * walking; users working in a monorepo should launch the tool inside the + * sub-package they intend to work on. + * + * Precedence (first hit wins): + * 1. package.json — `name` field + * 2. pyproject.toml — `[project].name` or `[tool.poetry].name` + * 3. Cargo.toml — `[package].name` + * 4. go.mod — final segment of the `module` directive + * 5. directory basename + */ +export async function discoverProjectIdentity( + cwd: string, +): Promise<ProjectIdentity> { + const detectors: Array<() => Promise<DetectedName<ProjectIdentitySource> | null>> = + [ + () => readPackageJsonName(cwd), + () => readPyprojectName(cwd), + () => readCargoTomlName(cwd), + () => readGoModName(cwd), + ] + + for (const detect of detectors) { + const hit = await detect() + if (hit) + return { name: hit.name, slug: slugify(hit.name), source: hit.source } + } + + const name = basename(cwd) + return { name, slug: slugify(name), source: "directory" } +} + +/** + * Normalize a project name into a stable slug suitable for filenames, URL + * path segments, and persistent identifiers. + * + * - Lowercased. + * - Non-alphanumeric runs collapse to a single `-`. + * - Leading and trailing `-` trimmed. + * - Empty input returns `"project"` so callers always get a non-empty slug. + */ +export function slugify(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug.length > 0 ? slug : "project" +} + +async function readFileOrNull(path: string): Promise<string | null> { + try { + return await readFile(path, "utf8") + } catch { + return null + } +} + +interface DetectedName<S extends ProjectIdentitySource> { + name: string + source: S +} + +async function readPackageJsonName( + cwd: string, +): Promise<DetectedName<"package.json"> | null> { + const raw = await readFileOrNull(join(cwd, "package.json")) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as { name?: unknown } + if (typeof parsed.name === "string" && parsed.name.trim().length > 0) { + return { name: parsed.name.trim(), source: "package.json" } + } + } catch { + // Malformed package.json — skip this signal rather than throwing. + } + return null +} + +async function readPyprojectName( + cwd: string, +): Promise<DetectedName<"pyproject.toml"> | null> { + const raw = await readFileOrNull(join(cwd, "pyproject.toml")) + if (!raw) return null + const fromProject = extractTomlNameInTable(raw, "project") + if (fromProject) return { name: fromProject, source: "pyproject.toml" } + const fromPoetry = extractTomlNameInTable(raw, "tool.poetry") + if (fromPoetry) return { name: fromPoetry, source: "pyproject.toml" } + return null +} + +async function readCargoTomlName( + cwd: string, +): Promise<DetectedName<"cargo.toml"> | null> { + const raw = await readFileOrNull(join(cwd, "Cargo.toml")) + if (!raw) return null + const name = extractTomlNameInTable(raw, "package") + return name ? { name, source: "cargo.toml" } : null +} + +async function readGoModName( + cwd: string, +): Promise<DetectedName<"go.mod"> | null> { + const raw = await readFileOrNull(join(cwd, "go.mod")) + if (!raw) return null + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line.startsWith("module")) continue + const match = line.match(/^module\s+(\S+)/) + const captured = match?.[1] + if (!captured) continue + const modulePath = captured.replace(/^["']|["']$/g, "") + const tail = modulePath.split("/").filter(Boolean).pop() + if (tail && tail.length > 0) { + return { name: tail, source: "go.mod" } + } + } + return null +} + +/** + * Minimal TOML extraction: find `name = "..."` inside `[tableName]`, stopping + * at the next top-level table header. Not a real TOML parser — sufficient for + * the well-formed manifests we care about and cheaper than a dependency. + */ +function extractTomlNameInTable( + content: string, + tableName: string, +): string | null { + const lines = content.split(/\r?\n/) + const header = `[${tableName}]` + let inTable = false + for (const rawLine of lines) { + const line = rawLine.trim() + if (line.startsWith("#") || line.length === 0) continue + if (line.startsWith("[") && line.endsWith("]")) { + inTable = line === header + continue + } + if (!inTable) continue + const match = line.match(/^name\s*=\s*(["'])(.*?)\1/) + const captured = match?.[2] + if (captured && captured.length > 0) return captured + } + return null +} From 7f254902bb5ce49618033263356a8b3b2d48f73b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:46:00 +0200 Subject: [PATCH 039/164] spec, plan and scope updates --- memory/CARDS.md | 370 ++++++++++++++++++++++++++++++++++++++++++++++++ memory/PLAN.md | 66 +++++---- memory/SPEC.md | 53 +++++-- 3 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..1e22ecdc --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,370 @@ +# Scope Cards — sealed-pi-profile-runtime-state + +## Orientation + +- **Containing frontier:** `sealed-pi-profile-runtime-state` in `memory/PLAN.md`; this is one frontier/Linear/branch boundary, with multiple commit-sized port/migration slices queued here. +- **Containing seam:** Brunch-owned Pi wrapper: extension factories, command/tool policy, TUI components, chrome, autocomplete, transcript UI primitives, and resource isolation from ambient `.pi/`. +- **Volatile state:** The `.pi/extensions/*` and `.pi/components/*` files are probe/test artifacts whose useful behavior should be ported into product `src/` modules, then retired so Brunch runtime no longer depends on project-local Pi discovery. +- **Main open risk:** The `/brunch` menu is intentionally only a shell in this queue; deeper settings/config IA still needs grilling, so this queue scopes only a combined menu entry that preserves current workspace-switch behavior and leaves obvious extension points. + +## Frontier-level obligations + +- Preserve the sealed-profile posture: Brunch product behavior comes from programmatic extension factories and profile policy, not ambient `.pi/` discovery. +- Keep product modules flat: `src/pi-extensions/{extension}.ts`, aggregate `src/pi-extensions.ts`, and reusable TUI components under `src/pi-components/{component}.ts`. +- Retire duplicate/stale `.pi/` probe code once its behavior is ported; do not leave parallel extension implementations masquerading as live product truth. +- Preserve current Brunch session invariants while moving files: one spec per session, linear transcript policy, branch/fork/tree blocking, and coordinator-owned workspace activation. +- Keep demo/probe affordances out of production defaults: demo card commands and fixture tag JSON should not ship as product behavior. + +--- + +## Card 1 — Flatten the existing product extension shell + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The existing Brunch Pi extension shell is imported from flat `src/pi-extensions.ts` and flat `src/pi-extensions/*.ts` modules with no remaining runtime imports from `src/pi-extensions/brunch/*`. + +### Boundary Crossings + +```text +→ src/brunch-tui.ts extension factory wiring +→ src/pi-extensions.ts aggregate factory +→ flat extension modules (command-policy, session-lifecycle, chrome, settings-switcher-menu) +→ existing tests/importers +``` + +### Risks and Assumptions + +- RISK: Rename-only movement can accidentally change behavior or break public test exports → MITIGATION: preserve current exported names where useful from `src/pi-extensions.ts`, update tests mechanically, and run focused TUI/extension tests. +- ASSUMPTION: A flat aggregate file is enough; no directory index is needed → VALIDATE: all current imports compile and no import path still references `src/pi-extensions/brunch`. + +### Acceptance Criteria + +✓ `src/pi-extensions.ts` exports `createBrunchPiExtensionShell` plus existing test-facing symbols. +✓ `src/pi-extensions/command-policy.ts` contains the current branch/tree/fork blocking behavior from `branch-policy.ts`. +✓ `src/pi-extensions/session-lifecycle.ts` contains the current session-boundary binding behavior from `session-boundary.ts`. +✓ `src/pi-extensions/settings-switcher-menu.ts` initially contains the current workspace command behavior from `workspace-command.ts`, even if the command name changes in a later card. +✓ No runtime or test import references `src/pi-extensions/brunch/*`. + +### Verification Approach + +- Inner: `npm run fix`; targeted tests for `brunch-tui` / workspace command imports. +- Middle: `rg "pi-extensions/brunch|./pi-extensions/brunch|../pi-extensions/brunch" src` returns no live imports. + +### Cross-cutting obligations + +- This card is structural movement only; do not change `/brunch-workspace` semantics yet. +- Preserve branch/session effect blocking exactly while renaming the module to command policy. + +--- + +## Card 2 — Move reusable Pi TUI components under `src/pi-components` + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +Reusable Pi TUI components live under `src/pi-components`, including the workspace switcher and cards component library, with importers updated to consume the new component location. + +### Boundary Crossings + +```text +→ src/workspace-switcher/* +→ src/pi-components/workspace-switcher.ts or workspace-switcher/* +→ .pi/components/cards.ts +→ src/pi-components/cards.ts +→ extension/component tests and package scripts +``` + +### Risks and Assumptions + +- RISK: Collapsing `workspace-switcher/*` too aggressively could make tests less clear → MITIGATION: preserve a small public component/preflight entrypoint under `src/pi-components/workspace-switcher.ts` or `src/pi-components/workspace-switcher/index.ts` if needed; prefer clarity over one-file compression. +- ASSUMPTION: `cards.ts` has no product dependency on `.pi/` placement → VALIDATE: it imports only Pi TUI/theme primitives and works from `src/pi-components/cards.ts`. + +### Acceptance Criteria + +✓ `createWorkspaceSwitchComponent` and `runWorkspaceSwitchPreflight` are imported from `src/pi-components` paths, not `src/workspace-switcher`. +✓ `CardComponent`, `ResponsiveColumns`, and `chunk` are available from `src/pi-components/cards.ts`. +✓ Existing workspace-switcher behavior and tests still pass after the move. +✓ Package lint/format scripts no longer need `.pi/components` to cover product component code. + +### Verification Approach + +- Inner: `npm run fix`; workspace-switcher tests. +- Middle: `rg "workspace-switcher|\.pi/components" src package.json` shows only intentional compatibility exports if any. + +### Cross-cutting obligations + +- Keep Pi-specific TUI widgets out of general product/domain folders. +- Do not change workspace activation semantics; component move only. + +--- + +## Card 3 — Replace `/brunch-workspace` with the Brunch menu shell + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`/brunch` and `ctrl+shift+b` open a Brunch menu shell that can launch the existing workspace/session switch flow, replacing `/brunch-workspace` as the primary product command. + +### Boundary Crossings + +```text +→ src/pi-extensions/settings-switcher-menu.ts +→ src/pi-components/brunch-menu.ts +→ src/pi-components/workspace-switcher +→ WorkspaceSessionCoordinator activation +→ TUI command/shortcut tests +``` + +### Risks and Assumptions + +- RISK: The final settings/config IA is not designed → MITIGATION: scope only a menu shell with a workspace/session item and clear extension points; do not invent full settings semantics. +- RISK: Removing `/brunch-workspace` immediately may break tests or muscle memory → MITIGATION: either retire it deliberately with test updates or keep it as a hidden/backward test alias only if needed for one transition commit; prefer deletion in pre-release. +- ASSUMPTION: `ctrl+shift+b` is collision-safe based on the probe extension note → VALIDATE: register shortcut test asserts the binding exists and no `ctrl+b` alias returns. + +### Acceptance Criteria + +✓ `src/pi-components/brunch-menu.ts` renders a minimal menu with a workspace/session switch action. +✓ `src/pi-extensions/settings-switcher-menu.ts` registers `/brunch` and `ctrl+shift+b` to open the Brunch menu. +✓ Choosing the workspace/session switch action preserves the current coordinator-backed activation behavior and chrome refresh. +✓ `/brunch-workspace` is removed as the primary command; tests assert the intended command/shortcut surface. + +### Verification Approach + +- Inner: `npm run fix`; unit tests with fake command contexts and workspace decisions. +- Middle: source-level command registry test verifies `/brunch`, `ctrl+shift+b`, no `ctrl+b`, and no product reliance on the old command name. + +### Cross-cutting obligations + +- The menu returns product decisions; `WorkspaceSessionCoordinator` still owns session opening, state writes, and binding. +- Do not introduce settings persistence or hidden menu state in this card. + +--- + +## Card 4 — Port and merge honest chrome + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`src/pi-extensions/chrome.ts` uses the richer `.pi/extensions/brunch-chrome.ts` header/footer discipline while rendering only Brunch/Pi state with real producers today. + +### Boundary Crossings + +```text +→ .pi/extensions/brunch-chrome.ts probe implementation +→ existing src/pi-extensions chrome wrapper +→ WorkspaceSessionChromeState / session binding state +→ Pi header/footer/status/widget/title surfaces +→ chrome tests and TUI launch wiring +``` + +### Risks and Assumptions + +- RISK: The probe chrome reads `.brunch/state.json` directly while current product chrome receives activated workspace state → MITIGATION: favor product-provided activated state when available; use session binding / ctx-derived fallbacks only for honest reload/session-switch reconstruction. +- RISK: Future-state stubs (lens, coherence, worker statuses) can become misleading → MITIGATION: do not render speculative fields until producers exist; leave clear placeholders only where current product state owns them. +- ASSUMPTION: Header/footer are the right primary chrome surfaces; status remains contribution channel → VALIDATE: code avoids using status as the main Brunch chrome owner except for intentional current wrapper compatibility. + +### Acceptance Criteria + +✓ `src/pi-extensions/chrome.ts` supersedes both the old product chrome and `.pi/extensions/brunch-chrome.ts` probe code. +✓ Header/footer render brand/version/cwd/spec/session/model/context/git/status information only where producers exist. +✓ Future state such as operational mode, lens, coherence, workers, and establishment offer is not fabricated; extension points are named for later producers. +✓ Existing chrome formatting tests are updated or replaced to assert the richer honest rendering contract. + +### Verification Approach + +- Inner: `npm run fix`; chrome formatter unit tests. +- Middle: fake `ExtensionContext`/footer-data tests cover selected spec/session binding fallback, model/thinking/context display, and extension status passthrough. +- Outer: optional manual TUI smoke after build thread if terminal rendering changed substantially. + +### Cross-cutting obligations + +- Chrome is projection, not authority; it must not mutate workspace/session state. +- Preserve RPC limitations: only assert Pi RPC chrome events that actually exist. + +--- + +## Card 5 — Port operational-mode tool policy + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`src/pi-extensions/operational-mode.ts` enforces the current `elicit`-safe read-only tool posture while being named and shaped as the future operational-mode policy seam. + +### Boundary Crossings + +```text +→ .pi/extensions/brunch-tools.ts probe implementation +→ Pi tool registry / active tool selection +→ before_agent_start prompt composition +→ tool_call and user_bash blocking events +→ Brunch extension aggregate factory +``` + +### Risks and Assumptions + +- RISK: Re-registering built-in read-only tools may conflict with Pi base tools or custom tools → MITIGATION: preserve the probe's available-tool filtering and test active tool names after registration. +- RISK: A permanent read-only name would fight future `execute` mode → MITIGATION: expose the code as operational-mode policy with an initial `elicit` bundle/default, not `tool-policy.ts`. +- ASSUMPTION: `read`, `grep`, `find`, `ls` are sufficient safe tools for the current elicitation prototype → VALIDATE: tests assert side-effecting tools are blocked and prompt text tells the agent the allowed set. + +### Acceptance Criteria + +✓ `operational-mode.ts` registers/readies read-only tools and sets active tools for the current elicit posture. +✓ `before_agent_start` appends operational-mode/tool-policy prompt guidance. +✓ `tool_call` blocks side-effecting tools, including `bash`, `edit`, and `write`. +✓ `user_bash` is blocked with a deterministic Brunch result. +✓ The module name and exported API leave room for future `execute` bundles. + +### Verification Approach + +- Inner: `npm run fix`; fake ExtensionAPI unit tests for active tools, prompt injection, and blocked calls. +- Middle: aggregate extension factory test proves operational-mode policy is loaded programmatically, not through `.pi/settings.json`. + +### Cross-cutting obligations + +- This is the first concrete enforcement for I25-L; do not let active tool state come from ambient Pi settings. +- Keep side-effect suppression aligned with future `elicit` operational mode rather than global product incapability. + +--- + +## Card 6 — Port mention autocomplete as graph-code completion + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`src/pi-extensions/mention-autocomplete.ts` provides `#` completion from a Brunch-owned graph mention source keyed by stable node codes, with no `.pi/extensions/brunch-tags.json` file. + +### Boundary Crossings + +```text +→ .pi/extensions/brunch-autocomplete.ts probe implementation +→ Brunch graph mention source interface +→ Pi autocomplete provider +→ before_agent_start mention guidance +→ future graph data plane integration point +``` + +### Risks and Assumptions + +- RISK: The graph data plane is not available yet → MITIGATION: define an injectable `GraphMentionSource` interface and test with fake intent/design/oracle/plan nodes; production source can return empty until M4/M5 plugs in. +- RISK: Stable code formats are not fully final → MITIGATION: support current known families (`D{n}` decisions and analogous intent/design/oracle/plan codes) through typed data, not hardcoded fixture food tags. +- ASSUMPTION: Pi autocomplete still persists only inserted handle text → VALIDATE: prompt guidance remains explicit that labels/descriptions are UI-only. + +### Acceptance Criteria + +✓ The autocomplete extension inserts stable handles such as `#D12` from Brunch-owned graph-node candidates. +✓ Candidate labels/descriptions are display-only and not treated as hidden transcript metadata. +✓ No code writes or reads `.pi/extensions/brunch-tags.json`. +✓ The graph mention source is injectable/testable before graph persistence lands. + +### Verification Approach + +- Inner: `npm run fix`; autocomplete extraction/apply unit tests with fake graph candidates. +- Middle: source audit `rg "brunch-tags|\.pi/extensions/brunch-tags" src .pi package.json` confirms the fixture JSON path is retired. + +### Cross-cutting obligations + +- Preserve D14-L: inserted text must be a stable Brunch-resolvable handle; autocomplete metadata is not transcript truth. +- Do not invent a graph lookup tool in this card. + +--- + +## Card 7 — Port alternatives/card transcript primitive without demos + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`src/pi-extensions/alternatives.ts` registers the persistent alternatives card transcript primitive and `present_alternatives` tool using `src/pi-components/cards.ts`, without shipping demo commands. + +### Boundary Crossings + +```text +→ .pi/extensions/brunch-messages.ts probe implementation +→ src/pi-components/cards.ts +→ Pi custom message renderer +→ Pi tool registry +→ structured exchange future seam +``` + +### Risks and Assumptions + +- RISK: Alternatives may be confused with terminal structured-question responses → MITIGATION: name it as a presentation/proposal primitive; do not record it as an answered offer or terminal response. +- RISK: Demo commands leak into product command surface → MITIGATION: delete `/cards-demo`, `/cards-columns-demo`, and `/cards-flavors` during port. +- ASSUMPTION: `present_alternatives` remains useful enough to register as a product tool → VALIDATE: tests prove content fallback plus details payload are self-contained and replay-renderable. + +### Acceptance Criteria + +✓ `alternatives-card-set` custom message renderer is registered from product code. +✓ `present_alternatives` tool emits persistent custom transcript content plus structured details. +✓ Demo commands from the probe file are not registered. +✓ The primitive is documented/named as a structured-exchange building block, not a terminal answer collector. + +### Verification Approach + +- Inner: `npm run fix`; renderer/tool unit tests with fake ExtensionAPI. +- Middle: command registry test proves demo commands are absent while `present_alternatives` is available. + +### Cross-cutting obligations + +- Preserve transcript truth: custom message content must provide a readable fallback for RPC/replay clients without the renderer. +- Keep this separate from structured-question result details until the FE-744 structured-response tool lands. + +--- + +## Card 8 — Retire `.pi/` probe runtime reliance and update docs/scripts + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The ported product behavior no longer relies on `.pi/extensions`, `.pi/components`, `.pi/settings.json`, or `.pi/extensions/brunch-tags.json`, and stale references are either deleted or explicitly documented as historical probe evidence. + +### Boundary Crossings + +```text +→ .pi/extensions/* probe files +→ .pi/components/* probe files +→ .pi/settings.json ambient config +→ package scripts +→ docs/reference and architecture references +→ source audits +``` + +### Risks and Assumptions + +- RISK: Some docs intentionally describe Pi's generic extension discovery locations → MITIGATION: keep reference docs that explain Pi generally, but update Brunch product docs to say product extensions are loaded programmatically from `src`. +- RISK: Deleting `.pi/settings.json` could remove useful local test defaults → MITIGATION: if needed, replace with a non-product example under docs or test fixtures; do not keep ambient config in the repo root. +- ASSUMPTION: Product lint/format coverage should now target `src` only → VALIDATE: package scripts no longer mention `.pi/extensions` or `.pi/components`. + +### Acceptance Criteria + +✓ Duplicate `.pi/extensions/brunch-*.ts`, `.pi/components/cards.ts`, and `.pi/extensions/brunch-tags.json` are deleted or moved into non-runtime historical documentation if explicitly needed. +✓ `.pi/settings.json` no longer controls Brunch product behavior; preferably it is removed from the repo. +✓ `package.json` lint/format scripts target product code, not deleted probe paths. +✓ Architecture docs mentioning `.pi/extensions/brunch-autocomplete.ts` or temporary probes are updated to point at `src/pi-extensions/*` or explicitly describe archived evidence. +✓ `rg "\.pi/extensions/brunch|\.pi/components|brunch-tags.json|brunch-workspace"` returns no stale product-runtime references. + +### Verification Approach + +- Inner: `npm run fix`; `npm run verify` if this is the tie-off card. +- Middle: source/doc audit commands for stale `.pi` product references and old command names. + +### Cross-cutting obligations + +- Keep generic Pi reference docs accurate where they discuss Pi itself; only remove Brunch product reliance on ambient `.pi`. +- Do not delete evidence references in architecture docs without replacing them with the durable product module names or noting the proof was temporary. diff --git a/memory/PLAN.md b/memory/PLAN.md index eaa0fbd3..7626294d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is starting from a deliberately razed slate on the `next` branch (tag `next-baseline`). Implementation, planning memory, and pre-POC docs have been archived under `archive/`. The new line is a thin layer over `pi-coding-agent` whose milestone ladder M0–M9 (from `prd.md`) is the planning spine. M0 (walking skeleton) is the first frontier to land — it also doubles as the Phase-3 infra bootstrap. Fixture capture starts at M1 and grows with every later milestone. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell. The active risk is now Pi wrapping: FE-744 must finish the structured-question / Pi-RPC JSON-editor fallback proof, then the new sealed-profile/runtime-state frontier must lock down ambient Pi isolation plus transcript-backed operational mode / role preset / strategy / lens state before graph tools and authority-gated agent work depend on those seams. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ## Sequencing @@ -24,8 +24,9 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta ### Next -1. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions. -2. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. +1. `sealed-pi-profile-runtime-state` — Seal Brunch's embedded Pi profile and transcript-backed runtime-bundle state before future agent-loop work depends on ambient-safe settings, prompt composition, or tool gating. +2. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions and the sealed-profile/runtime-state follow-up is scoped. +3. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. ### Parallel / Low-conflict @@ -54,7 +55,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Status:** done (bootstrap slice landed on `next` as commit `b104fc40`; coordinator/runbook and TUI boot/chrome slices landed on the frontier branch; manual M0 smoke + store-only runbook oracle passed) - **Objective:** Prove the wrapping model works at all: a `brunch` binary launches a pi-backed TUI session through the `WorkspaceSessionCoordinator`, scopes durable state to `.brunch/`, hardcodes Brunch's prompt and curated toolset, and mounts the persistent TUI chrome and spec-selector gate. - **Why now / unlocks:** First architectural proof of D1-L (depend on `pi-coding-agent`) and D2-L (opinionated product, not pi shell). Unlocks every subsequent milestone. Also doubles as the Phase-3 infra bootstrap (package.json, tsconfig, oxlint/oxfmt, vitest). -- **Acceptance:** `brunch` launches a TUI session in a project directory; `.brunch/` is created; boot routes through a `WorkspaceSessionCoordinator` that returns `ready | select_spec | needs_human`; the spec-selector is presented before any agent loop runs when no bound spec is ready; the selected spec is written as the session's `brunch.session_binding`; `/new` creates another session bound to the same spec rather than mutating the current session's spec; the chrome region displays cwd / spec / phase / chat-mode at all times; `npm run verify` is green. +- **Acceptance:** `brunch` launches a TUI session in a project directory; `.brunch/` is created; boot routes through a `WorkspaceSessionCoordinator` that returns `ready | select_spec | needs_human`; the spec-selector is presented before any agent loop runs when no bound spec is ready; the selected spec is written as the session's `brunch.session_binding`; `/new` creates another session bound to the same spec rather than mutating the current session's spec; the chrome region displays cwd / spec / phase / runtime bundle at all times; `npm run verify` is green. - **Verification:** Inner — `npm run fix` / `npm run verify` plus coordinator state/unit tests. Middle — M0 runbook oracle: manual TUI smoke against a scratch project paired with artifact/query postconditions for `.brunch/`, `brunch.session_binding`, same-spec `/new`, and chrome/workspace state (SPEC §Runbook Oracle Design). Outer — defer; first replay-regression fixture lands in M1. - **Cross-cutting obligations:** Preserve the `cwd → spec → session` hierarchy, one-spec-per-session binding, and persistent chrome region as durable product surfaces, not temporary bootstrapping hacks. Do not let TUI, RPC, or fixture code create/open Pi sessions or write `brunch.session_binding` directly; route boot, spec selection, and `/new` through the workspace-session seam. - **Traceability:** R1, R2, R3, R4, R19 / D1-L, D2-L, D6-L, D11-L, D21-L / I8-L, I13-L / A1-L, A10-L @@ -72,7 +73,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. - **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.brunch-fixtures/`; the first three curated briefs are captured. - **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./runbooks/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the runbook. -- **Cross-cutting obligations:** Keep transport mode distinct from agent modes/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. +- **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. - **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L - **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) - **Current execution pointer:** complete after M1 review fixes; proceed to `jsonl-session-viability`. @@ -109,13 +110,28 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Design docs:** [prd.md §M3, §Frontend Architecture](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) - **Current execution pointer:** complete. M3 tied off with shared JSON-RPC protocol helpers/dispatch semantics, `ws`-backed `/rpc` transport, persistent browser RPC client with protocol-failure hardening, canonical built asset serving with traversal-safe asset resolution, stable React runtime, explicit read-only session projection by durable session id through a canonical Brunch session-envelope reader with strict self-description validation, explicit transcript custom-entry classifiers, and read-only browser transcript rendering of assistant/user rows plus transcript-native prompt display rows from typed `{ sessionId, specId }` targets. Automated verification and direct HTTP/WebSocket projection postconditions pass. Accepted outer-loop deferral: qualitative browser-open smoke remains environment-blocked because `agent-browser` cannot create its socket directory under the current macOS sandbox (`Operation not permitted`); this does not block M3 tie-off because static HTML serving, absence of HTTP product reads, explicit `{ sessionId, specId }` WebSocket RPC reads, transcript-display text including custom prompt rows, and exchange projection were rechecked directly against the host. +### sealed-pi-profile-runtime-state + +- **Name:** Sealed Pi profile and transcript-backed runtime state +- **Linear:** unassigned +- **Kind:** structural hardening +- **Status:** not-started +- **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into flat product modules under `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. +- **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, observer/reviewer jobs, and authority gating depend on the embedded harness. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; the runtime no longer imports from `src/pi-extensions/brunch/*`; replacement modules are flat and product-named: `.pi/extensions/brunch-tools.ts` ports to `src/pi-extensions/operational-mode.ts`; `.pi/extensions/brunch-autocomplete.ts` ports to `src/pi-extensions/mention-autocomplete.ts` with graph-node stable-code completion instead of `.pi/extensions/brunch-tags.json`; `.pi/extensions/brunch-chrome.ts` supersedes and merges the old product `chrome.ts` as `src/pi-extensions/chrome.ts`; `.pi/extensions/brunch-messages.ts` ports to `src/pi-extensions/alternatives.ts` while `.pi/components/cards.ts` moves to `src/pi-components/cards.ts` with demo commands removed; `branch-policy.ts` becomes `src/pi-extensions/command-policy.ts`; `session-boundary.ts` becomes `src/pi-extensions/session-lifecycle.ts`; `workspace-command.ts` becomes `src/pi-extensions/settings-switcher-menu.ts`; `src/pi-extensions/brunch/index.ts` becomes `src/pi-extensions.ts`; `src/workspace-switcher/*` moves under `src/pi-components/workspace-switcher/*` (with a public component/preflight entrypoint) so TUI components live beside card components rather than as a top-level product domain. Transcript entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch` can be appended by Brunch commands and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. +- **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). +- **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L +- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) +- **Current execution pointer:** consume the prepared queue in [`memory/CARDS.md`](file:///Users/lunelson/Code/hashintel/brunch-next/memory/CARDS.md): flatten `src/pi-extensions`, create `src/pi-components`, port `operational-mode`, `command-policy`, `session-lifecycle`, `chrome`, `settings-switcher-menu`, `mention-autocomplete`, and `alternatives` behind the existing extension factory, move workspace-switcher/cards TUI components into `src/pi-components`, and delete/retire duplicate `.pi/` probe runtime reliance. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage before adding runtime-bundle switch entries. + ### graph-data-plane - **Name:** Graph data plane (intent-first, workspace-graph-ready) (M4) - **Linear:** [FE-741](https://linear.app/hash/issue/FE-741/graph-data-plane-intent-first-workspace-graph-ready-m4) - **Branch:** `ln/fe-741-graph-data-plane` (stacked on `ln/fe-737-web-shell`) - **Kind:** structural -- **Status:** active +- **Status:** next / paused until FE-744 structured-question proof and the sealed-profile/runtime-state follow-up are scoped - **Objective:** Stand up SQLite-backed graph persistence; durable intent-plane nodes and edges; a single global LSN per commit; the change log; the reconciliation-need substrate; named homes for coherence state (verdicts and violations) — all forward-compatible with oracle, design, and plan planes. - **Why now / unlocks:** Pins I1-L, I6-L. Unlocks all agent ↔ graph work (M5+) and lets oracle / design / plan planes be added later without re-foundation. - **Acceptance:** Graph CRUD + change-log replay tests pass through the `CommandExecutor` public mutation boundary; command results already include success, `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` shapes even if pre-M6 policy classification is minimal; reconciliation-need substrate accepts inserts/updates/resolutions with LSN invariants enforced; oracle-plane stub tables exist (Check, Validation Method, Evidence, Obligation) even if unused; the persistence layer proves the one-transaction protocol that couples authority/result classification, version checks, structural validation, LSN allocation, change-log append, and any coherence updates. @@ -227,7 +243,7 @@ Brunch-next is starting from a deliberately razed slate on the `next` branch (ta - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L / A10-L, A14-L, A17-L, A18-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). - **Current execution pointer:** Scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. @@ -295,25 +311,27 @@ walking-skeleton │ │ │ ├── jsonl-session-viability │ │ │ - │ │ ├── graph-data-plane - │ │ │ │ - │ │ │ ├── agent-graph-integration - │ │ │ │ │ - │ │ │ │ ├── authority-model - │ │ │ │ │ - │ │ │ │ └── turn-boundary-reconciliation - │ │ │ │ │ - │ │ │ │ └── coherence-first-class - │ │ │ │ │ - │ │ │ │ └── compaction-and-conflict-widening - │ │ │ │ - │ │ │ └── (oracle-design-plan-graphs — horizon) - │ │ │ │ │ ├── web-shell (M3, can run parallel after M2) │ │ │ - │ │ └── pi-ui-extension-patterns (parallel after M2; informs M5/M6/M7) - │ │ - │ └── brief-library-curation (parallel after M0) + │ │ ├── pi-ui-extension-patterns (parallel after M2; informs profile/M5/M6/M7) + │ │ │ │ + │ │ │ └── sealed-pi-profile-runtime-state + │ │ │ │ + │ │ │ ├── graph-data-plane + │ │ │ │ │ + │ │ │ │ ├── agent-graph-integration + │ │ │ │ │ │ + │ │ │ │ │ ├── authority-model + │ │ │ │ │ │ + │ │ │ │ │ └── turn-boundary-reconciliation + │ │ │ │ │ │ + │ │ │ │ │ └── coherence-first-class + │ │ │ │ │ │ + │ │ │ │ │ └── compaction-and-conflict-widening + │ │ │ │ │ + │ │ │ │ └── (oracle-design-plan-graphs — horizon) + │ │ │ + │ │ └── brief-library-curation (parallel after M0) │ └── fixture-strategy-evolution (continuous, doc-only) diff --git a/memory/SPEC.md b/memory/SPEC.md index 1f62dfb6..ac1c3f7d 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -75,7 +75,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. -20. Brunch must support multiple elicitation lenses within the `elicitor` agent-mode, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. +20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. 21. Brunch must distinguish *extractive* lenses (single-exchange, observer-extracted) from *generative* lenses (batch-proposal, captured at proposal time as structured entity-draft payloads, reviewer-analyzed post-acceptance). 22. Brunch must establish a minimum grounding bundle (domain, protagonist, pain/pull, and constraint anchors) before generative lenses produce non-speculative output; lenses remain always-available with epistemic-status signaling honestly reflecting grounding density. 23. Brunch must support a review-cycle acceptance pattern for generative-lens proposals — approve / request changes (triggering regeneration) / reject — with batch acceptance committed atomically as one CommandExecutor call; partial acceptance is not representable. @@ -84,6 +84,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 24. Brunch must ship a brief library and an agent-as-user driver over the JSON-RPC stdio surface to capture replayable golden runs and property-checkable fixtures. +#### Runtime profile & prompting + +25. Brunch must run the embedded Pi harness through a sealed Brunch Pi Profile: programmatic settings, resource-loader, extension-factory, keybinding, tool, and prompt policy must determine product behavior; ambient user/project `.pi/` resources must not influence Brunch sessions unless Brunch deliberately imports them. +26. Brunch must distinguish transport modes from operational modes and agent roles: operational modes such as `elicit` and future `execute` gate tool authority and prompt posture, while role presets/bundles select the active top-level role, model/thinking posture, prompt packs, allowed strategies/lenses, and tool policy. + ## Live Architecture Register ### Open Assumptions @@ -100,7 +105,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Fixture runs across briefs #1–#7: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | -| A10-L | A persistent TUI chrome region showing cwd / spec / phase / chat-mode can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | +| A10-L | A persistent TUI chrome region showing cwd / spec / phase / runtime bundle can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | A durable observer-job queue keyed by session id and elicitation-exchange entry range can recover async extraction after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | M5: observer extraction tests exercise restart/idempotence once graph writes exist. | | A14-L | LLM elicitor agents can reliably produce graph-structurally-legal intent-graph proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation) for generative lenses. | medium | open | D27-L | Fixture replay across briefs that exercise `propose-scenarios-with-tradeoffs`-shaped lenses; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | @@ -108,6 +113,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — observer/reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | | A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | | A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L, D35-L | `pi-ui-extension-patterns` product-shell review after command-containment and dynamic Brunch chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | +| A19-L | Pi's current settings/resource lifecycle can be made product-safe through a sealed Brunch Pi Profile without forking Pi: ambient discovery remains disabled, Brunch-owned extension factories may inject explicit resources, and remaining settings/keybinding leakage can be eliminated through programmatic policy or a narrow upstream seam. | medium | open | D39-L | FE-744/profile audit: source-backed resource-loader/settings audit, tests proving no ambient `.pi/` skills/prompts/themes/extensions/context files affect Brunch, and product-owned resources still load when intentionally injected. | ### Active Decisions @@ -115,7 +121,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions should be product modules under flat `src/pi-extensions/*.ts` plus an aggregate `src/pi-extensions.ts`; the old `.pi/extensions/*` files are test/probe sources to port, not product runtime configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Slash/key commands append product custom entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch`; turn preparation projects the latest linear transcript state into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. +- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. #### Data model & vocabulary @@ -137,7 +145,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. - **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. - **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, and later `elicitation.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. -- **D23-L — Transport modes are distinct from agent modes and lenses.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Agent modes are coarse operational strategies such as `elicitor`, `observer`, `reviewer`, `reconciler`, or future `generalist`; lenses are narrower perspectives such as technical-design, verification-design, or disambiguation that may later be skill-driven. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until agent-mode selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L. Supersedes: overloading “mode” to mean both transport and agent strategy. +- **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/state.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. Product RPC / Pi relay model: @@ -186,7 +194,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Depends on: A3-L, A4-L. Supersedes: —. - **D18-L — Observer extraction is exchange-keyed durable work, not a chat/turn store.** After a user response closes an elicitation exchange, Brunch may enqueue an observer job keyed by session id plus exchange entry ids; jobs survive process restart and graph writes still route through the command layer. Routine observer jobs are operational queue state, not reconciliation needs by default; low-confidence or conflicting findings may create reconciliation needs. Depends on: A13-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model. - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. -- **D29-L — Reviewer is an `observer`-shaped agent-mode with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. +- **D29-L — Reviewer is an `observer`-shaped agent role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. #### Interaction & UI shape @@ -199,8 +207,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-question tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-question toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-question results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. -- **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent-mode, not separate agent-modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); agent-modes (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent-mode into one vocabulary axis. -- **D26-L — Lenses split into *extractive* and *generative* families by capture mechanism.** Extractive lenses produce single-exchange interactions whose implicit content is captured by the `observer` agent-mode post-exchange (e.g. `step-by-step`, `disambiguate-via-examples`). Generative lenses produce batch proposals whose entity-draft payloads are captured by the elicitor *at proposal time*, with the `reviewer` agent-mode running advisory analysis post-acceptance (e.g. `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`). The family distinction is durable; the specific lens list is expected to evolve. Depends on: D18-L, D25-L. Supersedes: a single uniform "agent asks questions" mental model. +- **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent role, not separate roles or operational modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); roles (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent role into one vocabulary axis. +- **D26-L — Lenses split into *extractive* and *generative* families by capture mechanism.** Extractive lenses produce single-exchange interactions whose implicit content is captured by the `observer` role post-exchange (e.g. `step-by-step`, `disambiguate-via-examples`). Generative lenses produce batch proposals whose entity-draft payloads are captured by the elicitor *at proposal time*, with the `reviewer` role running advisory analysis post-acceptance (e.g. `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`). The family distinction is durable; the specific lens list is expected to evolve. Depends on: D18-L, D25-L. Supersedes: a single uniform "agent asks questions" mental model. - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. @@ -233,6 +241,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | +| I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | ## Future Direction Register @@ -250,6 +260,12 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - Remote deployment shape (headless HTTP/SSE host) modeled on Flue, as a later mode beyond TUI/web/RPC/print. - MCP adapter style and per-run event-stream style — Flue's patterns observed and selectively adopted post-POC. +### Prompt/runtime profile architecture + +- Brunch prompt composition should be explicit and layered: base Brunch product prompt + operational-mode prompt pack + top-level role preset + strategy prompt pack + lens prompt pack + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules. +- Spec phase/maturity is provisionally elicitor-assigned with heuristic assistance rather than purely hidden state or purely derived inference; later validators may warn when transcript/graph evidence and assigned maturity diverge. +- Core role/lens prompting should usually be product prompt packs rather than Pi skills. Pi skills remain available as Brunch-owned explicit resources when progressive disclosure is the right mechanism, but they are not the primary authority for operational mode/tool policy. + ### Vocabulary evolution - Whether public graph commands eventually split from one `graph.*` umbrella into `intent.*` / `oracle.*` / `design.*` / `plan.*` namespaces is deferred; current posture is unified `graph.*` for the POC. @@ -274,7 +290,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Chrome surface evolution -- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch defers wiring them until the question of *what state they should indicate* is sharper. Candidate signals once a canonical chrome-state snapshot exists: terminal title carries spec/session identity with optional working-state tied to the active agent-mode (e.g. eliciting / observing / reviewing / reconciling) rather than raw `agent_start`/`agent_end`; hidden-thinking label varies by agent-mode or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…"). Both depend on stable producers for those signals — the chrome wrapper must not synthesize state it doesn't have, so wiring is deferred until the relevant subsystems (agent-mode dispatcher, lens registry) land. Until then, Brunch's chrome owns header and footer projection only; title and hidden-thinking-label remain Pi defaults. +- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch defers wiring them until the question of *what state they should indicate* is sharper. Candidate signals once a canonical chrome-state snapshot exists: terminal title carries spec/session identity with optional working-state tied to the active agent role (e.g. eliciting / observing / reviewing / reconciling) rather than raw `agent_start`/`agent_end`; hidden-thinking label varies by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…"). Both depend on stable producers for those signals — the chrome wrapper must not synthesize state it doesn't have, so wiring is deferred until the relevant subsystems (agent-role dispatcher, lens registry) land. Until then, Brunch's chrome owns header and footer projection only; title and hidden-thinking-label remain Pi defaults. - **Status keys as the dynamic contribution channel.** `ctx.ui.setStatus(key, text)` remains the multi-extension-friendly seam for other Brunch extensions and future dynamic Brunch state to surface in the footer's status row. Brunch's chrome wrapper does not contribute its own status key by default; it merges all foreign status entries via `footerData.getExtensionStatuses()` into the footer's right column so contributions surface without anyone owning the whole footer. ## Lexicon @@ -283,8 +299,13 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | --- | --- | | **Brunch host** | The local process-level authority. Owns `.brunch/` resolution, agent session lifecycle, mode dispatch, and event fanout. | | **Transport mode** | One of TUI, web, RPC, print. All four drive the same host; they are presentation/protocol surfaces, not separate products or agent strategies. | -| **Agent mode** | A coarse operational strategy/persona for an agent run, such as `elicitor`, `observer`, `reviewer`, `reconciler`, or a future `generalist`. Agent modes are selected independently from transport modes. | -| **Lens** | A narrower interpretive or task perspective applied within or alongside an agent mode, such as technical-design, verification-design, or disambiguation. Lenses may eventually be driven by skills, but are not part of M1 transport-mode proof. | +| **Operational mode** | A top-level Brunch authority/tooling posture such as `elicit` or future `execute`. It determines what kind of work is allowed and which tools/prompt posture are available. Distinct from Pi's transport mode concept. | +| **Agent role** | A worker identity within an operational mode. Top-level roles drive the main turn (`elicitor`, future `executor/orchestrator`); side roles run async or advisory work (`observer`, `reviewer`, `reconciler`, future `scout` / `researcher`). | +| **Runtime bundle / role preset** | The transcript-backed Brunch selection that derives active operational mode, top-level role, model, thinking level, prompt packs, allowed strategies/lenses, and tool policy. Commands switch bundles instead of mutating hidden extension memory. | +| **Strategy** | A conversation or work tactic selected within the active runtime bundle. Strategies control interaction plan; lenses control interpretive/extraction/review framing. | +| **Lens** | A narrower interpretive, extraction, or review framing applied within a role/strategy, such as technical-design, verification-design, or disambiguation. Lenses may eventually be driven by Brunch-owned prompt packs or skills. | +| **Brunch Pi Profile** | The sealed programmatic wrapper around embedded Pi: settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. It allows Brunch-owned resources while suppressing ambient `.pi/` behavior. | +| **Prompt pack** | A Brunch-owned prompt fragment selected by operational mode, role preset, strategy, lens, or spec phase/maturity. Prompt packs compose at turn boundaries; they are product control-plane state, not ambient Pi prompt templates. | | **Print snapshot** | The M1 meaning of the print transport mode: boot the Brunch host, resolve workspace/spec/session state through the coordinator, render product-shaped state, and exit without running an agent turn. | | **Spec** | A specification workspace, identified by its intent-graph root. Lives under `.brunch/`. Multiple specs may coexist per project. | | **Session** | An elicitation transcript belonging to one spec. Backed by a linear pi JSONL session under `.brunch/sessions/`. A spec may have many sessions over time; a session never changes specs. Pi branch/tree mechanics are unsupported Brunch product behavior in the POC. | @@ -332,16 +353,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | | **Brief** | A short curated product brief in `.brunch-fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | | **Capture / Run / Fixture** | A captured agent-as-user run produces a `.jsonl` transcript, `.graph.json`, `.coherence.json`, and `.meta.json` bundle under `.brunch-fixtures/<brief-id>/<run-id>/`. | -| **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent-mode — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent-modes (`elicitor` / `observer` / `reviewer` / `reconciler`) remain orthogonal. | -| **Extractive lens** | A lens producing single-question / single-answer exchanges; implicit content is captured post-exchange by the `observer` agent-mode. Low cognitive load per move; small graph mutations. | -| **Generative lens** | A lens producing batch proposals (structured entity-draft payloads in `brunch.review_set_proposal` entries); proposals are captured by the elicitor at proposal time, with the `reviewer` agent-mode running advisory analysis post-acceptance. Higher cognitive load per move; large graph mutations on acceptance. | +| **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent-modes (`elicitor` / `observer` / `reviewer` / `reconciler`) remain orthogonal. | +| **Extractive lens** | A lens producing single-question / single-answer exchanges; implicit content is captured post-exchange by the `observer` role. Low cognitive load per move; small graph mutations. | +| **Generative lens** | A lens producing batch proposals (structured entity-draft payloads in `brunch.review_set_proposal` entries); proposals are captured by the elicitor at proposal time, with the `reviewer` role running advisory analysis post-acceptance. Higher cognitive load per move; large graph mutations on acceptance. | | **Grounding bundle** | The minimum set of session-level anchors required before generative lenses produce non-speculative output: a *domain anchor*, a *protagonist anchor*, a *pain/pull anchor*, and a *constraint anchor*. Captured technical constraints land in the constraint anchor and bound subsequent technical-design fan-outs. | | **Grounding anchor** | One sentence-scale fact captured during early elicitation that contributes to the grounding bundle. | | **Establishment offer** | A `brunch.establishment_offer` custom transcript entry summarising the elicitor's perceived gaps, the available lens strategies for the next move, the recommended lens, and the agent's confidence. Source of ambient affordances rendered in the chrome region; inspectable post-hoc and fixture-able. Orientation artifact, not a default exhaustive strategy menu. | | **Elicitor intent hint** | A `brunch.elicitor_intent_hint` custom transcript entry emitted alongside a prompt or proposal, declaring `lens` and semantic targets (e.g. expected ontological sub-type) for downstream observer/reviewer routing and extraction guidance. | | **Review set** | A batch proposal generated by a generative lens, presented to the user for review-cycle acceptance (approve / request changes / reject), modeled on the GitHub PR-review-cycle. | | **Batch acceptance** | The single `CommandExecutor` call (`acceptReviewSet`) that commits an entire review set atomically as one LSN and one change-log entry, attributed to the user. The only mutation a generative-lens acceptance produces. | -| **Reviewer** | An agent-mode that runs async after batch acceptance, scoped to the accepted batch plus graph neighborhood, analyzing for coherence / completeness / gaps. Authority is narrow: writes only `reconciliation_need` records via `CommandExecutor`. Architecturally a mirror of `observer`. | +| **Reviewer** | An agent role that runs async after batch acceptance, scoped to the accepted batch plus graph neighborhood, analyzing for coherence / completeness / gaps. Authority is narrow: writes only `reconciliation_need` records via `CommandExecutor`. Architecturally a mirror of `observer`. | | **Anchor scenario** | A particular vignette embedded inside one alternative pitch to ground its framing. Transcript-rendered; not persisted as a graph entity. | | **Contrastive scenario** | A particular vignette distinguishing two alternatives, surfaced in comparison UI. Transcript-rendered. | | **Probing scenario** | A particular vignette posed by the elicitor to force a user response that disambiguates intent. Transcript-rendered; user response persists per existing elicitation mechanics. | @@ -407,7 +428,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-switcher UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-switcher UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace switcher, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | @@ -451,6 +472,8 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | | I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | +| I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | +| I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | ### Design Notes From b3d458021ca59421cb372d7537c716ef7b82b0ec Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:47:43 +0200 Subject: [PATCH 040/164] FE-744 flatten pi extension shell --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 2 +- src/brunch-tui.ts | 4 ++-- .../brunch/index.ts => pi-extensions.ts} | 19 +++++++++++-------- src/pi-extensions/{brunch => }/chrome.ts | 2 +- .../branch-policy.ts => command-policy.ts} | 0 ...ssion-boundary.ts => session-lifecycle.ts} | 0 ...e-command.ts => settings-switcher-menu.ts} | 4 ++-- 8 files changed, 18 insertions(+), 15 deletions(-) rename src/{pi-extensions/brunch/index.ts => pi-extensions.ts} (74%) rename src/pi-extensions/{brunch => }/chrome.ts (98%) rename src/pi-extensions/{brunch/branch-policy.ts => command-policy.ts} (100%) rename src/pi-extensions/{brunch/session-boundary.ts => session-lifecycle.ts} (100%) rename src/pi-extensions/{brunch/workspace-command.ts => settings-switcher-menu.ts} (95%) diff --git a/memory/CARDS.md b/memory/CARDS.md index 1e22ecdc..de653620 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -19,7 +19,7 @@ ## Card 1 — Flatten the existing product extension shell -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 38c9a6c4..e404f488 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -28,7 +28,7 @@ import { formatChromeWidgetLines, renderBrunchChrome, runBrunchWorkspaceCommand, -} from "./pi-extensions/brunch/index.js" +} from "./pi-extensions.js" import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 6200e2b9..57c7529f 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -22,7 +22,7 @@ import { import { chromeStateForWorkspace, createBrunchPiExtensionShell, -} from "./pi-extensions/brunch/index.js" +} from "./pi-extensions.js" import { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, @@ -35,7 +35,7 @@ export { type BrunchChromeStage, type BrunchChromeState, type BrunchChromeWorkerStatus, -} from "./pi-extensions/brunch/index.js" +} from "./pi-extensions.js" export { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator diff --git a/src/pi-extensions/brunch/index.ts b/src/pi-extensions.ts similarity index 74% rename from src/pi-extensions/brunch/index.ts rename to src/pi-extensions.ts index 291310ae..180b723b 100644 --- a/src/pi-extensions/brunch/index.ts +++ b/src/pi-extensions.ts @@ -3,19 +3,22 @@ import { type ExtensionFactory, } from "@earendil-works/pi-coding-agent" -import { registerBrunchBranchPolicyHandlers } from "./branch-policy.js" -import { renderBrunchChrome, type BrunchChromeState } from "./chrome.js" +import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" +import { + renderBrunchChrome, + type BrunchChromeState, +} from "./pi-extensions/chrome.js" import { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./session-boundary.js" +} from "./pi-extensions/session-lifecycle.js" import { registerBrunchWorkspaceCommand, type BrunchWorkspaceCommandOptions, -} from "./workspace-command.js" +} from "./pi-extensions/settings-switcher-menu.js" -export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./branch-policy.js" +export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" export { chromeStateForWorkspace, formatBrunchChromeHeaderLines, @@ -27,18 +30,18 @@ export { type BrunchChromeState, type BrunchChromeUi, type BrunchChromeWorkerStatus, -} from "./chrome.js" +} from "./pi-extensions/chrome.js" export { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./session-boundary.js" +} from "./pi-extensions/session-lifecycle.js" export { BRUNCH_WORKSPACE_COMMAND, registerBrunchWorkspaceCommand, runBrunchWorkspaceCommand, type BrunchWorkspaceCommandOptions, -} from "./workspace-command.js" +} from "./pi-extensions/settings-switcher-menu.js" export function createBrunchPiExtensionShell( chrome: BrunchChromeState, diff --git a/src/pi-extensions/brunch/chrome.ts b/src/pi-extensions/chrome.ts similarity index 98% rename from src/pi-extensions/brunch/chrome.ts rename to src/pi-extensions/chrome.ts index 8d942455..b6e77bba 100644 --- a/src/pi-extensions/brunch/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -3,7 +3,7 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" import type { WorkspaceSessionChromeState, WorkspaceSessionReadyState, -} from "../../workspace-session-coordinator.js" +} from "../workspace-session-coordinator.js" export type BrunchChromeStage = "idle" | "streaming" | "observer-review" export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" diff --git a/src/pi-extensions/brunch/branch-policy.ts b/src/pi-extensions/command-policy.ts similarity index 100% rename from src/pi-extensions/brunch/branch-policy.ts rename to src/pi-extensions/command-policy.ts diff --git a/src/pi-extensions/brunch/session-boundary.ts b/src/pi-extensions/session-lifecycle.ts similarity index 100% rename from src/pi-extensions/brunch/session-boundary.ts rename to src/pi-extensions/session-lifecycle.ts diff --git a/src/pi-extensions/brunch/workspace-command.ts b/src/pi-extensions/settings-switcher-menu.ts similarity index 95% rename from src/pi-extensions/brunch/workspace-command.ts rename to src/pi-extensions/settings-switcher-menu.ts index 4b610ecf..bc3bd3b5 100644 --- a/src/pi-extensions/brunch/workspace-command.ts +++ b/src/pi-extensions/settings-switcher-menu.ts @@ -7,8 +7,8 @@ import { type WorkspaceSessionReadyState, type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, -} from "../../workspace-session-coordinator.js" -import { createWorkspaceSwitchComponent } from "../../workspace-switcher/index.js" +} from "../workspace-session-coordinator.js" +import { createWorkspaceSwitchComponent } from "../workspace-switcher/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" From 6f8a646064bc6c9ddc1d50a31db130199ca36964 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:49:16 +0200 Subject: [PATCH 041/164] FE-744 move pi tui components --- .pi/extensions/brunch-messages.ts | 6 +++++- memory/CARDS.md | 2 +- package.json | 8 ++++---- src/brunch-tui.ts | 4 ++-- {.pi/components => src/pi-components}/cards.ts | 0 src/{ => pi-components}/workspace-switcher.ts | 0 src/{ => pi-components}/workspace-switcher/component.ts | 2 +- src/{ => pi-components}/workspace-switcher/index.ts | 0 src/{ => pi-components}/workspace-switcher/model.ts | 2 +- src/{ => pi-components}/workspace-switcher/preflight.ts | 2 +- src/pi-extensions/settings-switcher-menu.ts | 2 +- src/workspace-switcher.test.ts | 2 +- 12 files changed, 17 insertions(+), 13 deletions(-) rename {.pi/components => src/pi-components}/cards.ts (100%) rename src/{ => pi-components}/workspace-switcher.ts (100%) rename src/{ => pi-components}/workspace-switcher/component.ts (98%) rename src/{ => pi-components}/workspace-switcher/index.ts (100%) rename src/{ => pi-components}/workspace-switcher/model.ts (98%) rename src/{ => pi-components}/workspace-switcher/preflight.ts (94%) diff --git a/.pi/extensions/brunch-messages.ts b/.pi/extensions/brunch-messages.ts index 908ee1e6..27a232ce 100644 --- a/.pi/extensions/brunch-messages.ts +++ b/.pi/extensions/brunch-messages.ts @@ -22,7 +22,11 @@ import { Container, Text } from "@earendil-works/pi-tui" import { StringEnum } from "@earendil-works/pi-ai" import { Type } from "typebox" -import { CardComponent, ResponsiveColumns, chunk } from "../components/cards.js" +import { + CardComponent, + ResponsiveColumns, + chunk, +} from "../../src/pi-components/cards.js" // ── Types & schema ───────────────────────────────────────────────────── const FLAVOR = StringEnum(["accent", "success", "warning", "muted"] as const) diff --git a/memory/CARDS.md b/memory/CARDS.md index de653620..b832e460 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -62,7 +62,7 @@ The existing Brunch Pi extension shell is imported from flat `src/pi-extensions. ## Card 2 — Move reusable Pi TUI components under `src/pi-components` -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/package.json b/package.json index 7874dfc9..b797bcc2 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", - "lint": "oxlint src .pi/extensions .pi/components", - "lint:fix": "oxlint --fix src .pi/extensions .pi/components", - "fmt": "oxfmt src .pi/extensions .pi/components", - "fmt:check": "oxfmt --check src .pi/extensions .pi/components", + "lint": "oxlint src .pi/extensions", + "lint:fix": "oxlint --fix src .pi/extensions", + "fmt": "oxfmt src .pi/extensions", + "fmt:check": "oxfmt --check src .pi/extensions", "fix": "npm run lint:fix && npm run fmt", "check": "npm run fmt:check && npm run lint && npm run typecheck", "verify": "npm run check && npm run test && npm run build", diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 57c7529f..6bee6fd8 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -23,7 +23,7 @@ import { chromeStateForWorkspace, createBrunchPiExtensionShell, } from "./pi-extensions.js" -import { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" +import { runWorkspaceSwitchPreflight } from "./pi-components/workspace-switcher.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, @@ -36,7 +36,7 @@ export { type BrunchChromeState, type BrunchChromeWorkerStatus, } from "./pi-extensions.js" -export { runWorkspaceSwitchPreflight } from "./workspace-switcher.js" +export { runWorkspaceSwitchPreflight } from "./pi-components/workspace-switcher.js" export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator diff --git a/.pi/components/cards.ts b/src/pi-components/cards.ts similarity index 100% rename from .pi/components/cards.ts rename to src/pi-components/cards.ts diff --git a/src/workspace-switcher.ts b/src/pi-components/workspace-switcher.ts similarity index 100% rename from src/workspace-switcher.ts rename to src/pi-components/workspace-switcher.ts diff --git a/src/workspace-switcher/component.ts b/src/pi-components/workspace-switcher/component.ts similarity index 98% rename from src/workspace-switcher/component.ts rename to src/pi-components/workspace-switcher/component.ts index 5fdb7d48..f762b439 100644 --- a/src/workspace-switcher/component.ts +++ b/src/pi-components/workspace-switcher/component.ts @@ -8,7 +8,7 @@ import { import type { WorkspaceLaunchInventory, WorkspaceSwitchDecision, -} from "../workspace-session-coordinator.js" +} from "../../workspace-session-coordinator.js" import { buildWorkspaceSwitchOptions, type WorkspaceSwitchOption, diff --git a/src/workspace-switcher/index.ts b/src/pi-components/workspace-switcher/index.ts similarity index 100% rename from src/workspace-switcher/index.ts rename to src/pi-components/workspace-switcher/index.ts diff --git a/src/workspace-switcher/model.ts b/src/pi-components/workspace-switcher/model.ts similarity index 98% rename from src/workspace-switcher/model.ts rename to src/pi-components/workspace-switcher/model.ts index d7dc7a31..da500966 100644 --- a/src/workspace-switcher/model.ts +++ b/src/pi-components/workspace-switcher/model.ts @@ -2,7 +2,7 @@ import type { WorkspaceLaunchInventory, WorkspaceLaunchSession, WorkspaceSwitchDecision, -} from "../workspace-session-coordinator.js" +} from "../../workspace-session-coordinator.js" export interface WorkspaceSwitchOption { id: string diff --git a/src/workspace-switcher/preflight.ts b/src/pi-components/workspace-switcher/preflight.ts similarity index 94% rename from src/workspace-switcher/preflight.ts rename to src/pi-components/workspace-switcher/preflight.ts index 919cf84c..6b72db07 100644 --- a/src/workspace-switcher/preflight.ts +++ b/src/pi-components/workspace-switcher/preflight.ts @@ -3,7 +3,7 @@ import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" import type { WorkspaceLaunchInventory, WorkspaceSwitchDecision, -} from "../workspace-session-coordinator.js" +} from "../../workspace-session-coordinator.js" import { createWorkspaceSwitchComponent } from "./component.js" export async function runWorkspaceSwitchPreflight( diff --git a/src/pi-extensions/settings-switcher-menu.ts b/src/pi-extensions/settings-switcher-menu.ts index bc3bd3b5..6fc27c71 100644 --- a/src/pi-extensions/settings-switcher-menu.ts +++ b/src/pi-extensions/settings-switcher-menu.ts @@ -8,7 +8,7 @@ import { type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "../workspace-session-coordinator.js" -import { createWorkspaceSwitchComponent } from "../workspace-switcher/index.js" +import { createWorkspaceSwitchComponent } from "../pi-components/workspace-switcher/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" diff --git a/src/workspace-switcher.test.ts b/src/workspace-switcher.test.ts index 553f43c2..963a7ea3 100644 --- a/src/workspace-switcher.test.ts +++ b/src/workspace-switcher.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest" import { buildWorkspaceSwitchOptions, createWorkspaceSwitchComponent, -} from "./workspace-switcher.js" +} from "./pi-components/workspace-switcher.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" describe("workspace switcher", () => { From 078d2c42c762b1d2730601ac682fb40ab73f078b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:52:20 +0200 Subject: [PATCH 042/164] FE-744 add brunch menu shell --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 63 ++++++++++++++-- src/pi-components/brunch-menu.ts | 83 +++++++++++++++++++++ src/pi-extensions.ts | 4 +- src/pi-extensions/settings-switcher-menu.ts | 42 +++++++++-- 5 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/pi-components/brunch-menu.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index b832e460..560b2ca8 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -105,7 +105,7 @@ Reusable Pi TUI components live under `src/pi-components`, including the workspa ## Card 3 — Replace `/brunch-workspace` with the Brunch menu shell -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index e404f488..290e8bfa 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -20,13 +20,15 @@ import { runBrunchTui, } from "./brunch-tui.js" import { - BRUNCH_WORKSPACE_COMMAND, + BRUNCH_MENU_COMMAND, + BRUNCH_MENU_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensionShell, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, renderBrunchChrome, + runBrunchMenuCommand, runBrunchWorkspaceCommand, } from "./pi-extensions.js" import { @@ -360,9 +362,11 @@ describe("Brunch TUI boot", () => { expect(titles).toEqual(["brunch — Spec One"]) }) - it("registers a Brunch-owned workspace switch command", async () => { + it("registers the Brunch menu command and shortcut", async () => { const commands = new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() + const shortcuts = + new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), @@ -378,11 +382,56 @@ describe("Brunch TUI boot", () => { on: (_event: string, _handler: unknown) => {}, registerCommand: (name: string, opts: unknown) => commands.set(name, opts as never), + registerShortcut: (name: string, opts: unknown) => + shortcuts.set(name, opts as never), } as never) - expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( - "Switch Brunch spec/session workspace", + expect(commands.get(BRUNCH_MENU_COMMAND)?.description).toBe( + "Open the Brunch menu", + ) + expect(commands.has("brunch-workspace")).toBe(false) + expect(shortcuts.get(BRUNCH_MENU_SHORTCUT)?.description).toBe( + "Open the Brunch menu", ) + expect(shortcuts.has("ctrl+b")).toBe(false) + }) + + it("opens the workspace switcher from the Brunch menu shell", async () => { + const events: string[] = [] + const target = readyWorkspace("/tmp/project", "session-target") + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decisions: [ + "workspace", + { + action: "openSession", + specId: target.spec.id, + sessionFile: target.session.file, + }, + ], + onEvent: (event) => events.push(event), + }) + + await runBrunchMenuCommand(ctx, { + inspectWorkspace: async () => { + events.push("inspect") + return inventoryWithWorkspace(target) + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return target + }, + }) + + expect(events).toEqual([ + "waitForIdle", + "custom", + "inspect", + "custom", + "activate:openSession", + `switch:${target.session.file}`, + "notify:info", + ]) }) it("runs the in-session workspace switch through coordinator activation and replacement context", async () => { @@ -645,7 +694,8 @@ function noOpWorkspaceCoordinator(cwd: string) { function fakeCommandContext(options: { currentSessionFile: string - decision: Awaited<ReturnType<ExtensionUIContext["custom"]>> + decision?: Awaited<ReturnType<ExtensionUIContext["custom"]>> + decisions?: Array<Awaited<ReturnType<ExtensionUIContext["custom"]>>> onCustomOptions?: (customOptions: unknown) => void onEvent: (event: string) => void replacementUi?: FakeExtensionUi @@ -655,6 +705,7 @@ function fakeCommandContext(options: { options.onEvent(`notify:${type}`) } }) + const decisions = [...(options.decisions ?? [options.decision])] const ctx = { cwd: "/tmp/project", sessionManager: { @@ -667,7 +718,7 @@ function fakeCommandContext(options: { if (customOptions !== undefined) { options.onCustomOptions?.(customOptions) } - return options.decision + return decisions.shift() }, }, waitForIdle: async () => options.onEvent("waitForIdle"), diff --git a/src/pi-components/brunch-menu.ts b/src/pi-components/brunch-menu.ts new file mode 100644 index 00000000..5e1439df --- /dev/null +++ b/src/pi-components/brunch-menu.ts @@ -0,0 +1,83 @@ +import { + Key, + matchesKey, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui" + +export type BrunchMenuDecision = "workspace" | "cancel" + +export interface BrunchMenuComponentOptions { + onDecision: (decision: BrunchMenuDecision) => void +} + +interface BrunchMenuOption { + decision: BrunchMenuDecision + label: string + description: string +} + +const BRUNCH_MENU_OPTIONS: BrunchMenuOption[] = [ + { + decision: "workspace", + label: "Workspace / session", + description: "Switch specs or open/create a session", + }, + { + decision: "cancel", + label: "Cancel", + description: "Return to the current conversation", + }, +] + +export function createBrunchMenuComponent( + options: BrunchMenuComponentOptions, +): Component { + return new BrunchMenuComponent(options) +} + +class BrunchMenuComponent implements Component { + #selectedIndex = 0 + + constructor(private options: BrunchMenuComponentOptions) {} + + handleInput(data: string): void { + if (matchesKey(data, Key.up)) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#selectedIndex = Math.min( + BRUNCH_MENU_OPTIONS.length - 1, + this.#selectedIndex + 1, + ) + return + } + if (matchesKey(data, Key.escape)) { + this.options.onDecision("cancel") + return + } + if (matchesKey(data, Key.enter)) { + this.options.onDecision( + BRUNCH_MENU_OPTIONS[this.#selectedIndex]?.decision ?? "cancel", + ) + } + } + + render(width: number): string[] { + const lines = [ + "Brunch", + "Choose a product action:", + "", + ...BRUNCH_MENU_OPTIONS.flatMap((option, index) => { + const prefix = index === this.#selectedIndex ? "› " : " " + return [`${prefix}${option.label}`, ` ${option.description}`] + }), + "", + "↑↓ navigate • enter select • esc cancel", + ] + return lines.map((line) => truncateToWidth(line, width)) + } + + invalidate(): void {} +} diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 180b723b..3793b3ff 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -37,8 +37,10 @@ export { type BrunchSessionBoundaryHandler, } from "./pi-extensions/session-lifecycle.js" export { - BRUNCH_WORKSPACE_COMMAND, + BRUNCH_MENU_COMMAND, + BRUNCH_MENU_SHORTCUT, registerBrunchWorkspaceCommand, + runBrunchMenuCommand, runBrunchWorkspaceCommand, type BrunchWorkspaceCommandOptions, } from "./pi-extensions/settings-switcher-menu.js" diff --git a/src/pi-extensions/settings-switcher-menu.ts b/src/pi-extensions/settings-switcher-menu.ts index 6fc27c71..b11001dd 100644 --- a/src/pi-extensions/settings-switcher-menu.ts +++ b/src/pi-extensions/settings-switcher-menu.ts @@ -8,10 +8,15 @@ import { type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "../workspace-session-coordinator.js" +import { + createBrunchMenuComponent, + type BrunchMenuDecision, +} from "../pi-components/brunch-menu.js" import { createWorkspaceSwitchComponent } from "../pi-components/workspace-switcher/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" -export const BRUNCH_WORKSPACE_COMMAND = "brunch-workspace" +export const BRUNCH_MENU_COMMAND = "brunch" +export const BRUNCH_MENU_SHORTCUT = "ctrl+shift+b" export interface BrunchWorkspaceCommandOptions { coordinator: WorkspaceSwitchCoordinator @@ -21,19 +26,46 @@ export function registerBrunchWorkspaceCommand( pi: ExtensionAPI, { coordinator }: BrunchWorkspaceCommandOptions, ): void { - pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { - description: "Switch Brunch spec/session workspace", + pi.registerCommand(BRUNCH_MENU_COMMAND, { + description: "Open the Brunch menu", handler: async (_args, ctx) => { - await runBrunchWorkspaceCommand(ctx, coordinator) + await runBrunchMenuCommand(ctx, coordinator) + }, + }) + pi.registerShortcut?.(BRUNCH_MENU_SHORTCUT, { + description: "Open the Brunch menu", + handler: async (ctx) => { + await runBrunchMenuCommand(ctx as ExtensionCommandContext, coordinator) }, }) } -export async function runBrunchWorkspaceCommand( +export async function runBrunchMenuCommand( ctx: ExtensionCommandContext, coordinator: WorkspaceSwitchCoordinator, ): Promise<void> { await ctx.waitForIdle() + const decision = await ctx.ui.custom<BrunchMenuDecision>( + (_tui, _theme, _keybindings, done) => + createBrunchMenuComponent({ onDecision: done }), + ) + + if (decision === "cancel") { + ctx.ui.notify("Brunch menu closed.", "info") + return + } + + await runBrunchWorkspaceCommand(ctx, coordinator, { waitForIdle: false }) +} + +export async function runBrunchWorkspaceCommand( + ctx: ExtensionCommandContext, + coordinator: WorkspaceSwitchCoordinator, + options: { waitForIdle?: boolean } = {}, +): Promise<void> { + if (options.waitForIdle !== false) { + await ctx.waitForIdle() + } const inventory = await coordinator.inspectWorkspace() const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( (_tui, _theme, _keybindings, done) => From cf1959c31735baab1b905f95ac37e216d9de9de3 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:54:34 +0200 Subject: [PATCH 043/164] FE-744 merge honest chrome wrapper --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 63 +++++++++++++++---------------------- src/pi-extensions.ts | 1 + src/pi-extensions/chrome.ts | 46 ++++++++++++--------------- 4 files changed, 48 insertions(+), 64 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 560b2ca8..c185b1b8 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -149,7 +149,7 @@ Reusable Pi TUI components live under `src/pi-components`, including the workspa ## Card 4 — Port and merge honest chrome -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 290e8bfa..3c41f4dc 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -24,6 +24,7 @@ import { BRUNCH_MENU_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensionShell, + formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, @@ -192,36 +193,32 @@ describe("Brunch TUI boot", () => { ) }) - it("formats Brunch chrome from one product-state snapshot", async () => { + it("formats honest Brunch chrome from one product-state snapshot", async () => { const state = { cwd: "/tmp/project", spec: { id: "spec-1", title: "Spec One" }, session: { id: "session-1", label: "Interview #1" }, phase: "elicitation" as const, - stage: "observer-review" as const, chatMode: "responding-to-elicitation" as const, - activeLens: "problem-framing", - coherenceVerdict: "needs_review" as const, - observerStatus: "running" as const, - reviewerStatus: "queued" as const, - reconcilerStatus: "idle" as const, - reconciliationNeedCount: 3, - latestEstablishmentOfferSummary: - "Recommended lens: problem-framing; missing constraints.", } - expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( - "Spec One", - ) - expect(formatChromeWidgetLines(state).join("\n")).toContain( - "lens: problem-framing", - ) - expect(formatBrunchStatus(state)).toBe( - "Brunch · elicitation · needs_review · needs 3", - ) - expect(formatChromeWidgetLines(state).join("\n")).toContain( - "offer: Recommended lens: problem-framing; missing constraints.", - ) + expect(formatBrunchChromeHeaderLines(state)).toEqual([ + "brunch specification workspace", + "cwd: /tmp/project", + "Spec One · Interview #1", + ]) + expect(formatBrunchChromeFooterLines(state)).toEqual([ + "phase: elicitation · chat: responding-to-elicitation", + "spec: Spec One · session: Interview #1", + "", + ]) + expect(formatBrunchStatus(state)).toBe("Brunch · elicitation · Spec One") + expect(formatChromeWidgetLines(state)).toEqual([ + "cwd: /tmp/project", + "spec: Spec One", + "session: Interview #1", + "chat mode: responding-to-elicitation", + ]) }) it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { @@ -246,15 +243,7 @@ describe("Brunch TUI boot", () => { spec: { id: "spec-1", title: "Spec One" }, session: { id: "session-1" }, phase: "elicitation", - stage: "idle", chatMode: "responding-to-elicitation", - activeLens: null, - coherenceVerdict: "coherent", - observerStatus: "idle", - reviewerStatus: "idle", - reconcilerStatus: "idle", - reconciliationNeedCount: 0, - latestEstablishmentOfferSummary: null, }) expect(calls.map((call) => call.method)).toEqual([ @@ -264,20 +253,20 @@ describe("Brunch TUI boot", () => { "setWidget", "setTitle", ]) - expect(calls.find((call) => call.method === "setFooter")?.args).toEqual([ - undefined, - ]) + expect(calls.find((call) => call.method === "setFooter")?.args[0]).toEqual( + expect.any(Function), + ) expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ "brunch.chrome", - "Brunch · elicitation · coherent · needs 0", + "Brunch · elicitation · Spec One", ]) expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ "brunch.chrome", [ "cwd: /tmp/project", - "chat mode: responding-to-elicitation stage: idle", - "lens: none", - "workers: observer idle · reviewer idle · reconciler idle", + "spec: Spec One", + "session: session-1", + "chat mode: responding-to-elicitation", ], { placement: "aboveEditor" }, ]) diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 3793b3ff..0f7cf06a 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -21,6 +21,7 @@ import { export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" export { chromeStateForWorkspace, + formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index b6e77bba..e4b0916a 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -14,14 +14,6 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { id: string label?: string } - stage: BrunchChromeStage - activeLens: string | null - coherenceVerdict: BrunchChromeCoherenceVerdict - observerStatus: BrunchChromeWorkerStatus - reviewerStatus: BrunchChromeWorkerStatus - reconcilerStatus: BrunchChromeWorkerStatus - reconciliationNeedCount: number - latestEstablishmentOfferSummary: string | null } export type BrunchChromeUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setStatus" | "setWidget" | "setTitle"> @@ -31,25 +23,32 @@ export function formatBrunchChromeHeaderLines( ): string[] { return [ "brunch specification workspace", + `cwd: ${chrome.cwd}`, `${formatSpec(chrome)} · ${formatSession(chrome)}`, ] } +export function formatBrunchChromeFooterLines( + chrome: BrunchChromeState, +): string[] { + return [ + `phase: ${chrome.phase} · chat: ${chrome.chatMode}`, + `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}`, + "", + ] +} + export function formatBrunchStatus(chrome: BrunchChromeState): string { - return `Brunch · ${chrome.phase} · ${chrome.coherenceVerdict} · needs ${chrome.reconciliationNeedCount}` + return `Brunch · ${chrome.phase} · ${formatSpec(chrome)}` } export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { - const lines = [ + return [ `cwd: ${chrome.cwd}`, - `chat mode: ${chrome.chatMode} stage: ${chrome.stage}`, - `lens: ${chrome.activeLens ?? "none"}`, - `workers: observer ${chrome.observerStatus} · reviewer ${chrome.reviewerStatus} · reconciler ${chrome.reconcilerStatus}`, + `spec: ${formatSpec(chrome)}`, + `session: ${formatSession(chrome)}`, + `chat mode: ${chrome.chatMode}`, ] - if (chrome.latestEstablishmentOfferSummary) { - lines.push(`offer: ${chrome.latestEstablishmentOfferSummary}`) - } - return lines } export function chromeStateForWorkspace( @@ -61,14 +60,6 @@ export function chromeStateForWorkspace( id: workspace.session.id, label: workspace.session.id, }, - stage: "idle", - activeLens: null, - coherenceVerdict: "unknown", - observerStatus: "idle", - reviewerStatus: "idle", - reconcilerStatus: "idle", - reconciliationNeedCount: 0, - latestEstablishmentOfferSummary: null, } } @@ -80,7 +71,10 @@ export function renderBrunchChrome( render: () => formatBrunchChromeHeaderLines(chrome), invalidate: () => {}, })) - ui.setFooter(undefined) + ui.setFooter(() => ({ + render: () => formatBrunchChromeFooterLines(chrome), + invalidate: () => {}, + })) ui.setStatus("brunch.chrome", formatBrunchStatus(chrome)) ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { placement: "aboveEditor", From 61e29fa3cff782e859538ab4886dc8f5a553af32 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:56:32 +0200 Subject: [PATCH 044/164] FE-744 port operational mode policy --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 47 +++++ src/pi-extensions.ts | 3 + src/pi-extensions/operational-mode.ts | 276 ++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/pi-extensions/operational-mode.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index c185b1b8..fbc798fd 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -194,7 +194,7 @@ Reusable Pi TUI components live under `src/pi-components`, including the workspa ## Card 5 — Port operational-mode tool policy -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 3c41f4dc..1c1d2fbe 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -28,6 +28,7 @@ import { formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, + registerBrunchOperationalModePolicy, renderBrunchChrome, runBrunchMenuCommand, runBrunchWorkspaceCommand, @@ -356,6 +357,7 @@ describe("Brunch TUI boot", () => { new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() const shortcuts = new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() + const registeredTools: string[] = [] createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), @@ -373,8 +375,13 @@ describe("Brunch TUI boot", () => { commands.set(name, opts as never), registerShortcut: (name: string, opts: unknown) => shortcuts.set(name, opts as never), + registerTool: (tool: { name: string }) => registeredTools.push(tool.name), + getAllTools: () => + ["read", "grep", "find", "ls", "bash"].map((name) => ({ name })), + setActiveTools: (_tools: string[]) => {}, } as never) + expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) expect(commands.get(BRUNCH_MENU_COMMAND)?.description).toBe( "Open the Brunch menu", ) @@ -592,6 +599,46 @@ describe("Brunch TUI boot", () => { ]) }) + it("loads the elicit operational-mode tool policy from product code", async () => { + const events: Record<string, (event: never) => unknown> = {} + const activeTools: string[][] = [] + const registeredTools: string[] = [] + + registerBrunchOperationalModePolicy({ + registerTool: (tool: { name: string }) => registeredTools.push(tool.name), + getAllTools: () => + ["read", "grep", "find", "ls", "bash", "write"].map((name) => ({ + name, + })), + setActiveTools: (tools: string[]) => activeTools.push(tools), + on: (event: string, handler: (event: never) => unknown) => { + events[event] = handler + }, + } as never) + + expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) + await events.session_start?.({} as never) + expect(activeTools).toEqual([["read", "grep", "find", "ls"]]) + await expect( + Promise.resolve( + events.before_agent_start?.({ systemPrompt: "base" } as never), + ), + ).resolves.toMatchObject({ + systemPrompt: expect.stringContaining( + "Brunch exposes only read-only tools: read, grep, find, ls.", + ), + }) + await expect( + Promise.resolve(events.tool_call?.({ toolName: "write" } as never)), + ).resolves.toMatchObject({ block: true }) + expect(events.user_bash?.({ command: "rm -rf ." } as never)).toMatchObject({ + result: { + exitCode: 1, + output: "Brunch tool policy blocks shell commands: rm -rf .", + }, + }) + }) + it("suppresses generic Pi startup resources for the Brunch shell", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) const settingsManager = createBrunchSettingsManager(cwd, cwd) diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 0f7cf06a..9edcd0db 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -4,6 +4,7 @@ import { } from "@earendil-works/pi-coding-agent" import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" +import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" import { renderBrunchChrome, type BrunchChromeState, @@ -19,6 +20,7 @@ import { } from "./pi-extensions/settings-switcher-menu.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" +export { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" export { chromeStateForWorkspace, formatBrunchChromeFooterLines, @@ -61,6 +63,7 @@ export function createBrunchPiExtensionShell( }) registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) registerBrunchBranchPolicyHandlers(pi) + registerBrunchOperationalModePolicy(pi) registerBrunchWorkspaceCommand(pi, options) } } diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts new file mode 100644 index 00000000..9aa8d439 --- /dev/null +++ b/src/pi-extensions/operational-mode.ts @@ -0,0 +1,276 @@ +/** + * Brunch — tools + * + * Product-facing tool policy for the Brunch Pi wrapper prototype: + * - hard-enforce read-only tool access (`read`, `grep`, `find`, `ls`) + * - block every side-effecting tool, including `bash`, `edit`, and `write` + * - render the standard read-only tools in a deliberately tiny TUI form + * + * This is not a toggle. Brunch is testing a narrower tool surface than Pi's + * default coding-agent harness, so loading this extension means Brunch tool + * policy is active for the session. + */ + +import { homedir } from "node:os" + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { + createFindTool, + createGrepTool, + createLsTool, + createReadTool, +} from "@earendil-works/pi-coding-agent" +import { Text } from "@earendil-works/pi-tui" + +const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const +type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] + +function shortenPath(path: string): string { + const home = homedir() + if (path.startsWith(home)) return `~${path.slice(home.length)}` + return path +} + +function availableReadOnlyToolNames(pi: ExtensionAPI): ReadOnlyToolName[] { + const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name)) + return READ_ONLY_TOOLS.filter((name) => allToolNames.has(name)) +} + +function applyBrunchToolPolicy(pi: ExtensionAPI): void { + pi.setActiveTools(availableReadOnlyToolNames(pi)) +} + +interface TextLikeContent { + type: string + text?: string +} + +interface TextToolResultLike { + content?: TextLikeContent[] +} + +interface TextContent { + type: "text" + text: string +} + +function firstText(result: TextToolResultLike): TextContent | undefined { + return result.content?.find( + (content): content is TextContent => + content.type === "text" && typeof content.text === "string", + ) +} + +function nonEmptyLineCount(text: string): number { + return text + .trim() + .split("\n") + .filter((line) => line.trim().length > 0).length +} + +function emptyResult() { + return new Text("", 0, 0) +} + +const toolCache = new Map<string, ReturnType<typeof createReadOnlyTools>>() + +function createReadOnlyTools(cwd: string) { + return { + read: createReadTool(cwd), + grep: createGrepTool(cwd), + find: createFindTool(cwd), + ls: createLsTool(cwd), + } +} + +function getReadOnlyTools(cwd: string) { + let tools = toolCache.get(cwd) + if (!tools) { + tools = createReadOnlyTools(cwd) + toolCache.set(cwd, tools) + } + return tools +} + +function supportsOperationalModePolicy(pi: ExtensionAPI): boolean { + const candidate = pi as Partial<ExtensionAPI> + return ( + typeof candidate.registerTool === "function" && + typeof candidate.getAllTools === "function" && + typeof candidate.setActiveTools === "function" + ) +} + +export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { + if (!supportsOperationalModePolicy(pi)) { + return + } + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).read, + label: "read", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).read.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || "") + const range = + args.offset !== undefined || args.limit !== undefined + ? theme.fg( + "muted", + `:${args.offset ?? 1}${ + args.limit !== undefined + ? `-${(args.offset ?? 1) + args.limit - 1}` + : "" + }`, + ) + : "" + return new Text( + `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path || "…")}${range}`, + 0, + 0, + ) + }, + renderResult() { + return emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).grep, + label: "grep", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).grep.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + const glob = args.glob ? theme.fg("muted", ` ${args.glob}`) : "" + return new Text( + `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${args.pattern || "…"}/`)} ${theme.fg("muted", path)}${glob}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} matches`), 0, 0) + : emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).find, + label: "find", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).find.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + return new Text( + `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", args.pattern || "…")} ${theme.fg("muted", path)}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} files`), 0, 0) + : emptyResult() + }, + }) + + pi.registerTool({ + ...getReadOnlyTools(process.cwd()).ls, + label: "ls", + async execute(toolCallId, params, signal, onUpdate, ctx) { + return getReadOnlyTools(ctx.cwd).ls.execute( + toolCallId, + params, + signal, + onUpdate, + ) + }, + renderCall(args, theme) { + const path = shortenPath(args.path || ".") + return new Text( + `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`, + 0, + 0, + ) + }, + renderResult(result, { expanded }, theme) { + const text = firstText(result)?.text ?? "" + if (expanded && text.trim().length > 0) { + return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) + } + const count = nonEmptyLineCount(text) + return count > 0 + ? new Text(theme.fg("muted", `→ ${count} entries`), 0, 0) + : emptyResult() + }, + }) + + pi.on("session_start", async () => { + applyBrunchToolPolicy(pi) + }) + + pi.on("before_agent_start", async (event) => { + applyBrunchToolPolicy(pi) + + const tools = availableReadOnlyToolNames(pi).join(", ") || "none" + return { + systemPrompt: + event.systemPrompt + + `\n\n[Brunch tool policy]\n` + + `- Brunch exposes only read-only tools: ${tools}.\n` + + `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + + `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.`, + } + }) + + pi.on("tool_call", async (event) => { + const allowedToolNames = new Set(availableReadOnlyToolNames(pi)) + if (allowedToolNames.has(event.toolName as ReadOnlyToolName)) return + + return { + block: true, + reason: + `Brunch tool policy blocks "${event.toolName}". ` + + `Allowed tools: ${Array.from(allowedToolNames).join(", ") || "none"}.`, + } + }) + + pi.on("user_bash", (event) => ({ + result: { + output: `Brunch tool policy blocks shell commands: ${event.command}`, + exitCode: 1, + cancelled: false, + truncated: false, + }, + })) +} From f75785341e8c300c4afb072def967492ebbc3427 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:01:23 +0200 Subject: [PATCH 045/164] FE-744 port mention autocomplete --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 133 ++++++++++++++++++---- src/pi-extensions.ts | 18 ++- src/pi-extensions/mention-autocomplete.ts | 129 +++++++++++++++++++++ 4 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 src/pi-extensions/mention-autocomplete.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index fbc798fd..1bd1ca1e 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -239,7 +239,7 @@ Reusable Pi TUI components live under `src/pi-components`, including the workspa ## Card 6 — Port mention autocomplete as graph-code completion -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 1c1d2fbe..e0911692 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -28,6 +28,8 @@ import { formatBrunchChromeHeaderLines, formatBrunchStatus, formatChromeWidgetLines, + extractHashPrefix, + registerBrunchMentionAutocomplete, registerBrunchOperationalModePolicy, renderBrunchChrome, runBrunchMenuCommand, @@ -296,18 +298,18 @@ describe("Brunch TUI boot", () => { notify: (_message: string, _type?: "info" | "warning" | "error") => {}, } const ctx: FakeExtensionContext = { sessionManager: manager, ui } - let sessionStart: (( + const sessionStart: Array<( event: unknown, ctx: FakeExtensionContext, - ) => Promise<void>) | undefined - let beforeAgentStart: (( + ) => Promise<void>> = [] + const beforeAgentStart: Array<( event: unknown, ctx: FakeExtensionContext, - ) => Promise<void>) | undefined - let messageStart: (( + ) => Promise<void>> = [] + const messageStart: Array<( event: unknown, ctx: FakeExtensionContext, - ) => Promise<void>) | undefined + ) => Promise<void>> = [] createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), @@ -316,30 +318,31 @@ describe("Brunch TUI boot", () => { }, { coordinator: noOpWorkspaceCoordinator(cwd) }, )({ - on: (event: string, handler: typeof sessionStart) => { + on: (event: string, handler: never) => { if (event === "session_start") { - sessionStart = handler + sessionStart.push(handler) } if (event === "before_agent_start") { - beforeAgentStart = handler + beforeAgentStart.push(handler) } if (event === "message_start") { - messageStart = handler + messageStart.push(handler) } }, registerCommand: (_name: string, _options: unknown) => {}, } as never) - await sessionStart?.({}, ctx) - await beforeAgentStart?.({}, ctx) - await messageStart?.( - { type: "message_start", message: { role: "user" } }, - ctx, - ) - await messageStart?.( - { type: "message_start", message: { role: "assistant" } }, - ctx, - ) + for (const handler of sessionStart) await handler({}, ctx) + for (const handler of beforeAgentStart) await handler({}, ctx) + for (const handler of messageStart) { + await handler({ type: "message_start", message: { role: "user" } }, ctx) + } + for (const handler of messageStart) { + await handler( + { type: "message_start", message: { role: "assistant" } }, + ctx, + ) + } expect(boundSessionIds).toEqual([ manager.getSessionId(), @@ -599,6 +602,70 @@ describe("Brunch TUI boot", () => { ]) }) + it("registers graph-code mention autocomplete without fixture tag JSON", async () => { + let providerFactory: (( + current: FakeAutocompleteProvider, + ) => FakeAutocompleteProvider) | undefined + const source = { + listMentionCandidates: () => [ + { + code: "D12", + title: "Command containment", + description: "Blocks branchy Pi flows", + plane: "design" as const, + }, + { code: "I9", title: "Mention ledger", plane: "intent" as const }, + ], + } + + registerBrunchMentionAutocomplete( + { + on: (event: string, handler: (event: never, ctx: never) => unknown) => { + if (event === "session_start") { + void handler({} as never, { + ui: { + addAutocompleteProvider: (factory: typeof providerFactory) => { + providerFactory = factory + }, + }, + } as never) + } + }, + } as never, + source, + ) + + const fallback: FakeAutocompleteProvider = { + getSuggestions: async () => ({ items: [], prefix: "" }), + applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }), + shouldTriggerFileCompletion: () => true, + } + const provider = providerFactory?.(fallback) + + expect(extractHashPrefix("See #D1", 7)).toBe("#D1") + await expect( + provider?.getSuggestions(["See #D1"], 0, 7, {} as never), + ).resolves.toEqual({ + prefix: "#D1", + items: [ + { + value: "#D12", + label: "#D12 Command containment", + description: "Blocks branchy Pi flows", + }, + ], + }) + expect( + provider?.applyCompletion( + ["See #D"], + 0, + 6, + { value: "#D12", label: "#D12 Command containment" }, + "#D", + ), + ).toEqual({ lines: ["See #D12"], cursorLine: 0, cursorCol: 8 }) + }) + it("loads the elicit operational-mode tool policy from product code", async () => { const events: Record<string, (event: never) => unknown> = {} const activeTools: string[][] = [] @@ -797,6 +864,32 @@ type FakeExtensionContext = Pick<ExtensionContext, "sessionManager"> & { ui: FakeExtensionUi } +interface FakeAutocompleteItem { + value: string + label: string +} + +interface FakeAutocompleteProvider { + getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: never, + ): Promise<unknown> + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: FakeAutocompleteItem, + prefix: string, + ): unknown + shouldTriggerFileCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + ): boolean +} + type FakeExtensionUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setStatus" | "setWidget" | "setWorkingIndicator" | "setTitle" | "notify"> function isStringArray(value: unknown): value is string[] { diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 9edcd0db..2042b3b1 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -4,6 +4,10 @@ import { } from "@earendil-works/pi-coding-agent" import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" +import { + registerBrunchMentionAutocomplete, + type GraphMentionSource, +} from "./pi-extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" import { renderBrunchChrome, @@ -20,6 +24,12 @@ import { } from "./pi-extensions/settings-switcher-menu.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" +export { + extractHashPrefix, + registerBrunchMentionAutocomplete, + type GraphMentionCandidate, + type GraphMentionSource, +} from "./pi-extensions/mention-autocomplete.js" export { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" export { chromeStateForWorkspace, @@ -48,10 +58,15 @@ export { type BrunchWorkspaceCommandOptions, } from "./pi-extensions/settings-switcher-menu.js" +export interface BrunchPiExtensionShellOptions + extends BrunchWorkspaceCommandOptions { + graphMentionSource?: GraphMentionSource +} + export function createBrunchPiExtensionShell( chrome: BrunchChromeState, onSessionBoundary: BrunchSessionBoundaryHandler | undefined, - options: BrunchWorkspaceCommandOptions, + options: BrunchPiExtensionShellOptions, ): ExtensionFactory { return (pi) => { pi.on("session_start", async (_event, ctx) => { @@ -64,6 +79,7 @@ export function createBrunchPiExtensionShell( registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) registerBrunchBranchPolicyHandlers(pi) registerBrunchOperationalModePolicy(pi) + registerBrunchMentionAutocomplete(pi, options.graphMentionSource) registerBrunchWorkspaceCommand(pi, options) } } diff --git a/src/pi-extensions/mention-autocomplete.ts b/src/pi-extensions/mention-autocomplete.ts new file mode 100644 index 00000000..a72e99c8 --- /dev/null +++ b/src/pi-extensions/mention-autocomplete.ts @@ -0,0 +1,129 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent" +import type { + AutocompleteItem, + AutocompleteSuggestions, +} from "@earendil-works/pi-tui" + +export interface GraphMentionCandidate { + code: string + title: string + description?: string + plane?: "intent" | "oracle" | "design" | "plan" +} + +export interface GraphMentionSource { + listMentionCandidates( + ctx: ExtensionContext, + ): Promise<GraphMentionCandidate[]> | GraphMentionCandidate[] +} + +const EMPTY_GRAPH_MENTION_SOURCE: GraphMentionSource = { + listMentionCandidates: () => [], +} + +export function registerBrunchMentionAutocomplete( + pi: ExtensionAPI, + source: GraphMentionSource = EMPTY_GRAPH_MENTION_SOURCE, +): void { + pi.on("before_agent_start", async (event) => ({ + systemPrompt: + event.systemPrompt + + `\n\n[Brunch graph references]\n` + + `- Tokens like #D12 are Brunch graph mention handles inserted as visible transcript text.\n` + + `- Treat the inserted handle as the only durable reference; autocomplete labels/descriptions are UI-only and are not hidden metadata.\n` + + `- Resolve deeper graph detail only through Brunch graph lookup/read tools when those are available.`, + })) + + pi.on("session_start", async (_event, ctx) => { + if (typeof ctx.ui.addAutocompleteProvider !== "function") { + return + } + + ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + const line = lines[cursorLine] ?? "" + const prefix = extractHashPrefix(line, cursorCol) + + if (prefix === null) { + return current.getSuggestions(lines, cursorLine, cursorCol, options) + } + + const query = prefix.slice(1).toLowerCase() + const candidates = await source.listMentionCandidates(ctx) + const items: AutocompleteItem[] = candidates + .filter((candidate) => candidateMatches(candidate, query)) + .map(candidateToAutocompleteItem) + + const result: AutocompleteSuggestions = { items, prefix } + return result + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + if (!prefix.startsWith("#")) { + return current.applyCompletion( + lines, + cursorLine, + cursorCol, + item, + prefix, + ) + } + + const line = lines[cursorLine] ?? "" + const before = line.slice(0, cursorCol) + const after = line.slice(cursorCol) + const newBefore = before.slice(0, -prefix.length) + item.value + return { + lines: lines.map((candidateLine, index) => + index === cursorLine ? newBefore + after : candidateLine, + ), + cursorLine, + cursorCol: newBefore.length, + } + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return ( + current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? + false + ) + }, + })) + }) +} + +export function extractHashPrefix( + line: string, + cursorCol: number, +): string | null { + const before = line.slice(0, cursorCol) + const match = before.match(/(?:^|\s)(#[\w-]*)$/) + return match?.[1] ?? null +} + +function candidateMatches( + candidate: GraphMentionCandidate, + query: string, +): boolean { + if (query.length === 0) { + return true + } + return [candidate.code, candidate.title, candidate.description] + .filter((value): value is string => typeof value === "string") + .some((value) => value.toLowerCase().includes(query)) +} + +function candidateToAutocompleteItem( + candidate: GraphMentionCandidate, +): AutocompleteItem { + return { + value: `#${candidate.code}`, + label: `#${candidate.code} ${candidate.title}`, + ...(candidate.description !== undefined + ? { description: candidate.description } + : {}), + } +} From bad0351341b7eac89a684420c98266df396d5d25 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:04:10 +0200 Subject: [PATCH 046/164] FE-744 port alternatives primitive --- memory/CARDS.md | 2 +- src/brunch-tui.test.ts | 55 ++++++- src/pi-extensions.ts | 3 + src/pi-extensions/alternatives.ts | 214 ++++++++++++++++++++++++++ src/pi-extensions/operational-mode.ts | 8 +- 5 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 src/pi-extensions/alternatives.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 1bd1ca1e..0f801fcd 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -283,7 +283,7 @@ Reusable Pi TUI components live under `src/pi-components`, including the workspa ## Card 7 — Port alternatives/card transcript primitive without demos -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index e0911692..2717e275 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -29,6 +29,7 @@ import { formatBrunchStatus, formatChromeWidgetLines, extractHashPrefix, + registerBrunchAlternatives, registerBrunchMentionAutocomplete, registerBrunchOperationalModePolicy, renderBrunchChrome, @@ -379,12 +380,20 @@ describe("Brunch TUI boot", () => { registerShortcut: (name: string, opts: unknown) => shortcuts.set(name, opts as never), registerTool: (tool: { name: string }) => registeredTools.push(tool.name), + registerMessageRenderer: (_type: string) => {}, + sendMessage: (_message: unknown) => {}, getAllTools: () => ["read", "grep", "find", "ls", "bash"].map((name) => ({ name })), setActiveTools: (_tools: string[]) => {}, } as never) - expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) + expect(registeredTools).toEqual([ + "read", + "grep", + "find", + "ls", + "present_alternatives", + ]) expect(commands.get(BRUNCH_MENU_COMMAND)?.description).toBe( "Open the Brunch menu", ) @@ -602,6 +611,50 @@ describe("Brunch TUI boot", () => { ]) }) + it("registers alternatives cards as a transcript primitive without demo commands", async () => { + const commands: string[] = [] + const renderers: string[] = [] + const tools = new Map<string, { + execute: (id: string, params: never) => unknown + }>() + const messages: unknown[] = [] + + registerBrunchAlternatives({ + registerMessageRenderer: (type: string) => renderers.push(type), + registerTool: (tool: { + name: string + execute: (id: string, params: never) => unknown + }) => tools.set(tool.name, tool), + registerCommand: (name: string) => commands.push(name), + sendMessage: (message: unknown) => messages.push(message), + } as never) + + await expect( + Promise.resolve(tools.get("present_alternatives")?.execute("tool-1", { + headline: "Choose", + alternatives: [{ title: "A", body: "Alpha", flavor: "accent" }], + } as never)), + ).resolves.toMatchObject({ + content: [{ type: "text", text: "Presented 1 alternative." }], + details: { count: 1 }, + terminate: true, + }) + + expect(renderers).toEqual(["alternatives-card-set"]) + expect(messages).toEqual([ + { + customType: "alternatives-card-set", + content: "## Choose\n\n---\n\n### A\n\nAlpha", + display: true, + details: { + headline: "Choose", + alternatives: [{ title: "A", body: "Alpha", flavor: "accent" }], + }, + }, + ]) + expect(commands).toEqual([]) + }) + it("registers graph-code mention autocomplete without fixture tag JSON", async () => { let providerFactory: (( current: FakeAutocompleteProvider, diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 2042b3b1..2e6a05ae 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -3,6 +3,7 @@ import { type ExtensionFactory, } from "@earendil-works/pi-coding-agent" +import { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" import { registerBrunchMentionAutocomplete, @@ -23,6 +24,7 @@ import { type BrunchWorkspaceCommandOptions, } from "./pi-extensions/settings-switcher-menu.js" +export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" export { extractHashPrefix, @@ -80,6 +82,7 @@ export function createBrunchPiExtensionShell( registerBrunchBranchPolicyHandlers(pi) registerBrunchOperationalModePolicy(pi) registerBrunchMentionAutocomplete(pi, options.graphMentionSource) + registerBrunchAlternatives(pi) registerBrunchWorkspaceCommand(pi, options) } } diff --git a/src/pi-extensions/alternatives.ts b/src/pi-extensions/alternatives.ts new file mode 100644 index 00000000..da8ff092 --- /dev/null +++ b/src/pi-extensions/alternatives.ts @@ -0,0 +1,214 @@ +/** + * Brunch — custom messages + * + * Owns the `alternatives-card-set` custom message type end-to-end: + * - registerMessageRenderer to draw bordered cards in the transcript + * - registerTool (`present_alternatives`) so the LLM can emit a card set + * * + * Compared with an ephemeral picker (e.g. `ctx.ui.custom`), this surface + * PRESENTS alternatives via `pi.sendMessage` — persistent, returns + * immediately, no UI focus stolen — and is the closest existing precedent for + * the offer-first transcript-native loop tracked under FE-744 (D37-L / I23-L). + * + * Activate: + */ + +import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent" +import { Container, Text } from "@earendil-works/pi-tui" +import { StringEnum } from "@earendil-works/pi-ai" +import { Type } from "typebox" + +import { + CardComponent, + ResponsiveColumns, + chunk, +} from "../pi-components/cards.js" + +// ── Types & schema ───────────────────────────────────────────────────── +const FLAVOR = StringEnum(["accent", "success", "warning", "muted"] as const) +type Flavor = "accent" | "success" | "warning" | "muted" + +interface Alternative { + title: string + body: string + flavor?: Flavor +} + +type Layout = "stack" | "columns" + +interface AlternativesDetails { + headline?: string | undefined + alternatives: Alternative[] + layout?: Layout | undefined + columnCount?: number | undefined + minColumnWidth?: number | undefined +} + +const AlternativeSchema = Type.Object({ + title: Type.String({ description: "Short label for the card header" }), + body: Type.String({ + description: "Markdown content rendered inside the card", + }), + flavor: Type.Optional(FLAVOR), +}) + +const LAYOUT = StringEnum(["stack", "columns"] as const) + +const PresentAlternativesParams = Type.Object({ + headline: Type.Optional( + Type.String({ description: "Optional headline shown above the cards" }), + ), + alternatives: Type.Array(AlternativeSchema, { minItems: 1, maxItems: 6 }), + layout: Type.Optional(LAYOUT), + columnCount: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 4, + description: "Cards per row when layout is 'columns'. Default 2.", + }), + ), + minColumnWidth: Type.Optional( + Type.Integer({ + minimum: 20, + maximum: 200, + description: + "Minimum width per card before falling back to vertical stack. Default 40.", + }), + ), +}) + +function flavorToColor(flavor: Flavor | undefined): ThemeColor { + switch (flavor) { + case "success": + return "success" + case "warning": + return "warning" + case "muted": + return "muted" + default: + return "accent" + } +} + +// Plain-markdown fallback so RPC clients without the renderer still see +// coherent content. Also persisted as the message `content` field. +function alternativesToMarkdown(details: AlternativesDetails): string { + const sections: string[] = [] + if (details.headline) sections.push(`## ${details.headline}`) + for (const alt of details.alternatives) { + sections.push(`### ${alt.title}\n\n${alt.body}`) + } + return sections.join("\n\n---\n\n") +} + +function supportsAlternativesPrimitive(pi: ExtensionAPI): boolean { + const candidate = pi as Partial<ExtensionAPI> + return ( + typeof candidate.registerMessageRenderer === "function" && + typeof candidate.registerTool === "function" && + typeof candidate.sendMessage === "function" + ) +} + +export function registerBrunchAlternatives(pi: ExtensionAPI) { + if (!supportsAlternativesPrimitive(pi)) { + return + } + + // ── Renderer ──────────────────────────────────────────────────────── + pi.registerMessageRenderer( + "alternatives-card-set", + (message, _opts, theme) => { + const details = message.details as AlternativesDetails | undefined + if (!details) { + // Fallback: if details is missing, render the raw content string. + return new Text( + typeof message.content === "string" ? message.content : "", + 0, + 0, + ) + } + + const container = new Container() + if (details.headline) { + container.addChild( + new Text( + theme.fg("customMessageLabel", theme.bold(details.headline)), + 1, + 1, + ), + ) + } + + const layout = details.layout ?? "stack" + const columnCount = Math.max(1, Math.min(4, details.columnCount ?? 2)) + const minColumnWidth = details.minColumnWidth ?? 40 + + const makeCard = (alt: Alternative) => + new CardComponent(alt.title, alt.body, theme, flavorToColor(alt.flavor)) + + if (layout === "columns" && details.alternatives.length > 1) { + const groups = chunk(details.alternatives, columnCount) + groups.forEach((group, gi) => { + container.addChild( + new ResponsiveColumns(group.map(makeCard), minColumnWidth), + ) + if (gi < groups.length - 1) container.addChild(new Text("", 0, 0)) + }) + } else { + details.alternatives.forEach((alt, i) => { + container.addChild(makeCard(alt)) + if (i < details.alternatives.length - 1) + container.addChild(new Text("", 0, 0)) + }) + } + return container + }, + ) + + // ── Tool ──────────────────────────────────────────────────────────── + pi.registerTool({ + name: "present_alternatives", + label: "Present Alternatives", + description: + "Present 1–6 alternative options to the user as bordered cards. Each alternative has a short title and a markdown body. Optional `flavor` (accent/success/warning/muted) styles the card border. Use when comparing options, surfacing draft variants, or laying out trade-offs.", + promptSnippet: + "Present comparable alternatives as bordered cards in the transcript", + promptGuidelines: [ + "Use present_alternatives when the user needs to compare 2–6 options side by side.", + "Each alternative's body should be self-contained markdown — headings, lists, code blocks all work.", + "After present_alternatives, ask the user which one they prefer rather than picking yourself.", + ], + parameters: PresentAlternativesParams, + + async execute(_toolCallId, params) { + const details: AlternativesDetails = { + headline: params.headline, + alternatives: params.alternatives, + layout: params.layout, + columnCount: params.columnCount, + minColumnWidth: params.minColumnWidth, + } + + pi.sendMessage({ + customType: "alternatives-card-set", + content: alternativesToMarkdown(details), // fallback / replay + display: true, + details, + }) + + return { + content: [ + { + type: "text", + text: `Presented ${params.alternatives.length} alternative${ + params.alternatives.length === 1 ? "" : "s" + }.`, + }, + ], + details: { count: params.alternatives.length }, + terminate: true, + } + }, + }) +} diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index 9aa8d439..fb769f91 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -22,7 +22,13 @@ import { } from "@earendil-works/pi-coding-agent" import { Text } from "@earendil-works/pi-tui" -const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const +const READ_ONLY_TOOLS = [ + "read", + "grep", + "find", + "ls", + "present_alternatives", +] as const type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] function shortenPath(path: string): string { From adf9c6ad89398125587c50d7232e538e10eca9cc Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:08:04 +0200 Subject: [PATCH 047/164] FE-744 retire pi probe runtime --- .pi/extensions/brunch-autocomplete.ts | 200 -------- .pi/extensions/brunch-chrome.ts | 460 ------------------ .pi/extensions/brunch-commands.ts | 141 ------ .pi/extensions/brunch-messages.ts | 331 ------------- .pi/extensions/brunch-tags.json | 47 -- .pi/extensions/brunch-tools.ts | 263 ---------- .pi/settings.json | 10 - ...-ui-extension-patterns-provisional-plan.md | 2 +- docs/architecture/pi-ui-extension-patterns.md | 12 +- memory/CARDS.md | 370 -------------- memory/PLAN.md | 6 +- memory/SPEC.md | 2 +- package.json | 8 +- src/brunch-tui.test.ts | 3 +- tsconfig.json | 4 +- 15 files changed, 18 insertions(+), 1841 deletions(-) delete mode 100644 .pi/extensions/brunch-autocomplete.ts delete mode 100644 .pi/extensions/brunch-chrome.ts delete mode 100644 .pi/extensions/brunch-commands.ts delete mode 100644 .pi/extensions/brunch-messages.ts delete mode 100644 .pi/extensions/brunch-tags.json delete mode 100644 .pi/extensions/brunch-tools.ts delete mode 100644 .pi/settings.json delete mode 100644 memory/CARDS.md diff --git a/.pi/extensions/brunch-autocomplete.ts b/.pi/extensions/brunch-autocomplete.ts deleted file mode 100644 index c6a1d13b..00000000 --- a/.pi/extensions/brunch-autocomplete.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Brunch — autocomplete (`#`-tag provider) - * - * Middleware-style autocomplete provider over `ctx.ui.addAutocompleteProvider`. - * Triggers on `#<chars>` tokens at the cursor; delegates everything else - * (file completion, slash commands, etc.) to the wrapped provider. - * - * TEMPORARY: tag candidates currently load from a co-located JSON file at - * <cwd>/.pi/extensions/brunch-tags.json - * This is a stand-in until the autocomplete source is wired to brunch graph - * items (intent/oracle/design/plan nodes) and `#`-mentions become ID-anchored - * per SPEC.md D14-L / I9-L. Treat this file as throwaway scaffolding for the - * autocomplete seam; do not grow product semantics on top of the JSON store. - * - * Companion command: - * /brunch-tags-edit open the JSON tag list in `ctx.ui.editor()` - */ - -import { readFile, writeFile, access } from "node:fs/promises" -import { join } from "node:path" - -import type { - ExtensionAPI, - ExtensionContext, -} from "@earendil-works/pi-coding-agent" -import type { - AutocompleteItem, - AutocompleteSuggestions, -} from "@earendil-works/pi-tui" - -interface BrunchTag { - value: string // inserted text (without the leading '#') - label: string // display label - description?: string -} - -const SEED_TAGS: BrunchTag[] = [ - { - value: "breakfast", - label: "Breakfast", - description: "First meal of the day", - }, - { value: "brunch", label: "Brunch", description: "Late morning treat" }, - { value: "coffee", label: "Coffee", description: "Morning fuel" }, - { value: "croissant", label: "Croissant", description: "Flaky pastry" }, - { - value: "eggs-benedict", - label: "Eggs Benedict", - description: "With hollandaise", - }, - { value: "mimosa", label: "Mimosa", description: "OJ + champagne" }, - { value: "pancakes", label: "Pancakes", description: "Fluffy stack" }, - { value: "toast", label: "Toast", description: "Crispy bread" }, - { value: "waffles", label: "Waffles", description: "Grid-shaped breakfast" }, -] - -// Co-located with the extension source so editing the file (in any editor) -// takes effect on the next autocomplete invocation. -function tagsPath(ctx: ExtensionContext): string { - return join(ctx.cwd, ".pi", "extensions", "brunch-tags.json") -} - -async function ensureTagsFile(ctx: ExtensionContext): Promise<void> { - const path = tagsPath(ctx) - try { - await access(path) - } catch { - await writeFile(path, JSON.stringify(SEED_TAGS, null, 2), "utf8") - } -} - -async function loadTags(ctx: ExtensionContext): Promise<BrunchTag[]> { - try { - const raw = await readFile(tagsPath(ctx), "utf8") - const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - return parsed.filter( - (t): t is BrunchTag => - t && typeof t.value === "string" && typeof t.label === "string", - ) - } catch { - return [] - } -} - -// Extract a `#<chars>` token at the cursor. Returns the matched prefix -// (including the `#`) or null if the cursor is not inside such a token. -function extractHashPrefix(line: string, cursorCol: number): string | null { - const before = line.slice(0, cursorCol) - // `#` preceded by start-of-line or whitespace, followed by [A-Za-z0-9_-]* - const match = before.match(/(?:^|\s)(#[\w-]*)$/) - return match?.[1] ?? null -} - -export default function brunchAutocomplete(pi: ExtensionAPI) { - pi.on("before_agent_start", async (event) => ({ - systemPrompt: - event.systemPrompt + - `\n\n[Brunch fixture references]\n` + - `- Tokens like #breakfast or #coffee may be inserted by the Brunch autocomplete fixture extension.\n` + - `- Treat these as fixture-backed Brunch reference handles for testing the #mention interaction, not as Markdown hashtags.\n` + - `- Pi autocomplete persists only the inserted handle text in the transcript; popup labels/descriptions are UI-only and are not hidden metadata.\n` + - `- There is not yet a Brunch graph lookup tool in this prototype extension. Use the visible handle text only, and ask the user if deeper fixture/entity details are needed.`, - })) - - pi.on("session_start", async (_event, ctx) => { - await ensureTagsFile(ctx) - - ctx.ui.addAutocompleteProvider((current) => ({ - async getSuggestions(lines, cursorLine, cursorCol, options) { - const line = lines[cursorLine] ?? "" - const prefix = extractHashPrefix(line, cursorCol) - - if (prefix === null) { - // Not our trigger — hand off to the wrapped provider. - return current.getSuggestions(lines, cursorLine, cursorCol, options) - } - - const query = prefix.slice(1).toLowerCase() // strip leading '#' - const tags = await loadTags(ctx) // re-read JSON every time - - const filtered = - query.length === 0 - ? tags - : tags.filter((t) => t.value.toLowerCase().includes(query)) - - const items: AutocompleteItem[] = filtered.map((t) => ({ - value: `#${t.value}`, - label: `#${t.label}`, - ...(t.description !== undefined - ? { description: t.description } - : {}), - })) - - const result: AutocompleteSuggestions = { items, prefix } - return result - }, - - applyCompletion(lines, cursorLine, cursorCol, item, prefix) { - // If the prefix isn't a '#' token, let the wrapped provider handle it. - if (!prefix.startsWith("#")) { - return current.applyCompletion( - lines, - cursorLine, - cursorCol, - item, - prefix, - ) - } - - const line = lines[cursorLine] ?? "" - const before = line.slice(0, cursorCol) - const after = line.slice(cursorCol) - // Replace the trailing `prefix` (e.g. "#br") with the chosen value. - const newBefore = before.slice(0, -prefix.length) + item.value - const newLine = newBefore + after - - return { - lines: lines.map((l, i) => (i === cursorLine ? newLine : l)), - cursorLine, - cursorCol: newBefore.length, - } - }, - - shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { - // Never hijack file completion (the `@` trigger); - // delegate the decision to the wrapped provider. - return ( - current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? - false - ) - }, - })) - }) - - // Convenience: edit the tag JSON in the system editor without leaving pi. - pi.registerCommand("brunch-tags-edit", { - description: "Edit the brunch autocomplete tag list (JSON)", - handler: async (_args, ctx) => { - await ensureTagsFile(ctx) - const path = tagsPath(ctx) - const current = await readFile(path, "utf8") - const edited = await ctx.ui.editor(`Edit ${path}`, current) - if (edited === undefined) { - ctx.ui.notify("Edit cancelled", "info") - return - } - try { - const parsed = JSON.parse(edited) - if (!Array.isArray(parsed)) - throw new Error("top-level must be a JSON array") - } catch (err) { - ctx.ui.notify(`Invalid JSON: ${(err as Error).message}`, "error") - return - } - await writeFile(path, edited, "utf8") - ctx.ui.notify("Tags saved", "info") - }, - }) -} diff --git a/.pi/extensions/brunch-chrome.ts b/.pi/extensions/brunch-chrome.ts deleted file mode 100644 index f971e3f3..00000000 --- a/.pi/extensions/brunch-chrome.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * Brunch — chrome (sandbox: header + footer) - * - * Owns Pi's header and footer surfaces as the only Brunch chrome wrapper. - * Deliberately scoped to what we can render *honestly* today, with no - * speculation about a Brunch state schema we haven't designed yet. - * - * Division of labor between Pi's chrome surfaces: - * - * HEADER = identity / "where am I". Static-ish; replaced rarely. - * Brand + version + cwd. Not for runtime telemetry. - * FOOTER = runtime telemetry / "what's happening". Updated on every render. - * Brunch workspace identity + current spec + git branch + model / - * thinking + context-window gauge + foreign status entries. - * STATUS = lateral contribution channel for *other* extensions and future - * dynamic Brunch state. This file does NOT call `setStatus`. The - * footer compositor merges `footerData.getExtensionStatuses()` so - * foreign keys surface in the footer without anyone needing to own - * the whole footer. - * TITLE / HIDDEN-THINKING-LABEL = deferred. See SPEC.md - * "Chrome surface evolution": both are state-indicative surfaces - * that require canonical Brunch state to drive them. We don't have - * that schema yet, so these stay at Pi defaults. - * - * What's NOT in this file (and why): - * - No `BrunchChromeState` snapshot. The coordinator's - * `WorkspaceSessionChromeState` (cwd / spec / phase / chatMode) is the - * only canonical chrome state with a real producer, and the sandbox does - * not currently wire the coordinator in. Until it does, this extension - * renders only `ctx`-derived facts. - * - No speculative fields (lens, coherence verdict, worker statuses, - * reconciliation needs, establishment offer summaries). Those correspond - * to subsystems that don't exist yet. - * - No mutation theater. Without a real producer there's nothing to mutate. - * - */ - -import { execSync } from "node:child_process" -import { readFileSync } from "node:fs" -import path from "node:path" - -import type { - ExtensionAPI, - ExtensionContext, - Theme, -} from "@earendil-works/pi-coding-agent" -import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" - -const SESSION_BINDING_TYPE = "brunch.session_binding" -const STATE_SCHEMA_VERSION = 1 -const CONTEXT_GAUGE_WIDTH = 12 -const BAR_FILLED = "━" -const BAR_EMPTY = "─" - -// Letterform copied from: cfonts "brunch" -f tiny -c candy -// Colors are intentionally applied through the active Pi theme at render time. -const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] - -const LOCAL_BUILD_TIME = formatBuildTime(new Date()) -const ESC = String.fromCharCode(27) -const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) - -type BrunchSpecIdentity = { - id: string - title: string -} - -type WorkspaceStateFile = { - schemaVersion?: unknown - currentSpec?: { - id?: unknown - title?: unknown - } -} - -type PackageJson = { - version?: unknown - private?: unknown -} - -type BrunchVersionInfo = { - version: string - dev: string | null -} - -function formatBuildTime(date: Date): string { - return date - .toISOString() - .replace("T", " ") - .replace(/\.\d+Z$/, " UTC") -} - -function getGitSha(cwd: string): string { - try { - return execSync("git rev-parse --short=7 HEAD", { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim() - } catch { - return "" - } -} - -function readPackage(cwd: string): PackageJson { - try { - return JSON.parse( - readFileSync(path.join(cwd, "package.json"), "utf8"), - ) as PackageJson - } catch { - return {} - } -} - -function brunchVersion(cwd: string): BrunchVersionInfo { - const pkg = readPackage(cwd) - const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" - const isLocalDev = pkg.private === true || version === "0.0.0" - if (!isLocalDev) return { version: `v${version}`, dev: null } - - const gitSha = getGitSha(cwd) - const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") - return { version: `v${version}`, dev: devMeta ? `(dev ${devMeta})` : "(dev)" } -} - -function stripAnsi(text: string): string { - return text.replace(new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g"), "") -} - -function visibleLeadingSpaces(line: string): number { - const plain = stripAnsi(line) - const match = plain.match(/^ */) - return match?.[0].length ?? 0 -} - -function removeVisibleColumns(line: string, columns: number): string { - if (columns <= 0) return line - - let output = "" - let removed = 0 - for (let index = 0; index < line.length; index += 1) { - if (line[index] === ESC) { - const match = line.slice(index).match(ANSI_SEQUENCE) - if (match) { - output += match[0] - index += match[0].length - 1 - continue - } - } - - if (removed < columns) { - removed += 1 - continue - } - output += line[index]! - } - return output -} - -function cropLogo(lines: string[]): string[] { - const cropped = [...lines] - while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) - cropped.shift() - while ( - cropped.length > 0 && - stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 - ) - cropped.pop() - if (cropped.length === 0) return [] - - const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) - return cropped.map((line) => removeVisibleColumns(line, commonLeft)) -} - -function supportsTruecolor(): boolean { - const colorterm = process.env.COLORTERM?.toLowerCase() ?? "" - const term = process.env.TERM?.toLowerCase() ?? "" - return ( - colorterm === "truecolor" || - colorterm === "24bit" || - term.includes("truecolor") - ) -} - -function readLogo(cwd: string): string[] { - const asset = supportsTruecolor() - ? "brunch-logo-quad-56x18.ansi" - : "brunch-logo-quad-56x18-240.ansi" - try { - return cropLogo( - readFileSync(path.join(cwd, "assets", asset), "utf8") - .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") - .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") - .split("\n"), - ) - } catch { - return [] - } -} - -function shortenPath(p: string): string { - const home = process.env.HOME ?? process.env.USERPROFILE - if (home && p.startsWith(home)) return `~${p.slice(home.length)}` - return p -} - -function sanitizeStatusText(text: string): string { - return text - .replace(/[\r\n\t]/g, " ") - .replace(/ +/g, " ") - .trim() -} - -function formatTokens(count: number): string { - if (count < 1000) return count.toString() - if (count < 10000) return `${(count / 1000).toFixed(1)}k` - if (count < 1000000) return `${Math.round(count / 1000)}k` - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M` - return `${Math.round(count / 1000000)}M` -} - -function readWorkspaceSpec(cwd: string): BrunchSpecIdentity | null { - try { - const parsed = JSON.parse( - readFileSync(path.join(cwd, ".brunch", "state.json"), "utf8"), - ) as WorkspaceStateFile - if ( - parsed.schemaVersion === STATE_SCHEMA_VERSION && - typeof parsed.currentSpec?.id === "string" && - typeof parsed.currentSpec.title === "string" - ) { - return { id: parsed.currentSpec.id, title: parsed.currentSpec.title } - } - } catch { - // No selected Brunch workspace state yet. - } - return null -} - -function readSessionBindingSpec( - ctx: ExtensionContext, -): BrunchSpecIdentity | null { - const entries = ctx.sessionManager.getEntries() - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index] - if ( - entry?.type === "custom" && - entry.customType === SESSION_BINDING_TYPE && - typeof entry.data === "object" && - entry.data !== null && - typeof (entry.data as { specId?: unknown }).specId === "string" && - typeof (entry.data as { specTitle?: unknown }).specTitle === "string" - ) { - return { - id: (entry.data as { specId: string }).specId, - title: (entry.data as { specTitle: string }).specTitle, - } - } - } - return null -} - -function currentSpec(ctx: ExtensionContext): BrunchSpecIdentity | null { - return readWorkspaceSpec(ctx.cwd) ?? readSessionBindingSpec(ctx) -} - -function renderContextGauge(ctx: ExtensionContext, theme: Theme): string { - const usage = ctx.getContextUsage() - const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0 - const percent = usage?.percent ?? null - const tokens = usage?.tokens ?? null - - const clamped = Math.max(0, Math.min(100, percent ?? 0)) - const filled = - percent === null ? 0 : Math.round((clamped / 100) * CONTEXT_GAUGE_WIDTH) - const empty = CONTEXT_GAUGE_WIDTH - filled - const bar = BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(empty) - const percentText = percent === null ? "?%" : `${Math.round(clamped)}%` - const counts = - tokens === null || contextWindow === 0 - ? `?/${formatTokens(contextWindow)}` - : `${formatTokens(tokens)}/${formatTokens(contextWindow)}` - - return theme.fg("dim", `${bar} ${percentText} ${counts}`) -} - -function rightAlign(left: string, right: string, width: number): string { - const leftWidth = visibleWidth(left) - const rightWidth = visibleWidth(right) - const minPadding = 2 - if (leftWidth + minPadding + rightWidth <= width) { - return left + " ".repeat(width - leftWidth - rightWidth) + right - } - - const availableForRight = width - leftWidth - minPadding - if (availableForRight <= 0) return truncateToWidth(left, width) - const truncatedRight = truncateToWidth(right, availableForRight, "") - return ( - left + - " ".repeat(Math.max(2, width - leftWidth - visibleWidth(truncatedRight))) + - truncatedRight - ) -} - -function projectName(cwd: string): string { - return path.basename(path.resolve(cwd)) -} - -function paddedHeaderLine(content: string, width: number): string { - if (width <= 2) return truncateToWidth(content, width) - const inner = truncateToWidth(content, width - 2) - return ` ${inner}${" ".repeat(Math.max(0, width - 1 - visibleWidth(inner)))}` -} - -function emptyHeaderLine(width: number): string { - return " ".repeat(Math.max(0, width)) -} - -// ── Header ───────────────────────────────────────────────────────────── -function installHeader(ctx: ExtensionContext): void { - if (!ctx.hasUI) return - - const logoLines = readLogo(ctx.cwd) - - ctx.ui.setHeader((_tui, theme) => ({ - render: (width: number) => { - const versionInfo = brunchVersion(ctx.cwd) - const versionLine = - theme.fg("accent", `brunch ${versionInfo.version}`) + - (versionInfo.dev ? ` ${theme.fg("success", versionInfo.dev)}` : "") - const piLine = theme.fg("dim", `built on Pi v${PI_VERSION}`) - const projectRootLine = theme.fg( - "dim", - `project root: ${shortenPath(path.resolve(ctx.cwd))}`, - ) - - return [ - emptyHeaderLine(width), - ...logoLines.map((line) => paddedHeaderLine(line, width)), - emptyHeaderLine(width), - ...BRUNCH_WORDMARK.map((line) => - paddedHeaderLine(theme.fg("muted", line), width), - ), - emptyHeaderLine(width), - paddedHeaderLine(versionLine, width), - paddedHeaderLine(piLine, width), - paddedHeaderLine(projectRootLine, width), - emptyHeaderLine(width), - ] - }, - invalidate: () => {}, - })) -} - -// ── Footer ───────────────────────────────────────────────────────────── -function installFooter( - ctx: ExtensionContext, - pi: ExtensionAPI, - setRequestFooterRender: (requestRender: (() => void) | null) => void, -): void { - if (!ctx.hasUI) return - - ctx.ui.setFooter((tui, theme, footerData) => { - // Re-render whenever the git branch changes — free signal Pi already - // provides. Model/thinking changes are handled by extension-level event - // listeners below. - setRequestFooterRender(() => tui.requestRender()) - const unsub = footerData.onBranchChange(() => tui.requestRender()) - - return { - dispose: () => { - unsub() - setRequestFooterRender(null) - }, - invalidate: () => {}, - render: (width: number): string[] => { - const branch = footerData.getGitBranch() ?? "no branch" - const spec = currentSpec(ctx) - const specTitle = spec?.title ?? "none" - - const projectLine = rightAlign( - `${theme.fg("accent", "project:")} ${theme.fg("success", projectName(ctx.cwd))}`, - `${theme.fg("accent", "specification:")} ${theme.fg("success", specTitle)}`, - width, - ) - - const modelName = ctx.model?.id ?? "no-model" - const thinkingLevel = pi.getThinkingLevel() - let modelLabel = modelName - if (ctx.model?.reasoning) { - modelLabel = - thinkingLevel === "off" - ? `${modelName} • thinking off` - : `${modelName} • ${thinkingLevel}` - } - if (footerData.getAvailableProviderCount() > 1 && ctx.model) { - modelLabel = `(${ctx.model.provider}) ${modelLabel}` - } - - const rootLine = rightAlign( - theme.fg("dim", shortenPath(path.resolve(ctx.cwd))), - theme.fg("dim", modelLabel), - width, - ) - const branchLine = rightAlign( - theme.fg("dim", branch), - renderContextGauge(ctx, theme), - width, - ) - - const lines = [projectLine, rootLine, branchLine] - - const extensionStatuses = footerData.getExtensionStatuses() - if (extensionStatuses.size > 0) { - const statusLine = Array.from(extensionStatuses.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => sanitizeStatusText(text)) - .filter(Boolean) - .join(" ") - if (statusLine.length > 0) { - lines.push( - truncateToWidth(statusLine, width, theme.fg("dim", "...")), - ) - } - } - - // One trailing row keeps VS Code's terminal from visually pinning the - // footer against the bottom edge; Ghostty already adds some external - // breathing room, so a single blank row is the least surprising shim. - lines.push("") - return lines - }, - } - }) -} - -// ── Extension entry ──────────────────────────────────────────────────── -export default function brunchChrome(pi: ExtensionAPI) { - let requestFooterRender: (() => void) | null = null - - pi.on("session_start", async (_event, ctx) => { - installHeader(ctx) - installFooter(ctx, pi, (requestRender) => { - requestFooterRender = requestRender - }) - }) - - pi.on("model_select", async () => { - requestFooterRender?.() - }) - - pi.on("thinking_level_select", async () => { - requestFooterRender?.() - }) - - pi.on("turn_end", async () => { - requestFooterRender?.() - }) -} diff --git a/.pi/extensions/brunch-commands.ts b/.pi/extensions/brunch-commands.ts deleted file mode 100644 index 57ad5fa0..00000000 --- a/.pi/extensions/brunch-commands.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Brunch — commands - * - * Slash commands and shortcuts. Currently exercises Pi's `ctx.ui.custom()` - * with the shipped `SettingsList` widget as a placeholder for richer Brunch - * dialogs. State is module-scoped, which means it resets on `/reload`; if/when - * persistence matters, write a custom session entry on change and rehydrate on - * `session_start`. - * - * Activate via: - * /brunch slash command - * ctrl+shift+b keyboard shortcut - * - * (The previous `ctrl+b` alias has been removed because it collided with - * `tui.editor.cursorLeft`.) - */ - -import type { - ExtensionAPI, - ExtensionContext, -} from "@earendil-works/pi-coding-agent" -import { getSettingsListTheme } from "@earendil-works/pi-coding-agent" -import { SettingsList, type SettingItem } from "@earendil-works/pi-tui" - -interface BrunchState { - drink: string - eggs: string - toast: string - hashBrowns: string - mood: string -} - -export default function brunchCommands(pi: ExtensionAPI) { - // Module-scoped — reset on `/reload`. See header comment. - const state: BrunchState = { - drink: "Coffee", - eggs: "Scrambled", - toast: "Sourdough", - hashBrowns: "Yes", - mood: "Leisurely", - } - - function buildItems(): SettingItem[] { - return [ - { - id: "drink", - label: "Drink", - description: "What's in your glass or mug?", - currentValue: state.drink, - values: ["Coffee", "Tea", "Juice", "Mimosa", "Water"], - }, - { - id: "eggs", - label: "Eggs", - description: "How would you like your eggs?", - currentValue: state.eggs, - values: [ - "Scrambled", - "Poached", - "Fried", - "Over Easy", - "Omelette", - "None", - ], - }, - { - id: "toast", - label: "Toast", - description: "Bread choice", - currentValue: state.toast, - values: ["Sourdough", "White", "Rye", "Multigrain", "None"], - }, - { - id: "hashBrowns", - label: "Hash Browns", - description: "Always a good idea", - currentValue: state.hashBrowns, - values: ["Yes", "No"], - }, - { - id: "mood", - label: "Mood", - description: "Pacing for the meal", - currentValue: state.mood, - values: ["Leisurely", "Focused", "Chatty", "Quiet"], - }, - ] - } - - function summarize(): string { - return `🥐 ${state.drink} · ${state.eggs} eggs · ${state.toast} · Hash browns: ${state.hashBrowns} · ${state.mood}` - } - - async function openBrunch(ctx: ExtensionContext) { - if (!ctx.hasUI) { - ctx.ui?.notify?.("Brunch settings require UI mode", "warning") - return - } - - await ctx.ui.custom<void>((_tui, _theme, _kb, done) => { - const items = buildItems() - const list = new SettingsList( - items, - 10, // maxVisible: rows shown at once - getSettingsListTheme(), - (id, newValue) => { - // Mirror the picked value into module state. The list updates its - // own currentValue display internally. - if (id === "drink") state.drink = newValue - else if (id === "eggs") state.eggs = newValue - else if (id === "toast") state.toast = newValue - else if (id === "hashBrowns") state.hashBrowns = newValue - else if (id === "mood") state.mood = newValue - }, - () => done(), - { enableSearch: true }, - ) - - return { - render: (width: number) => list.render(width), - invalidate: () => list.invalidate(), - handleInput: (data: string) => list.handleInput(data), - } - }) - - // After dismissal, surface the current selection as a transient toast. - // Persistent chrome (status/widget/header/footer) is deliberately not - // touched from here — it lives in `brunch-chrome.ts`. - ctx.ui.notify(summarize(), "info") - } - - pi.registerCommand("brunch", { - description: "Open the brunch settings selector", - handler: async (_args, ctx) => openBrunch(ctx), - }) - - pi.registerShortcut("ctrl+shift+b", { - description: "Open brunch settings", - handler: async (ctx) => openBrunch(ctx), - }) -} diff --git a/.pi/extensions/brunch-messages.ts b/.pi/extensions/brunch-messages.ts deleted file mode 100644 index 27a232ce..00000000 --- a/.pi/extensions/brunch-messages.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Brunch — custom messages - * - * Owns the `alternatives-card-set` custom message type end-to-end: - * - registerMessageRenderer to draw bordered cards in the transcript - * - registerTool (`present_alternatives`) so the LLM can emit a card set - * - demo slash commands that emit card sets directly for visual smoke - * - * Compared with an ephemeral picker (e.g. `ctx.ui.custom`), this surface - * PRESENTS alternatives via `pi.sendMessage` — persistent, returns - * immediately, no UI focus stolen — and is the closest existing precedent for - * the offer-first transcript-native loop tracked under FE-744 (D37-L / I23-L). - * - * Activate: - * /cards-demo three sample alternatives - * /cards-columns-demo four cards in a 2-column layout - * /cards-flavors one card per flavor (accent/success/warning/muted) - */ - -import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent" -import { Container, Text } from "@earendil-works/pi-tui" -import { StringEnum } from "@earendil-works/pi-ai" -import { Type } from "typebox" - -import { - CardComponent, - ResponsiveColumns, - chunk, -} from "../../src/pi-components/cards.js" - -// ── Types & schema ───────────────────────────────────────────────────── -const FLAVOR = StringEnum(["accent", "success", "warning", "muted"] as const) -type Flavor = "accent" | "success" | "warning" | "muted" - -interface Alternative { - title: string - body: string - flavor?: Flavor -} - -type Layout = "stack" | "columns" - -interface AlternativesDetails { - headline?: string | undefined - alternatives: Alternative[] - layout?: Layout | undefined - columnCount?: number | undefined - minColumnWidth?: number | undefined -} - -const AlternativeSchema = Type.Object({ - title: Type.String({ description: "Short label for the card header" }), - body: Type.String({ - description: "Markdown content rendered inside the card", - }), - flavor: Type.Optional(FLAVOR), -}) - -const LAYOUT = StringEnum(["stack", "columns"] as const) - -const PresentAlternativesParams = Type.Object({ - headline: Type.Optional( - Type.String({ description: "Optional headline shown above the cards" }), - ), - alternatives: Type.Array(AlternativeSchema, { minItems: 1, maxItems: 6 }), - layout: Type.Optional(LAYOUT), - columnCount: Type.Optional( - Type.Integer({ - minimum: 1, - maximum: 4, - description: "Cards per row when layout is 'columns'. Default 2.", - }), - ), - minColumnWidth: Type.Optional( - Type.Integer({ - minimum: 20, - maximum: 200, - description: - "Minimum width per card before falling back to vertical stack. Default 40.", - }), - ), -}) - -function flavorToColor(flavor: Flavor | undefined): ThemeColor { - switch (flavor) { - case "success": - return "success" - case "warning": - return "warning" - case "muted": - return "muted" - default: - return "accent" - } -} - -// Plain-markdown fallback so RPC clients without the renderer still see -// coherent content. Also persisted as the message `content` field. -function alternativesToMarkdown(details: AlternativesDetails): string { - const sections: string[] = [] - if (details.headline) sections.push(`## ${details.headline}`) - for (const alt of details.alternatives) { - sections.push(`### ${alt.title}\n\n${alt.body}`) - } - return sections.join("\n\n---\n\n") -} - -export default function brunchMessages(pi: ExtensionAPI) { - // ── Renderer ──────────────────────────────────────────────────────── - pi.registerMessageRenderer( - "alternatives-card-set", - (message, _opts, theme) => { - const details = message.details as AlternativesDetails | undefined - if (!details) { - // Fallback: if details is missing, render the raw content string. - return new Text( - typeof message.content === "string" ? message.content : "", - 0, - 0, - ) - } - - const container = new Container() - if (details.headline) { - container.addChild( - new Text( - theme.fg("customMessageLabel", theme.bold(details.headline)), - 1, - 1, - ), - ) - } - - const layout = details.layout ?? "stack" - const columnCount = Math.max(1, Math.min(4, details.columnCount ?? 2)) - const minColumnWidth = details.minColumnWidth ?? 40 - - const makeCard = (alt: Alternative) => - new CardComponent(alt.title, alt.body, theme, flavorToColor(alt.flavor)) - - if (layout === "columns" && details.alternatives.length > 1) { - const groups = chunk(details.alternatives, columnCount) - groups.forEach((group, gi) => { - container.addChild( - new ResponsiveColumns(group.map(makeCard), minColumnWidth), - ) - if (gi < groups.length - 1) container.addChild(new Text("", 0, 0)) - }) - } else { - details.alternatives.forEach((alt, i) => { - container.addChild(makeCard(alt)) - if (i < details.alternatives.length - 1) - container.addChild(new Text("", 0, 0)) - }) - } - return container - }, - ) - - // ── Tool ──────────────────────────────────────────────────────────── - pi.registerTool({ - name: "present_alternatives", - label: "Present Alternatives", - description: - "Present 1–6 alternative options to the user as bordered cards. Each alternative has a short title and a markdown body. Optional `flavor` (accent/success/warning/muted) styles the card border. Use when comparing options, surfacing draft variants, or laying out trade-offs.", - promptSnippet: - "Present comparable alternatives as bordered cards in the transcript", - promptGuidelines: [ - "Use present_alternatives when the user needs to compare 2–6 options side by side.", - "Each alternative's body should be self-contained markdown — headings, lists, code blocks all work.", - "After present_alternatives, ask the user which one they prefer rather than picking yourself.", - ], - parameters: PresentAlternativesParams, - - async execute(_toolCallId, params) { - const details: AlternativesDetails = { - headline: params.headline, - alternatives: params.alternatives, - layout: params.layout, - columnCount: params.columnCount, - minColumnWidth: params.minColumnWidth, - } - - pi.sendMessage({ - customType: "alternatives-card-set", - content: alternativesToMarkdown(details), // fallback / replay - display: true, - details, - }) - - return { - content: [ - { - type: "text", - text: `Presented ${params.alternatives.length} alternative${ - params.alternatives.length === 1 ? "" : "s" - }.`, - }, - ], - details: { count: params.alternatives.length }, - terminate: true, - } - }, - }) - - // ── Demo commands ─────────────────────────────────────────────────── - pi.registerCommand("cards-demo", { - description: "Render three sample alternative cards in the transcript", - handler: async (_args, _ctx) => { - const details: AlternativesDetails = { - headline: "Three approaches to caching", - alternatives: [ - { - title: "In-memory LRU", - flavor: "accent", - body: [ - "**Pros**", - "- Zero deploy overhead", - "- Sub-millisecond access", - "", - "**Cons**", - "- Lost on restart", - "- Not shared across replicas", - "", - "```ts", - "const cache = new LRU<string, Value>({ max: 1000 });", - "```", - ].join("\n"), - }, - { - title: "Redis", - flavor: "success", - body: [ - "**Pros**", - "- Survives restarts", - "- Shared across replicas", - "- Battle-tested", - "", - "**Cons**", - "- New infra to operate", - "- Network hop on every read", - ].join("\n"), - }, - { - title: "Filesystem", - flavor: "warning", - body: [ - "**Pros**", - "- Cheap, no new infra", - "", - "**Cons**", - "- Slow", - "- Concurrency tricky", - "- Not great for hot data", - ].join("\n"), - }, - ], - } - - pi.sendMessage({ - customType: "alternatives-card-set", - content: alternativesToMarkdown(details), - display: true, - details, - }) - }, - }) - - pi.registerCommand("cards-columns-demo", { - description: "Render four alternative cards in a 2-column layout", - handler: async (_args, _ctx) => { - const details: AlternativesDetails = { - headline: "Four ways to ship the feature", - layout: "columns", - columnCount: 2, - minColumnWidth: 40, - alternatives: [ - { - title: "Vertical slice", - flavor: "accent", - body: "Build one thin path end-to-end.\n\n- Fast feedback\n- High confidence\n- Real integration", - }, - { - title: "Horizontal layers", - flavor: "warning", - body: "Build each layer fully before the next.\n\n- Easier coordination\n- Riskier integration\n- Late surprises", - }, - { - title: "Feature flag", - flavor: "success", - body: "Ship behind a toggle and dark-launch.\n\n- Safe rollout\n- Production validation\n- Flag debt", - }, - { - title: "Spike first", - flavor: "muted", - body: "Throw-away prototype to retire risk.\n\n- Cheap learning\n- Discard the code\n- Plan the real build after", - }, - ], - } - pi.sendMessage({ - customType: "alternatives-card-set", - content: alternativesToMarkdown(details), - display: true, - details, - }) - }, - }) - - pi.registerCommand("cards-flavors", { - description: "Show one card per flavor to compare colors", - handler: async (_args, _ctx) => { - const details: AlternativesDetails = { - headline: "Flavor palette", - alternatives: (["accent", "success", "warning", "muted"] as const).map( - (flavor) => ({ - title: flavor, - flavor, - body: `This is a **${flavor}** card. Its border, title accents, and any inline emphasis use the \`${flavor}\` theme color.`, - }), - ), - } - - pi.sendMessage({ - customType: "alternatives-card-set", - content: alternativesToMarkdown(details), - display: true, - details, - }) - }, - }) -} diff --git a/.pi/extensions/brunch-tags.json b/.pi/extensions/brunch-tags.json deleted file mode 100644 index c7746223..00000000 --- a/.pi/extensions/brunch-tags.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "value": "breakfast", - "label": "Breakfast", - "description": "First meal of the day" - }, - { - "value": "brunch", - "label": "Brunch", - "description": "Late morning treat" - }, - { - "value": "coffee", - "label": "Coffee", - "description": "Morning fuel" - }, - { - "value": "croissant", - "label": "Croissant", - "description": "Flaky pastry" - }, - { - "value": "eggs-benedict", - "label": "Eggs Benedict", - "description": "With hollandaise" - }, - { - "value": "mimosa", - "label": "Mimosa", - "description": "OJ + champagne" - }, - { - "value": "pancakes", - "label": "Pancakes", - "description": "Fluffy stack" - }, - { - "value": "toast", - "label": "Toast", - "description": "Crispy bread" - }, - { - "value": "waffles", - "label": "Waffles", - "description": "Grid-shaped breakfast" - } -] diff --git a/.pi/extensions/brunch-tools.ts b/.pi/extensions/brunch-tools.ts deleted file mode 100644 index 0af88b0e..00000000 --- a/.pi/extensions/brunch-tools.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Brunch — tools - * - * Product-facing tool policy for the Brunch Pi wrapper prototype: - * - hard-enforce read-only tool access (`read`, `grep`, `find`, `ls`) - * - block every side-effecting tool, including `bash`, `edit`, and `write` - * - render the standard read-only tools in a deliberately tiny TUI form - * - * This is not a toggle. Brunch is testing a narrower tool surface than Pi's - * default coding-agent harness, so loading this extension means Brunch tool - * policy is active for the session. - */ - -import { homedir } from "node:os" - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { - createFindTool, - createGrepTool, - createLsTool, - createReadTool, -} from "@earendil-works/pi-coding-agent" -import { Text } from "@earendil-works/pi-tui" - -const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const -type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] - -function shortenPath(path: string): string { - const home = homedir() - if (path.startsWith(home)) return `~${path.slice(home.length)}` - return path -} - -function availableReadOnlyToolNames(pi: ExtensionAPI): ReadOnlyToolName[] { - const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name)) - return READ_ONLY_TOOLS.filter((name) => allToolNames.has(name)) -} - -function applyBrunchToolPolicy(pi: ExtensionAPI): void { - pi.setActiveTools(availableReadOnlyToolNames(pi)) -} - -interface TextLikeContent { - type: string - text?: string -} - -interface TextToolResultLike { - content?: TextLikeContent[] -} - -interface TextContent { - type: "text" - text: string -} - -function firstText(result: TextToolResultLike): TextContent | undefined { - return result.content?.find( - (content): content is TextContent => - content.type === "text" && typeof content.text === "string", - ) -} - -function nonEmptyLineCount(text: string): number { - return text - .trim() - .split("\n") - .filter((line) => line.trim().length > 0).length -} - -function emptyResult() { - return new Text("", 0, 0) -} - -const toolCache = new Map<string, ReturnType<typeof createReadOnlyTools>>() - -function createReadOnlyTools(cwd: string) { - return { - read: createReadTool(cwd), - grep: createGrepTool(cwd), - find: createFindTool(cwd), - ls: createLsTool(cwd), - } -} - -function getReadOnlyTools(cwd: string) { - let tools = toolCache.get(cwd) - if (!tools) { - tools = createReadOnlyTools(cwd) - toolCache.set(cwd, tools) - } - return tools -} - -export default function brunchTools(pi: ExtensionAPI) { - pi.registerTool({ - ...getReadOnlyTools(process.cwd()).read, - label: "read", - async execute(toolCallId, params, signal, onUpdate, ctx) { - return getReadOnlyTools(ctx.cwd).read.execute( - toolCallId, - params, - signal, - onUpdate, - ) - }, - renderCall(args, theme) { - const path = shortenPath(args.path || "") - const range = - args.offset !== undefined || args.limit !== undefined - ? theme.fg( - "muted", - `:${args.offset ?? 1}${ - args.limit !== undefined - ? `-${(args.offset ?? 1) + args.limit - 1}` - : "" - }`, - ) - : "" - return new Text( - `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path || "…")}${range}`, - 0, - 0, - ) - }, - renderResult() { - return emptyResult() - }, - }) - - pi.registerTool({ - ...getReadOnlyTools(process.cwd()).grep, - label: "grep", - async execute(toolCallId, params, signal, onUpdate, ctx) { - return getReadOnlyTools(ctx.cwd).grep.execute( - toolCallId, - params, - signal, - onUpdate, - ) - }, - renderCall(args, theme) { - const path = shortenPath(args.path || ".") - const glob = args.glob ? theme.fg("muted", ` ${args.glob}`) : "" - return new Text( - `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${args.pattern || "…"}/`)} ${theme.fg("muted", path)}${glob}`, - 0, - 0, - ) - }, - renderResult(result, { expanded }, theme) { - const text = firstText(result)?.text ?? "" - if (expanded && text.trim().length > 0) { - return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) - } - const count = nonEmptyLineCount(text) - return count > 0 - ? new Text(theme.fg("muted", `→ ${count} matches`), 0, 0) - : emptyResult() - }, - }) - - pi.registerTool({ - ...getReadOnlyTools(process.cwd()).find, - label: "find", - async execute(toolCallId, params, signal, onUpdate, ctx) { - return getReadOnlyTools(ctx.cwd).find.execute( - toolCallId, - params, - signal, - onUpdate, - ) - }, - renderCall(args, theme) { - const path = shortenPath(args.path || ".") - return new Text( - `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", args.pattern || "…")} ${theme.fg("muted", path)}`, - 0, - 0, - ) - }, - renderResult(result, { expanded }, theme) { - const text = firstText(result)?.text ?? "" - if (expanded && text.trim().length > 0) { - return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) - } - const count = nonEmptyLineCount(text) - return count > 0 - ? new Text(theme.fg("muted", `→ ${count} files`), 0, 0) - : emptyResult() - }, - }) - - pi.registerTool({ - ...getReadOnlyTools(process.cwd()).ls, - label: "ls", - async execute(toolCallId, params, signal, onUpdate, ctx) { - return getReadOnlyTools(ctx.cwd).ls.execute( - toolCallId, - params, - signal, - onUpdate, - ) - }, - renderCall(args, theme) { - const path = shortenPath(args.path || ".") - return new Text( - `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`, - 0, - 0, - ) - }, - renderResult(result, { expanded }, theme) { - const text = firstText(result)?.text ?? "" - if (expanded && text.trim().length > 0) { - return new Text(`\n${theme.fg("toolOutput", text.trim())}`, 0, 0) - } - const count = nonEmptyLineCount(text) - return count > 0 - ? new Text(theme.fg("muted", `→ ${count} entries`), 0, 0) - : emptyResult() - }, - }) - - pi.on("session_start", async () => { - applyBrunchToolPolicy(pi) - }) - - pi.on("before_agent_start", async (event) => { - applyBrunchToolPolicy(pi) - - const tools = availableReadOnlyToolNames(pi).join(", ") || "none" - return { - systemPrompt: - event.systemPrompt + - `\n\n[Brunch tool policy]\n` + - `- Brunch exposes only read-only tools: ${tools}.\n` + - `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + - `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.`, - } - }) - - pi.on("tool_call", async (event) => { - const allowedToolNames = new Set(availableReadOnlyToolNames(pi)) - if (allowedToolNames.has(event.toolName as ReadOnlyToolName)) return - - return { - block: true, - reason: - `Brunch tool policy blocks "${event.toolName}". ` + - `Allowed tools: ${Array.from(allowedToolNames).join(", ") || "none"}.`, - } - }) - - pi.on("user_bash", (event) => ({ - result: { - output: `Brunch tool policy blocks shell commands: ${event.command}`, - exitCode: 1, - cancelled: false, - truncated: false, - }, - })) -} diff --git a/.pi/settings.json b/.pi/settings.json deleted file mode 100644 index b16a28e3..00000000 --- a/.pi/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "quietStartup": true, - "extensions": [ - "-extensions/brunch-tools.ts" - ], - "skills": [ - "-skills/d3k/SKILL.md", - "-skills/planning-pr/SKILL.md" - ] -} \ No newline at end of file diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md index 99ddcbcb..7128f6df 100644 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -4,7 +4,7 @@ This file is a trimmed working inventory for the remaining FE-744 gap. It is not ## Why this is still live -Command containment, Brunch chrome, startup no-resume, and `/brunch-workspace` are proven enough for now. The unresolved POC seam is different: +Command containment, Brunch chrome, startup no-resume, and the `/brunch` menu/workspace switch flow are proven enough for now. The unresolved POC seam is different: > Brunch sessions must work elicitation-first: a system/assistant-originated question, questionnaire, or offer should own the response surface, persist a terminal structured result in Pi JSONL, and be projectable as a prompt/response elicitation exchange before the next agent turn. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index c24c1a8e..81f8200b 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -20,8 +20,8 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. -- **Raw Pi harness oracle:** temporary project extension `.pi/extensions/brunch-command-probe.ts` was loaded with `pi --mode rpc --no-session -e .pi/extensions/brunch-command-probe.ts`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions/brunch/index.ts`, with private surface modules for chrome (`chrome.ts`), session-boundary binding (`session-boundary.ts`), branch policy (`branch-policy.ts`), and the in-session workspace switch command (`workspace-command.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, restores the default footer with `setFooter(undefined)`, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace-command tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. +- **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the Brunch menu/workspace switcher (`settings-switcher-menu.ts` plus `src/pi-components/brunch-menu.ts`), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; menu/workspace tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -84,7 +84,7 @@ Pi autocomplete persists only the text inserted into the editor. For both file c Brunch `#` mentions must therefore use a stable inserted handle (`#A12`, `#I7`, or a stable node id) as the durable transcript reference. If the agent needs deeper detail, Brunch must teach that convention through `before_agent_start` system-prompt injection and provide a read-only lookup/re-read tool that resolves the handle against the local graph DB. Any structured mention ledger or staleness state is Brunch-owned parsing/indexing work layered after insertion; it is not supplied by Pi autocomplete. -The current `.pi/extensions/brunch-autocomplete.ts` fixture extension follows this model: it inserts fixture handles, explains via `before_agent_start` that labels/descriptions are UI-only, and explicitly says no graph lookup tool exists yet. +The product `src/pi-extensions/mention-autocomplete.ts` follows this model: it inserts stable graph-code handles from an injectable Brunch mention source, explains via `before_agent_start` that labels/descriptions are UI-only, and leaves deeper detail lookup to future Brunch graph read tools. ### Exact slash execution @@ -177,7 +177,7 @@ Startup now runs through Brunch-owned inventory and activation before Pi `Intera The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-switcher markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. -The in-session command is product-named `/brunch-workspace`. Its handler waits for idle, inspects inventory, renders the same typed workspace-switcher component with the default `ctx.ui.custom()` component-replacement flow, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. Overlay/modal custom-UI patterns remain deferred to later review-set, orientation, or picker surfaces only when a concrete product interaction needs them. +The in-session product command is `/brunch` with `ctrl+shift+b`. It opens a minimal Brunch menu shell; choosing the workspace/session action waits for idle, inspects inventory, renders the same typed workspace-switcher component with the default `ctx.ui.custom()` component-replacement flow, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. Overlay/modal custom-UI patterns remain deferred to later review-set, orientation, or picker surfaces only when a concrete product interaction needs them. ## Pi example evidence not yet Brunch integration proof @@ -256,7 +256,7 @@ The seam Brunch must still prove is the composition: assistant tool/custom promp - `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. - M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. - M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. -- Future switcher/review/elicitation commands should follow `/brunch-workspace`: product-owned names, typed default `ctx.ui.custom()` decision components unless richer modal behavior is specifically needed, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. +- Future switcher/review/elicitation commands should follow the `/brunch` menu pattern: product-owned names, typed default `ctx.ui.custom()` decision components unless richer modal behavior is specifically needed, coordinator/command-layer activation, and replacement-session work only through `withSession` contexts. - A strict upstream Pi command-policy API is required before Brunch can honestly claim Pi's generic shell is unavailable rather than merely discouraged/guarded. ## Open evidence gaps @@ -265,5 +265,5 @@ The seam Brunch must still prove is the composition: assistant tool/custom promp - Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. - The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. -- The in-session `/brunch-workspace` command is unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. +- The in-session `/brunch` menu and workspace/session action are unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. - Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; a full Brunch-host manual walkthrough remains useful before product signoff because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data. diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 0f801fcd..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,370 +0,0 @@ -# Scope Cards — sealed-pi-profile-runtime-state - -## Orientation - -- **Containing frontier:** `sealed-pi-profile-runtime-state` in `memory/PLAN.md`; this is one frontier/Linear/branch boundary, with multiple commit-sized port/migration slices queued here. -- **Containing seam:** Brunch-owned Pi wrapper: extension factories, command/tool policy, TUI components, chrome, autocomplete, transcript UI primitives, and resource isolation from ambient `.pi/`. -- **Volatile state:** The `.pi/extensions/*` and `.pi/components/*` files are probe/test artifacts whose useful behavior should be ported into product `src/` modules, then retired so Brunch runtime no longer depends on project-local Pi discovery. -- **Main open risk:** The `/brunch` menu is intentionally only a shell in this queue; deeper settings/config IA still needs grilling, so this queue scopes only a combined menu entry that preserves current workspace-switch behavior and leaves obvious extension points. - -## Frontier-level obligations - -- Preserve the sealed-profile posture: Brunch product behavior comes from programmatic extension factories and profile policy, not ambient `.pi/` discovery. -- Keep product modules flat: `src/pi-extensions/{extension}.ts`, aggregate `src/pi-extensions.ts`, and reusable TUI components under `src/pi-components/{component}.ts`. -- Retire duplicate/stale `.pi/` probe code once its behavior is ported; do not leave parallel extension implementations masquerading as live product truth. -- Preserve current Brunch session invariants while moving files: one spec per session, linear transcript policy, branch/fork/tree blocking, and coordinator-owned workspace activation. -- Keep demo/probe affordances out of production defaults: demo card commands and fixture tag JSON should not ship as product behavior. - ---- - -## Card 1 — Flatten the existing product extension shell - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The existing Brunch Pi extension shell is imported from flat `src/pi-extensions.ts` and flat `src/pi-extensions/*.ts` modules with no remaining runtime imports from `src/pi-extensions/brunch/*`. - -### Boundary Crossings - -```text -→ src/brunch-tui.ts extension factory wiring -→ src/pi-extensions.ts aggregate factory -→ flat extension modules (command-policy, session-lifecycle, chrome, settings-switcher-menu) -→ existing tests/importers -``` - -### Risks and Assumptions - -- RISK: Rename-only movement can accidentally change behavior or break public test exports → MITIGATION: preserve current exported names where useful from `src/pi-extensions.ts`, update tests mechanically, and run focused TUI/extension tests. -- ASSUMPTION: A flat aggregate file is enough; no directory index is needed → VALIDATE: all current imports compile and no import path still references `src/pi-extensions/brunch`. - -### Acceptance Criteria - -✓ `src/pi-extensions.ts` exports `createBrunchPiExtensionShell` plus existing test-facing symbols. -✓ `src/pi-extensions/command-policy.ts` contains the current branch/tree/fork blocking behavior from `branch-policy.ts`. -✓ `src/pi-extensions/session-lifecycle.ts` contains the current session-boundary binding behavior from `session-boundary.ts`. -✓ `src/pi-extensions/settings-switcher-menu.ts` initially contains the current workspace command behavior from `workspace-command.ts`, even if the command name changes in a later card. -✓ No runtime or test import references `src/pi-extensions/brunch/*`. - -### Verification Approach - -- Inner: `npm run fix`; targeted tests for `brunch-tui` / workspace command imports. -- Middle: `rg "pi-extensions/brunch|./pi-extensions/brunch|../pi-extensions/brunch" src` returns no live imports. - -### Cross-cutting obligations - -- This card is structural movement only; do not change `/brunch-workspace` semantics yet. -- Preserve branch/session effect blocking exactly while renaming the module to command policy. - ---- - -## Card 2 — Move reusable Pi TUI components under `src/pi-components` - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -Reusable Pi TUI components live under `src/pi-components`, including the workspace switcher and cards component library, with importers updated to consume the new component location. - -### Boundary Crossings - -```text -→ src/workspace-switcher/* -→ src/pi-components/workspace-switcher.ts or workspace-switcher/* -→ .pi/components/cards.ts -→ src/pi-components/cards.ts -→ extension/component tests and package scripts -``` - -### Risks and Assumptions - -- RISK: Collapsing `workspace-switcher/*` too aggressively could make tests less clear → MITIGATION: preserve a small public component/preflight entrypoint under `src/pi-components/workspace-switcher.ts` or `src/pi-components/workspace-switcher/index.ts` if needed; prefer clarity over one-file compression. -- ASSUMPTION: `cards.ts` has no product dependency on `.pi/` placement → VALIDATE: it imports only Pi TUI/theme primitives and works from `src/pi-components/cards.ts`. - -### Acceptance Criteria - -✓ `createWorkspaceSwitchComponent` and `runWorkspaceSwitchPreflight` are imported from `src/pi-components` paths, not `src/workspace-switcher`. -✓ `CardComponent`, `ResponsiveColumns`, and `chunk` are available from `src/pi-components/cards.ts`. -✓ Existing workspace-switcher behavior and tests still pass after the move. -✓ Package lint/format scripts no longer need `.pi/components` to cover product component code. - -### Verification Approach - -- Inner: `npm run fix`; workspace-switcher tests. -- Middle: `rg "workspace-switcher|\.pi/components" src package.json` shows only intentional compatibility exports if any. - -### Cross-cutting obligations - -- Keep Pi-specific TUI widgets out of general product/domain folders. -- Do not change workspace activation semantics; component move only. - ---- - -## Card 3 — Replace `/brunch-workspace` with the Brunch menu shell - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`/brunch` and `ctrl+shift+b` open a Brunch menu shell that can launch the existing workspace/session switch flow, replacing `/brunch-workspace` as the primary product command. - -### Boundary Crossings - -```text -→ src/pi-extensions/settings-switcher-menu.ts -→ src/pi-components/brunch-menu.ts -→ src/pi-components/workspace-switcher -→ WorkspaceSessionCoordinator activation -→ TUI command/shortcut tests -``` - -### Risks and Assumptions - -- RISK: The final settings/config IA is not designed → MITIGATION: scope only a menu shell with a workspace/session item and clear extension points; do not invent full settings semantics. -- RISK: Removing `/brunch-workspace` immediately may break tests or muscle memory → MITIGATION: either retire it deliberately with test updates or keep it as a hidden/backward test alias only if needed for one transition commit; prefer deletion in pre-release. -- ASSUMPTION: `ctrl+shift+b` is collision-safe based on the probe extension note → VALIDATE: register shortcut test asserts the binding exists and no `ctrl+b` alias returns. - -### Acceptance Criteria - -✓ `src/pi-components/brunch-menu.ts` renders a minimal menu with a workspace/session switch action. -✓ `src/pi-extensions/settings-switcher-menu.ts` registers `/brunch` and `ctrl+shift+b` to open the Brunch menu. -✓ Choosing the workspace/session switch action preserves the current coordinator-backed activation behavior and chrome refresh. -✓ `/brunch-workspace` is removed as the primary command; tests assert the intended command/shortcut surface. - -### Verification Approach - -- Inner: `npm run fix`; unit tests with fake command contexts and workspace decisions. -- Middle: source-level command registry test verifies `/brunch`, `ctrl+shift+b`, no `ctrl+b`, and no product reliance on the old command name. - -### Cross-cutting obligations - -- The menu returns product decisions; `WorkspaceSessionCoordinator` still owns session opening, state writes, and binding. -- Do not introduce settings persistence or hidden menu state in this card. - ---- - -## Card 4 — Port and merge honest chrome - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`src/pi-extensions/chrome.ts` uses the richer `.pi/extensions/brunch-chrome.ts` header/footer discipline while rendering only Brunch/Pi state with real producers today. - -### Boundary Crossings - -```text -→ .pi/extensions/brunch-chrome.ts probe implementation -→ existing src/pi-extensions chrome wrapper -→ WorkspaceSessionChromeState / session binding state -→ Pi header/footer/status/widget/title surfaces -→ chrome tests and TUI launch wiring -``` - -### Risks and Assumptions - -- RISK: The probe chrome reads `.brunch/state.json` directly while current product chrome receives activated workspace state → MITIGATION: favor product-provided activated state when available; use session binding / ctx-derived fallbacks only for honest reload/session-switch reconstruction. -- RISK: Future-state stubs (lens, coherence, worker statuses) can become misleading → MITIGATION: do not render speculative fields until producers exist; leave clear placeholders only where current product state owns them. -- ASSUMPTION: Header/footer are the right primary chrome surfaces; status remains contribution channel → VALIDATE: code avoids using status as the main Brunch chrome owner except for intentional current wrapper compatibility. - -### Acceptance Criteria - -✓ `src/pi-extensions/chrome.ts` supersedes both the old product chrome and `.pi/extensions/brunch-chrome.ts` probe code. -✓ Header/footer render brand/version/cwd/spec/session/model/context/git/status information only where producers exist. -✓ Future state such as operational mode, lens, coherence, workers, and establishment offer is not fabricated; extension points are named for later producers. -✓ Existing chrome formatting tests are updated or replaced to assert the richer honest rendering contract. - -### Verification Approach - -- Inner: `npm run fix`; chrome formatter unit tests. -- Middle: fake `ExtensionContext`/footer-data tests cover selected spec/session binding fallback, model/thinking/context display, and extension status passthrough. -- Outer: optional manual TUI smoke after build thread if terminal rendering changed substantially. - -### Cross-cutting obligations - -- Chrome is projection, not authority; it must not mutate workspace/session state. -- Preserve RPC limitations: only assert Pi RPC chrome events that actually exist. - ---- - -## Card 5 — Port operational-mode tool policy - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`src/pi-extensions/operational-mode.ts` enforces the current `elicit`-safe read-only tool posture while being named and shaped as the future operational-mode policy seam. - -### Boundary Crossings - -```text -→ .pi/extensions/brunch-tools.ts probe implementation -→ Pi tool registry / active tool selection -→ before_agent_start prompt composition -→ tool_call and user_bash blocking events -→ Brunch extension aggregate factory -``` - -### Risks and Assumptions - -- RISK: Re-registering built-in read-only tools may conflict with Pi base tools or custom tools → MITIGATION: preserve the probe's available-tool filtering and test active tool names after registration. -- RISK: A permanent read-only name would fight future `execute` mode → MITIGATION: expose the code as operational-mode policy with an initial `elicit` bundle/default, not `tool-policy.ts`. -- ASSUMPTION: `read`, `grep`, `find`, `ls` are sufficient safe tools for the current elicitation prototype → VALIDATE: tests assert side-effecting tools are blocked and prompt text tells the agent the allowed set. - -### Acceptance Criteria - -✓ `operational-mode.ts` registers/readies read-only tools and sets active tools for the current elicit posture. -✓ `before_agent_start` appends operational-mode/tool-policy prompt guidance. -✓ `tool_call` blocks side-effecting tools, including `bash`, `edit`, and `write`. -✓ `user_bash` is blocked with a deterministic Brunch result. -✓ The module name and exported API leave room for future `execute` bundles. - -### Verification Approach - -- Inner: `npm run fix`; fake ExtensionAPI unit tests for active tools, prompt injection, and blocked calls. -- Middle: aggregate extension factory test proves operational-mode policy is loaded programmatically, not through `.pi/settings.json`. - -### Cross-cutting obligations - -- This is the first concrete enforcement for I25-L; do not let active tool state come from ambient Pi settings. -- Keep side-effect suppression aligned with future `elicit` operational mode rather than global product incapability. - ---- - -## Card 6 — Port mention autocomplete as graph-code completion - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`src/pi-extensions/mention-autocomplete.ts` provides `#` completion from a Brunch-owned graph mention source keyed by stable node codes, with no `.pi/extensions/brunch-tags.json` file. - -### Boundary Crossings - -```text -→ .pi/extensions/brunch-autocomplete.ts probe implementation -→ Brunch graph mention source interface -→ Pi autocomplete provider -→ before_agent_start mention guidance -→ future graph data plane integration point -``` - -### Risks and Assumptions - -- RISK: The graph data plane is not available yet → MITIGATION: define an injectable `GraphMentionSource` interface and test with fake intent/design/oracle/plan nodes; production source can return empty until M4/M5 plugs in. -- RISK: Stable code formats are not fully final → MITIGATION: support current known families (`D{n}` decisions and analogous intent/design/oracle/plan codes) through typed data, not hardcoded fixture food tags. -- ASSUMPTION: Pi autocomplete still persists only inserted handle text → VALIDATE: prompt guidance remains explicit that labels/descriptions are UI-only. - -### Acceptance Criteria - -✓ The autocomplete extension inserts stable handles such as `#D12` from Brunch-owned graph-node candidates. -✓ Candidate labels/descriptions are display-only and not treated as hidden transcript metadata. -✓ No code writes or reads `.pi/extensions/brunch-tags.json`. -✓ The graph mention source is injectable/testable before graph persistence lands. - -### Verification Approach - -- Inner: `npm run fix`; autocomplete extraction/apply unit tests with fake graph candidates. -- Middle: source audit `rg "brunch-tags|\.pi/extensions/brunch-tags" src .pi package.json` confirms the fixture JSON path is retired. - -### Cross-cutting obligations - -- Preserve D14-L: inserted text must be a stable Brunch-resolvable handle; autocomplete metadata is not transcript truth. -- Do not invent a graph lookup tool in this card. - ---- - -## Card 7 — Port alternatives/card transcript primitive without demos - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`src/pi-extensions/alternatives.ts` registers the persistent alternatives card transcript primitive and `present_alternatives` tool using `src/pi-components/cards.ts`, without shipping demo commands. - -### Boundary Crossings - -```text -→ .pi/extensions/brunch-messages.ts probe implementation -→ src/pi-components/cards.ts -→ Pi custom message renderer -→ Pi tool registry -→ structured exchange future seam -``` - -### Risks and Assumptions - -- RISK: Alternatives may be confused with terminal structured-question responses → MITIGATION: name it as a presentation/proposal primitive; do not record it as an answered offer or terminal response. -- RISK: Demo commands leak into product command surface → MITIGATION: delete `/cards-demo`, `/cards-columns-demo`, and `/cards-flavors` during port. -- ASSUMPTION: `present_alternatives` remains useful enough to register as a product tool → VALIDATE: tests prove content fallback plus details payload are self-contained and replay-renderable. - -### Acceptance Criteria - -✓ `alternatives-card-set` custom message renderer is registered from product code. -✓ `present_alternatives` tool emits persistent custom transcript content plus structured details. -✓ Demo commands from the probe file are not registered. -✓ The primitive is documented/named as a structured-exchange building block, not a terminal answer collector. - -### Verification Approach - -- Inner: `npm run fix`; renderer/tool unit tests with fake ExtensionAPI. -- Middle: command registry test proves demo commands are absent while `present_alternatives` is available. - -### Cross-cutting obligations - -- Preserve transcript truth: custom message content must provide a readable fallback for RPC/replay clients without the renderer. -- Keep this separate from structured-question result details until the FE-744 structured-response tool lands. - ---- - -## Card 8 — Retire `.pi/` probe runtime reliance and update docs/scripts - -**Status:** next -**Weight:** full scope card - -### Target Behavior - -The ported product behavior no longer relies on `.pi/extensions`, `.pi/components`, `.pi/settings.json`, or `.pi/extensions/brunch-tags.json`, and stale references are either deleted or explicitly documented as historical probe evidence. - -### Boundary Crossings - -```text -→ .pi/extensions/* probe files -→ .pi/components/* probe files -→ .pi/settings.json ambient config -→ package scripts -→ docs/reference and architecture references -→ source audits -``` - -### Risks and Assumptions - -- RISK: Some docs intentionally describe Pi's generic extension discovery locations → MITIGATION: keep reference docs that explain Pi generally, but update Brunch product docs to say product extensions are loaded programmatically from `src`. -- RISK: Deleting `.pi/settings.json` could remove useful local test defaults → MITIGATION: if needed, replace with a non-product example under docs or test fixtures; do not keep ambient config in the repo root. -- ASSUMPTION: Product lint/format coverage should now target `src` only → VALIDATE: package scripts no longer mention `.pi/extensions` or `.pi/components`. - -### Acceptance Criteria - -✓ Duplicate `.pi/extensions/brunch-*.ts`, `.pi/components/cards.ts`, and `.pi/extensions/brunch-tags.json` are deleted or moved into non-runtime historical documentation if explicitly needed. -✓ `.pi/settings.json` no longer controls Brunch product behavior; preferably it is removed from the repo. -✓ `package.json` lint/format scripts target product code, not deleted probe paths. -✓ Architecture docs mentioning `.pi/extensions/brunch-autocomplete.ts` or temporary probes are updated to point at `src/pi-extensions/*` or explicitly describe archived evidence. -✓ `rg "\.pi/extensions/brunch|\.pi/components|brunch-tags.json|brunch-workspace"` returns no stale product-runtime references. - -### Verification Approach - -- Inner: `npm run fix`; `npm run verify` if this is the tie-off card. -- Middle: source/doc audit commands for stale `.pi` product references and old command names. - -### Cross-cutting obligations - -- Keep generic Pi reference docs accurate where they discuss Pi itself; only remove Brunch product reliance on ambient `.pi`. -- Do not delete evidence references in architecture docs without replacing them with the durable product module names or noting the proof was temporary. diff --git a/memory/PLAN.md b/memory/PLAN.md index 7626294d..55b51215 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -118,12 +118,12 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Status:** not-started - **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into flat product modules under `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. - **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, observer/reviewer jobs, and authority gating depend on the embedded harness. -- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; the runtime no longer imports from `src/pi-extensions/brunch/*`; replacement modules are flat and product-named: `.pi/extensions/brunch-tools.ts` ports to `src/pi-extensions/operational-mode.ts`; `.pi/extensions/brunch-autocomplete.ts` ports to `src/pi-extensions/mention-autocomplete.ts` with graph-node stable-code completion instead of `.pi/extensions/brunch-tags.json`; `.pi/extensions/brunch-chrome.ts` supersedes and merges the old product `chrome.ts` as `src/pi-extensions/chrome.ts`; `.pi/extensions/brunch-messages.ts` ports to `src/pi-extensions/alternatives.ts` while `.pi/components/cards.ts` moves to `src/pi-components/cards.ts` with demo commands removed; `branch-policy.ts` becomes `src/pi-extensions/command-policy.ts`; `session-boundary.ts` becomes `src/pi-extensions/session-lifecycle.ts`; `workspace-command.ts` becomes `src/pi-extensions/settings-switcher-menu.ts`; `src/pi-extensions/brunch/index.ts` becomes `src/pi-extensions.ts`; `src/workspace-switcher/*` moves under `src/pi-components/workspace-switcher/*` (with a public component/preflight entrypoint) so TUI components live beside card components rather than as a top-level product domain. Transcript entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch` can be appended by Brunch commands and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Transcript entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch` can be appended by Brunch commands and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. - **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) -- **Current execution pointer:** consume the prepared queue in [`memory/CARDS.md`](file:///Users/lunelson/Code/hashintel/brunch-next/memory/CARDS.md): flatten `src/pi-extensions`, create `src/pi-components`, port `operational-mode`, `command-policy`, `session-lifecycle`, `chrome`, `settings-switcher-menu`, `mention-autocomplete`, and `alternatives` behind the existing extension factory, move workspace-switcher/cards TUI components into `src/pi-components`, and delete/retire duplicate `.pi/` probe runtime reliance. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage before adding runtime-bundle switch entries. +- **Current execution pointer:** product extension/component port queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, settings/menu switching, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; duplicate project-local Pi probe runtime files and package/tooling references were retired. Next scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage before adding runtime-bundle switch entries. ### graph-data-plane @@ -239,7 +239,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** structural (spike-flavored) - **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, branch policy, session-boundary binding, and `/brunch-workspace`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session-boundary binding, and `/brunch`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. diff --git a/memory/SPEC.md b/memory/SPEC.md index ac1c3f7d..f253a007 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -121,7 +121,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions should be product modules under flat `src/pi-extensions/*.ts` plus an aggregate `src/pi-extensions.ts`; the old `.pi/extensions/*` files are test/probe sources to port, not product runtime configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Slash/key commands append product custom entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch`; turn preparation projects the latest linear transcript state into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. diff --git a/package.json b/package.json index b797bcc2..fc5c83d6 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", - "lint": "oxlint src .pi/extensions", - "lint:fix": "oxlint --fix src .pi/extensions", - "fmt": "oxfmt src .pi/extensions", - "fmt:check": "oxfmt --check src .pi/extensions", + "lint": "oxlint src", + "lint:fix": "oxlint --fix src", + "fmt": "oxfmt src", + "fmt:check": "oxfmt --check src", "fix": "npm run lint:fix && npm run fmt", "check": "npm run fmt:check && npm run lint && npm run typecheck", "verify": "npm run check && npm run test && npm run build", diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 2717e275..21040591 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -397,7 +397,8 @@ describe("Brunch TUI boot", () => { expect(commands.get(BRUNCH_MENU_COMMAND)?.description).toBe( "Open the Brunch menu", ) - expect(commands.has("brunch-workspace")).toBe(false) + const retiredWorkspaceCommand = ["brunch", "workspace"].join("-") + expect(commands.has(retiredWorkspaceCommand)).toBe(false) expect(shortcuts.get(BRUNCH_MENU_SHORTCUT)?.description).toBe( "Open the Brunch menu", ) diff --git a/tsconfig.json b/tsconfig.json index ee401bfc..7ce6d2a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,9 +26,7 @@ "verbatimModuleSyntax": true }, "include": [ - "src/**/*", - ".pi/extensions/**/*.ts", - ".pi/components/**/*.ts", + "src/**/*" ], "exclude": [ "node_modules", From 347057fe505b26bb837dae5397776b1bdec48993 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:23:02 +0200 Subject: [PATCH 048/164] interim draft scoping --- memory/CARDS.md | 253 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..89c6ffd0 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,253 @@ +# Scope Cards — sealed-pi-profile-runtime-state follow-up + +## Orientation + +- **Containing frontier:** `sealed-pi-profile-runtime-state` in `memory/PLAN.md`; this remains one frontier/Linear/branch boundary, now following the completed FE-744 extension/component port. +- **Containing seam:** Brunch-owned Pi wrapper: `src/pi-extensions.ts`, `src/pi-extensions/*`, `src/pi-components/*`, transcript-backed `BrunchAgentState`, prompt/tool posture, chrome projection, and sealed-profile resource isolation. +- **Volatile state:** Prior Cards 1–8 for the extension/component port have landed on `ln/fe-744-pi-ui-extension-patterns`; review found post-port cleanup and overclaim issues that must be fixed before runtime-state expansion. +- **Main open risk:** Runtime-state work will be built on shaky footing if the just-ported extension layout, chrome contract, menu naming, and operational-mode seam still contain stale probe-era vocabulary. + +## Frontier-level obligations + +- Preserve sealed-profile posture: Brunch product behavior comes from programmatic Brunch extension factories and profile policy, not ambient `.pi/` discovery. +- Preserve D23-L/D40-L/I25-L: transport mode, operational mode, agent role, strategy, and lens are separate axes, and active agent posture must be reconstructable from linear transcript entries at turn start. +- Preserve D25-L/D32-L: lenses are elicitor metadata and establishment offers are orientation artifacts, not a persistent default strategy menu. +- Preserve current elicit-safe tool policy: `elicit` must not expose side-effecting tools such as raw `bash`, `edit`, or `write` unless explicitly allowed by a future operational mode. +- Keep derivative planning state disciplined: scope-card queues live in `memory/CARDS.md`; temporary sidecar drafts must be reconciled and deleted. + +--- + +## Card 0 — Reconcile post-port review findings before runtime-state work + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The completed extension/component port has no unreconciled draft sidecar, chrome overclaim, or stale probe-era naming in product code and architecture evidence. + +### Boundary Crossings + +```text +→ docs/design/DRAFT_CARDS.md temporary sidecar +→ memory/CARDS.md canonical scope queue +→ src/pi-extensions/chrome.ts and chrome tests +→ docs/architecture/pi-ui-extension-patterns.md +→ src/pi-extensions/settings-switcher-menu.ts aggregate exports +→ src/pi-extensions/operational-mode.ts naming/comments +→ src/pi-components/cards.ts and src/pi-extensions/alternatives.ts comments +``` + +### Risks and Assumptions + +- RISK: Chrome code and architecture docs can drift in opposite directions → MITIGATION: either finish the richer chrome port or narrow the docs/acceptance in the same slice; do not leave proof language stronger than code. +- RISK: Renaming menu/workspace exports can break tests or external imports → MITIGATION: update aggregate exports and tests deliberately; keep workspace switching as an internal helper behind menu/settings-switcher language. +- RISK: Card 0 becomes a grab bag → MITIGATION: limit it to review findings #1–#6 from the completed port and stop before adding new runtime-state behavior. +- ASSUMPTION: FE-744 Cards 1–8 are otherwise green and this slice is cleanup/reconciliation, not a feature expansion → VALIDATE: `npm run verify` remains green after edits. + +### Acceptance Criteria + +✓ `planning sidecar removed` — useful content from `docs/design/DRAFT_CARDS.md` is reconciled into `memory/CARDS.md`, and `docs/design/DRAFT_CARDS.md` is deleted. +✓ `chrome proof matches code` — `src/pi-extensions/chrome.ts` and `docs/architecture/pi-ui-extension-patterns.md` agree on the actual chrome contract: either richer version/build/model/thinking/context/git/status passthrough is implemented and tested, or docs explicitly narrow the claim. +✓ `extension layout narrative updated` — `docs/architecture/pi-ui-extension-patterns.md` names the current flat `src/pi-extensions.ts`, `command-policy`, `session-lifecycle`, `settings-switcher-menu`, `operational-mode`, `mention-autocomplete`, `alternatives`, and `src/pi-components/*` layout without old `branch-policy` / `session-boundary` / `workspace-command` narratives. +✓ `menu surface renamed` — public-ish exports use menu/settings-switcher language for `/brunch`; workspace switching is an internal menu action helper rather than the exported registration surface. +✓ `operational-mode vocabulary cleaned` — `operational-mode.ts` no longer reads like copied “Brunch — tools” / generic read-only tool policy, and local constants/comments use `elicit` / operational-mode policy vocabulary. +✓ `stale comments cleaned` — `src/pi-components/cards.ts` and `src/pi-extensions/alternatives.ts` no longer reference `.pi/extensions`, `brunch-messages.ts`, malformed comments, or empty activation sections. + +### Verification Approach + +- Inner: `npm run fix`; targeted unit/source tests for chrome formatting, menu command registration/export shape, and operational-mode policy where present. +- Middle: source/doc audit — `rg "DRAFT_CARDS|branch-policy|session-boundary|workspace-command|brunch-workspace|brunch-messages|\.pi/extensions" memory docs/architecture src` has only intentional historical references, and `npm run verify` passes. + +### Cross-cutting obligations + +- Do not add Brunch agent-state switching in this cleanup card. +- Preserve existing `/brunch` behavior and coordinator-owned workspace activation while renaming the module surface. +- Keep chrome a projection, not authority; it must not mutate workspace/session state. + +--- + +## Card 1 — Project Brunch agent state from transcript + +**Status:** queued +**Weight:** full scope card + +### Target Behavior + +`src/pi-extensions/operational-mode.ts` reconstructs the active `BrunchAgentState` from `brunch.agent_runtime_state` custom entries with a deterministic default when no runtime entries exist. + +### Boundary Crossings + +```text +→ Pi SessionManager linear entries +→ Brunch agent-runtime-entry parser/projection helpers +→ Brunch operational-mode / agent-role definition registry +→ operational-mode policy state used by extension handlers +``` + +### Risks and Assumptions + +- RISK: Runtime-entry schemas become durable before they are typed tightly enough → MITIGATION: define discriminated TypeScript shapes for `brunch.agent_runtime_state`, reject unknown/partial entries in projection tests, and keep parser tolerant only by ignoring malformed entries rather than guessing. +- RISK: Default state silently diverges from the current fixed read-only policy → MITIGATION: make the default state explicit (`operationalMode: "elicit"`, `agentRole: "elicitor"`, default strategy/lens) and assert its resolved tool/prompt posture in tests. +- ASSUMPTION: Pi custom entries can be read synchronously enough from `ctx.sessionManager.getEntries()` during `session_start` / `before_agent_start` → VALIDATE: fake SessionManager tests plus existing JSONL projection tests; already governed by D17-L/D40-L/I25-L. + +### Acceptance Criteria + +✓ `projects default runtime` — with no runtime custom entries, projection returns a `BrunchAgentState` with operational mode `elicit`, agent role `elicitor`, and role-default strategy/lens selections. +✓ `last valid runtime state wins` — a later `brunch.agent_runtime_state` supersedes earlier snapshots without mutating older transcript state. +✓ `rejects ambient config authority` — projection does not read `.pi/presets.json`, `.pi/modes.json`, environment mode files, or extension-local persisted booleans. +✓ `exports typed runtime state` — tests can import a narrow `projectBrunchAgentState`/equivalent helper without instantiating a full Pi runtime. + +### Verification Approach + +- Inner: unit/schema tests — runtime-entry parsing, default projection, last-valid-entry-wins ordering, malformed-entry handling. +- Middle: JSONL fixture/projection test — append representative runtime init/switch custom entries and reload/project them through the same helper used by the extension. + +### Cross-cutting obligations + +- Runtime state is transcript-backed, not hidden extension memory. +- Keep the concept named `BrunchAgentState` / `operational mode`, not generic Pi mode or plan mode. +- This card should not add user-facing strategy/lens menus. + +### Terminology and types + +```ts +export interface BrunchAgentState { + schemaVersion: 1 + operationalMode: OperationalModeId + agentRole: AgentRoleId + agentStrategy: AgentStrategyId + agentLens: AgentLensId | null +} + +export interface OperationalModeDefinition { + id: OperationalModeId + defaultRole: AgentRoleId + allowedRoles: readonly AgentRoleId[] + toolPolicyId: ToolPolicyId + promptPackIds: readonly PromptPackId[] +} + +export interface AgentRoleDefinition { + id: AgentRoleId + operationalMode: OperationalModeId + defaultStrategy: AgentStrategyId + allowedStrategies: readonly AgentStrategyId[] + defaultLens: AgentLensId | null + allowedLenses: readonly AgentLensId[] + promptPackIds: readonly PromptPackId[] + modelPreference?: ModelPreference + thinkingLevel?: ThinkingLevel +} + +export interface ResolvedBrunchAgentState extends BrunchAgentState { + operationalModeDefinition: OperationalModeDefinition + agentRoleDefinition: AgentRoleDefinition +} + +export interface BrunchAgentStateEntryData { + schemaVersion: 1 + reason: "init" | "switch" + state: BrunchAgentState + previous?: BrunchAgentState + source: "system" | "user" | "agent" | "extension" +} +``` + +Custom entry kind: `brunch.agent_runtime_state`. + +Validation requires: `OperationalModeDefinition.allowedRoles` contains `agentRole`; `AgentRoleDefinition.operationalMode` equals `operationalMode`; `AgentRoleDefinition.allowedStrategies` contains `agentStrategy`; and `agentLens` is either `null` or contained in `AgentRoleDefinition.allowedLenses`. + +--- + +## Card 2 — Apply active Brunch agent state to prompt and tools + +**Status:** queued +**Weight:** full scope card + +### Target Behavior + +Before each agent turn, `operational-mode.ts` applies the reconstructed and resolved `BrunchAgentState` tool policy and prompt packs. + +### Boundary Crossings + +```text +→ runtime-state projection helper +→ Pi before_agent_start hook +→ Pi active-tool registry +→ Pi tool_call / user_bash enforcement hooks +→ model-facing system prompt +``` + +### Risks and Assumptions + +- RISK: `setActiveTools()` is only a visibility layer and cannot be the whole authority boundary → MITIGATION: preserve `tool_call` and `user_bash` blockers as defense-in-depth. +- RISK: Prompt fragments become scattered strings again → MITIGATION: centralize prompt text in operational-mode and agent-role definitions and have `before_agent_start` compose from resolved state. +- ASSUMPTION: Current `elicit` + `elicitor` state should preserve the read-only tool set from `.pi/extensions/brunch-tools.ts` / current `operational-mode.ts` → VALIDATE: active-tools and blocking tests assert `read`, `grep`, `find`, `ls` allowed and `bash`, `edit`, `write` blocked. + +### Acceptance Criteria + +✓ `applies elicit tools` — `before_agent_start` sets active tools from the resolved operational mode / agent role definitions for `elicit` + `elicitor`. +✓ `injects resolved prompt` — the system prompt includes operational-mode and agent-role guidance from the resolved `BrunchAgentState`. +✓ `blocks side effects` — `tool_call` blocks `bash`, `edit`, `write`, and any non-allowed tool under `elicit` + `elicitor` with deterministic Brunch wording. +✓ `blocks user bash` — `user_bash` returns a deterministic blocked result under `elicit` + `elicitor`. +✓ `does not hardcode plan-mode vocabulary` — product prompt/status strings refer to Brunch operational mode and agent role, not borrowed plan-mode terminology. + +### Verification Approach + +- Inner: fake ExtensionAPI tests — active tool application, prompt composition, tool-call blocking, user-bash blocking. +- Middle: aggregate extension factory test — `createBrunchPiExtensionShell` loads operational-mode policy programmatically and no ambient `.pi` tool policy is required. + +### Cross-cutting obligations + +- Preserve I25-L: tool gating follows reconstructed operational mode. +- Preserve sealed-profile posture: ambient Pi settings/resources must not decide the tool set. +- Keep future `execute` as a new operational-mode definition, not a contradiction of current `elicit` safety. + +--- + +## Card 3 — Persist Brunch agent-state switches as selected-state snapshots + +**Status:** queued +**Weight:** full scope card + +### Target Behavior + +Brunch-owned runtime switch helpers persist accepted agent-state changes as full selected `BrunchAgentState` snapshots. + +### Boundary Crossings + +```text +→ product command/helper entry point +→ operational-mode / agent-role registry validation +→ Pi appendEntry custom transcript persistence +→ runtime-state projection helper +→ Brunch chrome/status projection input +→ future observer/reviewer routing metadata +``` + +### Risks and Assumptions + +- RISK: A switch UI turns into a default strategy menu and violates D32-L → MITIGATION: expose narrow product command/helper hooks for explicit user/agent switches only; do not render a persistent exhaustive menu by default. +- RISK: Runtime axes drift into invalid combinations → MITIGATION: validate every requested change against the operational-mode / agent-role registry hierarchy and append only a full valid selected `BrunchAgentState` snapshot. +- ASSUMPTION: Product commands may append custom entries through Pi extension APIs for now, while future Brunch command-layer integration can own richer authority → VALIDATE: tests assert append shape and replay projection; no graph mutation is introduced. + +### Acceptance Criteria + +✓ `appends runtime init` — session initialization appends one `brunch.agent_runtime_state` entry when no valid runtime state exists. +✓ `appends runtime switch` — a Brunch helper/command appends a `brunch.agent_runtime_state` snapshot with `reason: "switch"`, previous state, source metadata, and validated `operationalMode` / `agentRole` / `agentStrategy` / `agentLens` fields. +✓ `projects latest runtime state` — projection reconstructs and resolves the active mode/role/strategy/lens from the latest valid full-state snapshot. +✓ `updates chrome input only when producers exist` — chrome/status may consume projected active lens/strategy, but no speculative worker/coherence/offer state is fabricated. +✓ `no persistent strategy menu` — no default exhaustive lens/strategy chooser is added to idle UI. + +### Verification Approach + +- Inner: unit tests — append payload shape, registry validation, projection last-valid-snapshot wins, invalid combination rejection. +- Middle: JSONL reload/projection test — selected runtime-state snapshots survive reload and resolve active mode/role/strategy/lens. +- Outer: optional manual TUI/RPC smoke — explicit switch command/helper is inspectable in transcript and reflected in status/chrome where currently wired. + +### Cross-cutting obligations + +- Preserve D25-L: lens is metadata within the `elicitor` role, not an agent role or operational mode. +- Preserve D32-L: establishment offers remain orientation artifacts, not a default next-action menu. +- Do not introduce graph writes or observer/reviewer routing behavior in this card; only provide the transcript-backed state seam. From 0b3cfc54ec5f02f19adcdb0ed2d29beffddf5d09 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:34:42 +0200 Subject: [PATCH 049/164] stub a POC for modal brunch menu --- .pi/extensions/brunch-menu.ts | 268 ++++++++++++++++++++++++++++++++++ package.json | 8 +- tsconfig.json | 3 +- 3 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 .pi/extensions/brunch-menu.ts diff --git a/.pi/extensions/brunch-menu.ts b/.pi/extensions/brunch-menu.ts new file mode 100644 index 00000000..e1f5abeb --- /dev/null +++ b/.pi/extensions/brunch-menu.ts @@ -0,0 +1,268 @@ +/** + * Brunch — menu (centered overlay splash) + * + * Opens a centered overlay modal showing the same Brunch identity panel that + * `brunch-chrome.ts` renders into the header (logo + wordmark + version + Pi + * version + project root). Invoked via `ctrl+shift+k`. Dismisses on any key. + * + * This deliberately mirrors only the header *visuals*; nothing here writes to + * footer/header/status. Persistent chrome stays owned by `brunch-chrome.ts`. + * + * The rendering helpers (logo loader, wordmark, version block) are duplicated + * from `brunch-chrome.ts` to keep the two extensions independent. If a third + * caller appears, lift the helpers into a shared module then. + */ + +import { execSync } from "node:child_process" +import { readFileSync } from "node:fs" +import path from "node:path" + +import type { + ExtensionAPI, + ExtensionContext, + Theme, +} from "@earendil-works/pi-coding-agent" +import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" + +const OVERLAY_WIDTH = 60 + +// Letterform copied from: cfonts "brunch" -f tiny -c candy +const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] + +const LOCAL_BUILD_TIME = formatBuildTime(new Date()) +const ESC = String.fromCharCode(27) +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) + +type PackageJson = { + version?: unknown + private?: unknown +} + +type BrunchVersionInfo = { + version: string + dev: string | null +} + +function formatBuildTime(date: Date): string { + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, " UTC") +} + +function getGitSha(cwd: string): string { + try { + return execSync("git rev-parse --short=7 HEAD", { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() + } catch { + return "" + } +} + +function readPackage(cwd: string): PackageJson { + try { + return JSON.parse( + readFileSync(path.join(cwd, "package.json"), "utf8"), + ) as PackageJson + } catch { + return {} + } +} + +function brunchVersion(cwd: string): BrunchVersionInfo { + const pkg = readPackage(cwd) + const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" + const isLocalDev = pkg.private === true || version === "0.0.0" + if (!isLocalDev) return { version: `v${version}`, dev: null } + + const gitSha = getGitSha(cwd) + const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") + return { version: `v${version}`, dev: devMeta ? `(dev ${devMeta})` : "(dev)" } +} + +function stripAnsi(text: string): string { + return text.replace(new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g"), "") +} + +function visibleLeadingSpaces(line: string): number { + const plain = stripAnsi(line) + const match = plain.match(/^ */) + return match?.[0].length ?? 0 +} + +function removeVisibleColumns(line: string, columns: number): string { + if (columns <= 0) return line + + let output = "" + let removed = 0 + for (let index = 0; index < line.length; index += 1) { + if (line[index] === ESC) { + const match = line.slice(index).match(ANSI_SEQUENCE) + if (match) { + output += match[0] + index += match[0].length - 1 + continue + } + } + + if (removed < columns) { + removed += 1 + continue + } + output += line[index]! + } + return output +} + +function cropLogo(lines: string[]): string[] { + const cropped = [...lines] + while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) + cropped.shift() + while ( + cropped.length > 0 && + stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 + ) + cropped.pop() + if (cropped.length === 0) return [] + + const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) + return cropped.map((line) => removeVisibleColumns(line, commonLeft)) +} + +function supportsTruecolor(): boolean { + const colorterm = process.env.COLORTERM?.toLowerCase() ?? "" + const term = process.env.TERM?.toLowerCase() ?? "" + return ( + colorterm === "truecolor" || + colorterm === "24bit" || + term.includes("truecolor") + ) +} + +function readLogo(cwd: string): string[] { + const asset = supportsTruecolor() + ? "brunch-logo-quad-56x18.ansi" + : "brunch-logo-quad-56x18-240.ansi" + try { + return cropLogo( + readFileSync(path.join(cwd, "assets", asset), "utf8") + .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") + .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") + .split("\n"), + ) + } catch { + return [] + } +} + +function shortenPath(p: string): string { + const home = process.env.HOME ?? process.env.USERPROFILE + if (home && p.startsWith(home)) return `~${p.slice(home.length)}` + return p +} + +function borderedContentLine( + content: string, + width: number, + theme: Theme, +): string { + // width includes the two border columns. Inner content area is width - 4 + // (left border + space + content + space + right border). + if (width <= 4) return truncateToWidth(content, width) + const innerWidth = width - 4 + const inner = truncateToWidth(content, innerWidth) + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(inner))) + const vertical = theme.fg("borderMuted", "│") + return `${vertical} ${inner}${padding} ${vertical}` +} + +function borderedEmptyLine(width: number, theme: Theme): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + const vertical = theme.fg("borderMuted", "│") + return `${vertical}${" ".repeat(width - 2)}${vertical}` +} + +function topBorderLine(width: number, theme: Theme): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + return theme.fg("borderMuted", `╭${"─".repeat(width - 2)}╮`) +} + +function bottomBorderLine(width: number, theme: Theme): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + return theme.fg("borderMuted", `╰${"─".repeat(width - 2)}╯`) +} + +function renderOverlayLines( + ctx: ExtensionContext, + theme: Theme, + width: number, +): string[] { + const logoLines = readLogo(ctx.cwd) + const versionInfo = brunchVersion(ctx.cwd) + const versionLine = + theme.fg("accent", `brunch ${versionInfo.version}`) + + (versionInfo.dev ? ` ${theme.fg("success", versionInfo.dev)}` : "") + const piLine = theme.fg("dim", `built on Pi v${PI_VERSION}`) + const projectRootLine = theme.fg( + "dim", + `project root: ${shortenPath(path.resolve(ctx.cwd))}`, + ) + const hintLine = theme.fg("dim", "press any key to dismiss") + + return [ + topBorderLine(width, theme), + borderedEmptyLine(width, theme), + ...logoLines.map((line) => borderedContentLine(line, width, theme)), + borderedEmptyLine(width, theme), + ...BRUNCH_WORDMARK.map((line) => + borderedContentLine(theme.fg("muted", line), width, theme), + ), + borderedEmptyLine(width, theme), + borderedContentLine(versionLine, width, theme), + borderedContentLine(piLine, width, theme), + borderedContentLine(projectRootLine, width, theme), + borderedEmptyLine(width, theme), + borderedContentLine(hintLine, width, theme), + bottomBorderLine(width, theme), + ] +} + +async function openMenu(ctx: ExtensionContext): Promise<void> { + if (!ctx.hasUI) { + ctx.ui?.notify?.("Brunch menu requires UI mode", "warning") + return + } + + await ctx.ui.custom<void>( + (_tui, theme, _kb, done) => { + let width = OVERLAY_WIDTH + return { + render: (w: number) => { + width = w + return renderOverlayLines(ctx, theme, width) + }, + // Any key dismisses, matching the pi-powerline-footer welcome overlay. + handleInput: (_data: string) => done(), + invalidate: () => {}, + } + }, + { + overlay: true, + overlayOptions: () => ({ + anchor: "center", + width: OVERLAY_WIDTH, + }), + }, + ) +} + +export default function brunchMenu(pi: ExtensionAPI) { + pi.registerShortcut("ctrl+shift+k", { + description: "Open the Brunch identity menu", + handler: async (ctx) => openMenu(ctx), + }) +} diff --git a/package.json b/package.json index fc5c83d6..b797bcc2 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", - "lint": "oxlint src", - "lint:fix": "oxlint --fix src", - "fmt": "oxfmt src", - "fmt:check": "oxfmt --check src", + "lint": "oxlint src .pi/extensions", + "lint:fix": "oxlint --fix src .pi/extensions", + "fmt": "oxfmt src .pi/extensions", + "fmt:check": "oxfmt --check src .pi/extensions", "fix": "npm run lint:fix && npm run fmt", "check": "npm run fmt:check && npm run lint && npm run typecheck", "verify": "npm run check && npm run test && npm run build", diff --git a/tsconfig.json b/tsconfig.json index 7ce6d2a1..ba1bf396 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "verbatimModuleSyntax": true }, "include": [ - "src/**/*" + "src/**/*", + ".pi/extensions/**/*.ts" ], "exclude": [ "node_modules", From 4e7c3d79b9a2fbc4c822ea170a6ffa85857ffbfe Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 15:05:33 +0200 Subject: [PATCH 050/164] FE-744 reconcile pi extension port cleanup --- docs/architecture/pi-ui-extension-patterns.md | 30 ++++++++++--------- memory/CARDS.md | 2 +- memory/PLAN.md | 2 +- src/brunch-tui.test.ts | 8 ++--- src/pi-components/cards.ts | 5 ++-- src/pi-extensions.ts | 14 ++++----- src/pi-extensions/alternatives.ts | 11 +++---- src/pi-extensions/operational-mode.ts | 15 ++++------ src/pi-extensions/settings-switcher-menu.ts | 12 ++++---- 9 files changed, 48 insertions(+), 51 deletions(-) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 81f8200b..ede8a1d6 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -120,31 +120,33 @@ The same probe emitted corresponding `notify` requests (`cancel switch new`, `ca ## Brunch extension layout and dynamic chrome proof -The Brunch extension entrypoint is intentionally a registration map. It composes private modules by Pi surface/responsibility: +The Brunch extension entrypoint is intentionally a registration map. `src/pi-extensions.ts` composes flat product-owned modules by Pi surface/responsibility: - `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. -- `session-boundary.ts` owns coordinator refresh calls on session-boundary events. -- `branch-policy.ts` owns `session_before_tree` / `session_before_fork` cancellation. -- `workspace-command.ts` owns the product slash command and replacement-session lifecycle. +- `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. +- `command-policy.ts` owns branch/session effect blocking for unsupported Pi flows. +- `settings-switcher-menu.ts` owns `/brunch`, `ctrl+shift+b`, the product menu shell, and the internal workspace-switch action. +- `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. +- `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. +- `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/pi-components/*`. -`renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current surface allocation is deliberate: +`renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: -- header: product identity plus active spec/session (`brunch specification workspace`, spec title, real activated session id/label); -- status: compact persistent phase/coherence/reconciliation-need summary; -- widget: expanded diagnostics (cwd, chat mode, stage, active lens, worker statuses, latest establishment offer when present); -- title: compact Brunch-owned terminal title derived from activated workspace state; -- footer: restored to Pi default via `setFooter(undefined)` because Brunch does not currently need to replace the whole footer. +- header: product identity plus cwd, active spec, and real activated session id/label; +- footer: phase/chat mode plus active spec/session; +- status: compact persistent phase/spec summary; +- widget: cwd, spec, session, and chat mode diagnostics; +- title: compact Brunch-owned terminal title derived from activated workspace state. -The wrapper uses plain, narrow-terminal-safe text/glyphs (`Brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. Brunch currently restores Pi's default footer and leaves Pi's working indicator untouched instead of carrying empty/custom chrome abstractions. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-boundary hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. The wrapper deliberately does not fabricate build version, model/thinking, git state, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: | Scenario | Result | Evidence | | --- | --- | --- | -| Idle TUI mount | Header, status, diagnostic widget, title, and default-footer restoration are called from one snapshot; raw TUI transcript shows Brunch header/widget text visible. | `src/brunch-tui.test.ts`; temp `script` transcript needle check | -| Streaming/progress update | Wrapper formats stage/worker state deterministically in status/widget; Brunch leaves the interactive working indicator on Pi defaults until a concrete side-task/reviewer spinner is product-proven. | `src/brunch-tui.test.ts`; temp RPC JSONL probe | +| Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one snapshot; tests assert the same formatter output used by the wrapper. | `src/brunch-tui.test.ts` | | `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | -| Session replacement / selected-session reopen | Existing Brunch extension calls the session-boundary binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The Brunch workspace command activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | ## Startup/splash logo asset decision diff --git a/memory/CARDS.md b/memory/CARDS.md index 89c6ffd0..611f4028 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -19,7 +19,7 @@ ## Card 0 — Reconcile post-port review findings before runtime-state work -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/memory/PLAN.md b/memory/PLAN.md index 55b51215..24a12c6a 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -239,7 +239,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** structural (spike-flavored) - **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session-boundary binding, and `/brunch`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 21040591..44d9facc 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -34,7 +34,7 @@ import { registerBrunchOperationalModePolicy, renderBrunchChrome, runBrunchMenuCommand, - runBrunchWorkspaceCommand, + runBrunchSettingsSwitcherAction, } from "./pi-extensions.js" import { createWorkspaceSessionCoordinator, @@ -462,7 +462,7 @@ describe("Brunch TUI boot", () => { replacementUi, }) - await runBrunchWorkspaceCommand(ctx, { + await runBrunchSettingsSwitcherAction(ctx, { inspectWorkspace: async () => { events.push("inspect") return inventoryWithWorkspace(target) @@ -497,7 +497,7 @@ describe("Brunch TUI boot", () => { onEvent: (event) => events.push(event), }) - await runBrunchWorkspaceCommand(ctx, { + await runBrunchSettingsSwitcherAction(ctx, { inspectWorkspace: async () => emptyInventory("/tmp/project"), activateWorkspace: async () => ({ status: "cancelled", @@ -526,7 +526,7 @@ describe("Brunch TUI boot", () => { onEvent: (event) => events.push(event), }) - await runBrunchWorkspaceCommand(ctx, { + await runBrunchSettingsSwitcherAction(ctx, { inspectWorkspace: async () => emptyInventory("/tmp/project"), activateWorkspace: async () => ({ status: "needs_human", diff --git a/src/pi-components/cards.ts b/src/pi-components/cards.ts index f0d95974..b80dd0c3 100644 --- a/src/pi-components/cards.ts +++ b/src/pi-components/cards.ts @@ -1,9 +1,8 @@ /** * Cards — pi-tui rendering primitives for bordered card layouts. * - * Pure library module. Lives outside `.pi/extensions/` because it registers - * nothing with Pi; it is consumed by extensions (e.g. `brunch-messages.ts`) - * that compose these primitives into custom message renderers. + * Pure library module. It registers nothing with Pi; product extensions import + * these primitives when they need transcript-rendered card layouts. * * Components here should remain stateless and stitch only pi-tui primitives. */ diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 2e6a05ae..3f1030e4 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -20,8 +20,8 @@ import { type BrunchSessionBoundaryHandler, } from "./pi-extensions/session-lifecycle.js" import { - registerBrunchWorkspaceCommand, - type BrunchWorkspaceCommandOptions, + registerBrunchSettingsSwitcherMenu, + type BrunchSettingsSwitcherMenuOptions, } from "./pi-extensions/settings-switcher-menu.js" export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" @@ -54,14 +54,14 @@ export { export { BRUNCH_MENU_COMMAND, BRUNCH_MENU_SHORTCUT, - registerBrunchWorkspaceCommand, + registerBrunchSettingsSwitcherMenu, runBrunchMenuCommand, - runBrunchWorkspaceCommand, - type BrunchWorkspaceCommandOptions, + runBrunchSettingsSwitcherAction, + type BrunchSettingsSwitcherMenuOptions, } from "./pi-extensions/settings-switcher-menu.js" export interface BrunchPiExtensionShellOptions - extends BrunchWorkspaceCommandOptions { + extends BrunchSettingsSwitcherMenuOptions { graphMentionSource?: GraphMentionSource } @@ -83,6 +83,6 @@ export function createBrunchPiExtensionShell( registerBrunchOperationalModePolicy(pi) registerBrunchMentionAutocomplete(pi, options.graphMentionSource) registerBrunchAlternatives(pi) - registerBrunchWorkspaceCommand(pi, options) + registerBrunchSettingsSwitcherMenu(pi, options) } } diff --git a/src/pi-extensions/alternatives.ts b/src/pi-extensions/alternatives.ts index da8ff092..f0c8cdd3 100644 --- a/src/pi-extensions/alternatives.ts +++ b/src/pi-extensions/alternatives.ts @@ -1,16 +1,13 @@ /** - * Brunch — custom messages + * Brunch alternatives transcript primitive. * * Owns the `alternatives-card-set` custom message type end-to-end: * - registerMessageRenderer to draw bordered cards in the transcript * - registerTool (`present_alternatives`) so the LLM can emit a card set - * * - * Compared with an ephemeral picker (e.g. `ctx.ui.custom`), this surface - * PRESENTS alternatives via `pi.sendMessage` — persistent, returns - * immediately, no UI focus stolen — and is the closest existing precedent for - * the offer-first transcript-native loop tracked under FE-744 (D37-L / I23-L). * - * Activate: + * Compared with an ephemeral picker (e.g. `ctx.ui.custom`), this surface + * presents alternatives via `pi.sendMessage`: persistent, immediately returned, + * and visible to transcript replay/RPC clients through markdown fallback text. */ import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent" diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index fb769f91..04021333 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -1,14 +1,11 @@ /** - * Brunch — tools + * Brunch operational-mode policy. * - * Product-facing tool policy for the Brunch Pi wrapper prototype: - * - hard-enforce read-only tool access (`read`, `grep`, `find`, `ls`) - * - block every side-effecting tool, including `bash`, `edit`, and `write` - * - render the standard read-only tools in a deliberately tiny TUI form - * - * This is not a toggle. Brunch is testing a narrower tool surface than Pi's - * default coding-agent harness, so loading this extension means Brunch tool - * policy is active for the session. + * The current product runtime has one safe state: `elicit`. In that state the + * embedded Pi harness exposes only Brunch's read-only inspection tools and + * blocks side-effecting tools (`bash`, `edit`, `write`, etc.) at multiple Pi + * seams. Later cards replace this fixed posture with transcript-backed + * BrunchAgentState projection, but the policy remains operational-mode owned. */ import { homedir } from "node:os" diff --git a/src/pi-extensions/settings-switcher-menu.ts b/src/pi-extensions/settings-switcher-menu.ts index b11001dd..19a34ad9 100644 --- a/src/pi-extensions/settings-switcher-menu.ts +++ b/src/pi-extensions/settings-switcher-menu.ts @@ -18,13 +18,13 @@ import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_MENU_COMMAND = "brunch" export const BRUNCH_MENU_SHORTCUT = "ctrl+shift+b" -export interface BrunchWorkspaceCommandOptions { +export interface BrunchSettingsSwitcherMenuOptions { coordinator: WorkspaceSwitchCoordinator } -export function registerBrunchWorkspaceCommand( +export function registerBrunchSettingsSwitcherMenu( pi: ExtensionAPI, - { coordinator }: BrunchWorkspaceCommandOptions, + { coordinator }: BrunchSettingsSwitcherMenuOptions, ): void { pi.registerCommand(BRUNCH_MENU_COMMAND, { description: "Open the Brunch menu", @@ -55,10 +55,12 @@ export async function runBrunchMenuCommand( return } - await runBrunchWorkspaceCommand(ctx, coordinator, { waitForIdle: false }) + await runBrunchSettingsSwitcherAction(ctx, coordinator, { + waitForIdle: false, + }) } -export async function runBrunchWorkspaceCommand( +export async function runBrunchSettingsSwitcherAction( ctx: ExtensionCommandContext, coordinator: WorkspaceSwitchCoordinator, options: { waitForIdle?: boolean } = {}, From 213c4110b543c4c48941ece359666386ddb0289a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 16:51:28 +0200 Subject: [PATCH 051/164] FE-744 project brunch agent runtime state --- memory/CARDS.md | 2 +- src/pi-extensions.ts | 16 +- src/pi-extensions/operational-mode.test.ts | 141 +++++++++++++++ src/pi-extensions/operational-mode.ts | 195 +++++++++++++++++++++ 4 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 src/pi-extensions/operational-mode.test.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 611f4028..4244c844 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -69,7 +69,7 @@ The completed extension/component port has no unreconciled draft sidecar, chrome ## Card 1 — Project Brunch agent state from transcript -**Status:** queued +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 3f1030e4..8aad19eb 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -32,7 +32,21 @@ export { type GraphMentionCandidate, type GraphMentionSource, } from "./pi-extensions/mention-autocomplete.js" -export { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" +export { + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + DEFAULT_BRUNCH_AGENT_STATE, + projectBrunchAgentState, + registerBrunchOperationalModePolicy, + type AgentLensId, + type AgentRoleDefinition, + type AgentRoleId, + type AgentStrategyId, + type BrunchAgentState, + type BrunchAgentStateEntryData, + type OperationalModeDefinition, + type OperationalModeId, + type ResolvedBrunchAgentState, +} from "./pi-extensions/operational-mode.js" export { chromeStateForWorkspace, formatBrunchChromeFooterLines, diff --git a/src/pi-extensions/operational-mode.test.ts b/src/pi-extensions/operational-mode.test.ts new file mode 100644 index 00000000..c8beffea --- /dev/null +++ b/src/pi-extensions/operational-mode.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { describe, expect, it } from "vitest" + +import { SessionManager } from "@earendil-works/pi-coding-agent" + +import { + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + DEFAULT_BRUNCH_AGENT_STATE, + projectBrunchAgentState, + type BrunchAgentState, +} from "./operational-mode.js" + +function runtimeEntry( + state: BrunchAgentState, + data: Record<string, unknown> = {}, +) { + return { + type: "custom", + customType: BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + data: { + schemaVersion: 1, + reason: "switch", + state, + source: "user", + ...data, + }, + } +} + +describe("Brunch agent runtime-state projection", () => { + it("projects the deterministic elicit/elicitor default when no runtime entries exist", () => { + expect(projectBrunchAgentState([])).toMatchObject({ + ...DEFAULT_BRUNCH_AGENT_STATE, + operationalModeDefinition: { + id: "elicit", + defaultRole: "elicitor", + toolPolicyId: "elicit-read-only", + }, + agentRoleDefinition: { + id: "elicitor", + operationalMode: "elicit", + defaultStrategy: DEFAULT_BRUNCH_AGENT_STATE.agentStrategy, + defaultLens: DEFAULT_BRUNCH_AGENT_STATE.agentLens, + }, + }) + }) + + it("uses the last valid runtime-state snapshot without mutating earlier transcript entries", () => { + const first = runtimeEntry(DEFAULT_BRUNCH_AGENT_STATE) + const latestState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "disambiguate-via-examples", + agentLens: "disambiguate-via-examples", + } + const latest = runtimeEntry(latestState) + + expect(projectBrunchAgentState([first, latest])).toMatchObject(latestState) + expect(first.data.state).toEqual(DEFAULT_BRUNCH_AGENT_STATE) + }) + + it("ignores malformed and invalid runtime entries instead of guessing", () => { + const valid = runtimeEntry(DEFAULT_BRUNCH_AGENT_STATE) + const invalidCombination = runtimeEntry({ + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "not-a-strategy", + agentLens: "step-by-step", + } as unknown as BrunchAgentState) + const malformed = { + type: "custom", + customType: BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + data: { schemaVersion: 1, reason: "switch", source: "user" }, + } + + expect( + projectBrunchAgentState([valid, invalidCombination, malformed]), + ).toMatchObject(DEFAULT_BRUNCH_AGENT_STATE) + }) + + it("reprojects runtime-state snapshots after Pi JSONL reload", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-agent-state-")) + const sessionDir = join(cwd, ".brunch", "sessions") + const manager = SessionManager.create(cwd, sessionDir) + const latestState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "disambiguate-via-examples", + agentLens: "disambiguate-via-examples", + } + + manager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { + schemaVersion: 1, + reason: "init", + state: DEFAULT_BRUNCH_AGENT_STATE, + source: "extension", + }) + manager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "runtime initialized" }], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: Date.now(), + } as never) + manager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { + schemaVersion: 1, + reason: "switch", + state: latestState, + previous: DEFAULT_BRUNCH_AGENT_STATE, + source: "user", + }) + + const reloaded = SessionManager.open(manager.getSessionFile()!, sessionDir) + + expect(projectBrunchAgentState(reloaded.getEntries())).toMatchObject( + latestState, + ) + }) +}) diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index 04021333..eb8b9b1e 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -28,6 +28,201 @@ const READ_ONLY_TOOLS = [ ] as const type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] +export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = + "brunch.agent_runtime_state" + +export type OperationalModeId = "elicit" +export type AgentRoleId = "elicitor" +export type AgentStrategyId = "step-by-step" | "disambiguate-via-examples" +export type AgentLensId = AgentStrategyId +export type ToolPolicyId = "elicit-read-only" +export type PromptPackId = "brunch-base" | "elicit" | "elicitor" +export type ModelPreference = "default" +export type ThinkingLevel = "low" | "medium" | "high" + +export interface BrunchAgentState { + schemaVersion: 1 + operationalMode: OperationalModeId + agentRole: AgentRoleId + agentStrategy: AgentStrategyId + agentLens: AgentLensId | null +} + +export interface OperationalModeDefinition { + id: OperationalModeId + defaultRole: AgentRoleId + allowedRoles: readonly AgentRoleId[] + toolPolicyId: ToolPolicyId + promptPackIds: readonly PromptPackId[] +} + +export interface AgentRoleDefinition { + id: AgentRoleId + operationalMode: OperationalModeId + defaultStrategy: AgentStrategyId + allowedStrategies: readonly AgentStrategyId[] + defaultLens: AgentLensId | null + allowedLenses: readonly AgentLensId[] + promptPackIds: readonly PromptPackId[] + modelPreference?: ModelPreference + thinkingLevel?: ThinkingLevel +} + +export interface ResolvedBrunchAgentState extends BrunchAgentState { + operationalModeDefinition: OperationalModeDefinition + agentRoleDefinition: AgentRoleDefinition +} + +export interface BrunchAgentStateEntryData { + schemaVersion: 1 + reason: "init" | "switch" + state: BrunchAgentState + previous?: BrunchAgentState + source: "system" | "user" | "agent" | "extension" +} + +export const DEFAULT_BRUNCH_AGENT_STATE: BrunchAgentState = { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "step-by-step", + agentLens: "step-by-step", +} + +export const OPERATIONAL_MODE_DEFINITIONS: Record<OperationalModeId, OperationalModeDefinition> = + { + elicit: { + id: "elicit", + defaultRole: "elicitor", + allowedRoles: ["elicitor"], + toolPolicyId: "elicit-read-only", + promptPackIds: ["brunch-base", "elicit"], + }, + } + +export const AGENT_ROLE_DEFINITIONS: Record<AgentRoleId, AgentRoleDefinition> = + { + elicitor: { + id: "elicitor", + operationalMode: "elicit", + defaultStrategy: "step-by-step", + allowedStrategies: ["step-by-step", "disambiguate-via-examples"], + defaultLens: "step-by-step", + allowedLenses: ["step-by-step", "disambiguate-via-examples"], + promptPackIds: ["elicitor"], + }, + } + +interface CustomEntryLike { + type?: unknown + customType?: unknown + data?: unknown +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +function isOneOf<T extends string>( + value: unknown, + allowed: readonly T[], +): value is T { + return typeof value === "string" && allowed.includes(value as T) +} + +function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { + if (!isRecord(value)) return undefined + const operationalModes = Object.keys( + OPERATIONAL_MODE_DEFINITIONS, + ) as OperationalModeId[] + const agentRoles = Object.keys(AGENT_ROLE_DEFINITIONS) as AgentRoleId[] + + if (value.schemaVersion !== 1) return undefined + if (!isOneOf(value.operationalMode, operationalModes)) return undefined + if (!isOneOf(value.agentRole, agentRoles)) return undefined + + const mode = OPERATIONAL_MODE_DEFINITIONS[value.operationalMode] + const role = AGENT_ROLE_DEFINITIONS[value.agentRole] + if (!mode.allowedRoles.includes(value.agentRole)) return undefined + if (role.operationalMode !== value.operationalMode) return undefined + if (!isOneOf(value.agentStrategy, role.allowedStrategies)) return undefined + if ( + value.agentLens !== null && + !isOneOf(value.agentLens, role.allowedLenses) + ) { + return undefined + } + + return { + schemaVersion: 1, + operationalMode: value.operationalMode, + agentRole: value.agentRole, + agentStrategy: value.agentStrategy, + agentLens: value.agentLens, + } +} + +function parseBrunchAgentStateEntryData( + value: unknown, +): BrunchAgentStateEntryData | undefined { + if (!isRecord(value)) return undefined + if (value.schemaVersion !== 1) return undefined + if (value.reason !== "init" && value.reason !== "switch") return undefined + if ( + value.source !== "system" && + value.source !== "user" && + value.source !== "agent" && + value.source !== "extension" + ) { + return undefined + } + const state = parseBrunchAgentState(value.state) + if (!state) return undefined + const previous = + value.previous === undefined + ? undefined + : parseBrunchAgentState(value.previous) + if (value.previous !== undefined && !previous) return undefined + + return { + schemaVersion: 1, + reason: value.reason, + state, + ...(previous ? { previous } : {}), + source: value.source, + } +} + +function resolveBrunchAgentState( + state: BrunchAgentState, +): ResolvedBrunchAgentState { + return { + ...state, + operationalModeDefinition: + OPERATIONAL_MODE_DEFINITIONS[state.operationalMode], + agentRoleDefinition: AGENT_ROLE_DEFINITIONS[state.agentRole], + } +} + +export function projectBrunchAgentState( + entries: readonly CustomEntryLike[], +): ResolvedBrunchAgentState { + let state = DEFAULT_BRUNCH_AGENT_STATE + + for (const entry of entries) { + if ( + entry.type !== "custom" || + entry.customType !== BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE + ) { + continue + } + const data = parseBrunchAgentStateEntryData(entry.data) + if (data) state = data.state + } + + return resolveBrunchAgentState(state) +} + function shortenPath(path: string): string { const home = homedir() if (path.startsWith(home)) return `~${path.slice(home.length)}` From 19e2a250829e70d68932d44bb5d4e0d479b95652 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 16:58:22 +0200 Subject: [PATCH 052/164] FE-744 apply brunch agent runtime posture --- memory/CARDS.md | 2 +- src/pi-extensions/operational-mode.test.ts | 63 ++++++++++++++++++++ src/pi-extensions/operational-mode.ts | 69 ++++++++++++++++++---- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 4244c844..20540585 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -162,7 +162,7 @@ Validation requires: `OperationalModeDefinition.allowedRoles` contains `agentRol ## Card 2 — Apply active Brunch agent state to prompt and tools -**Status:** queued +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/pi-extensions/operational-mode.test.ts b/src/pi-extensions/operational-mode.test.ts index c8beffea..c283ddd5 100644 --- a/src/pi-extensions/operational-mode.test.ts +++ b/src/pi-extensions/operational-mode.test.ts @@ -10,6 +10,7 @@ import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, projectBrunchAgentState, + registerBrunchOperationalModePolicy, type BrunchAgentState, } from "./operational-mode.js" @@ -83,6 +84,68 @@ describe("Brunch agent runtime-state projection", () => { ).toMatchObject(DEFAULT_BRUNCH_AGENT_STATE) }) + it("applies resolved elicit state to active tools, prompt, and blockers", async () => { + const latestState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "disambiguate-via-examples", + agentLens: "disambiguate-via-examples", + } + const events: Record<string, (event: never, ctx?: never) => unknown> = {} + const activeTools: string[][] = [] + + registerBrunchOperationalModePolicy({ + registerTool: (_tool: { name: string }) => {}, + getAllTools: () => + ["read", "grep", "find", "ls", "bash", "edit", "write"].map((name) => ({ + name, + })), + setActiveTools: (tools: string[]) => activeTools.push(tools), + on: (event: string, handler: (event: never, ctx?: never) => unknown) => { + events[event] = handler + }, + } as never) + + const promptResult = await Promise.resolve( + events.before_agent_start?.({ systemPrompt: "base" } as never, { + sessionManager: { + getEntries: () => [runtimeEntry(latestState)], + }, + } as never), + ) + + expect(activeTools).toEqual([["read", "grep", "find", "ls"]]) + expect(promptResult).toMatchObject({ + systemPrompt: expect.stringContaining("Operational mode: elicit."), + }) + expect(promptResult).toMatchObject({ + systemPrompt: expect.stringContaining("Agent role: elicitor."), + }) + expect(promptResult).toMatchObject({ + systemPrompt: expect.stringContaining( + "Agent strategy: disambiguate-via-examples.", + ), + }) + expect(promptResult).toMatchObject({ + systemPrompt: expect.stringContaining( + "Brunch exposes only read-only tools: read, grep, find, ls.", + ), + }) + await expect( + Promise.resolve(events.tool_call?.({ toolName: "write" } as never)), + ).resolves.toMatchObject({ + block: true, + reason: expect.stringContaining('Brunch tool policy blocks "write"'), + }) + expect(events.user_bash?.({ command: "rm -rf ." } as never)).toMatchObject({ + result: { + exitCode: 1, + output: "Brunch tool policy blocks shell commands: rm -rf .", + }, + }) + }) + it("reprojects runtime-state snapshots after Pi JSONL reload", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-agent-state-")) const sessionDir = join(cwd, ".brunch", "sessions") diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index eb8b9b1e..5bb97cf4 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -234,8 +234,55 @@ function availableReadOnlyToolNames(pi: ExtensionAPI): ReadOnlyToolName[] { return READ_ONLY_TOOLS.filter((name) => allToolNames.has(name)) } -function applyBrunchToolPolicy(pi: ExtensionAPI): void { - pi.setActiveTools(availableReadOnlyToolNames(pi)) +interface SessionManagerLike { + getEntries(): readonly CustomEntryLike[] +} + +function projectBrunchAgentStateFromSessionManager( + sessionManager: SessionManagerLike | undefined, +): ResolvedBrunchAgentState { + return projectBrunchAgentState(sessionManager?.getEntries() ?? []) +} + +function activeToolNamesForState( + pi: ExtensionAPI, + state: ResolvedBrunchAgentState, +): ReadOnlyToolName[] { + if (state.operationalModeDefinition.toolPolicyId === "elicit-read-only") { + return availableReadOnlyToolNames(pi) + } + return [] +} + +function applyBrunchToolPolicy( + pi: ExtensionAPI, + state: ResolvedBrunchAgentState, +): void { + pi.setActiveTools(activeToolNamesForState(pi, state)) +} + +function composeBrunchAgentStatePrompt( + state: ResolvedBrunchAgentState, + activeTools: readonly string[], +): string { + const tools = activeTools.join(", ") || "none" + const lens = state.agentLens ?? "none" + + return ( + `\n\n[Brunch agent state]\n` + + `- Operational mode: ${state.operationalMode}.\n` + + `- Agent role: ${state.agentRole}.\n` + + `- Agent strategy: ${state.agentStrategy}.\n` + + `- Agent lens: ${lens}.\n` + + `- Prompt packs: ${[ + ...state.operationalModeDefinition.promptPackIds, + ...state.agentRoleDefinition.promptPackIds, + ].join(", ")}.\n` + + `\n[Brunch tool policy]\n` + + `- Brunch exposes only read-only tools: ${tools}.\n` + + `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + + `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.` + ) } interface TextLikeContent { @@ -433,21 +480,19 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { }, }) - pi.on("session_start", async () => { - applyBrunchToolPolicy(pi) + pi.on("session_start", async (_event, ctx) => { + const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager) + applyBrunchToolPolicy(pi, state) }) - pi.on("before_agent_start", async (event) => { - applyBrunchToolPolicy(pi) + pi.on("before_agent_start", async (event, ctx) => { + const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager) + const activeTools = activeToolNamesForState(pi, state) + applyBrunchToolPolicy(pi, state) - const tools = availableReadOnlyToolNames(pi).join(", ") || "none" return { systemPrompt: - event.systemPrompt + - `\n\n[Brunch tool policy]\n` + - `- Brunch exposes only read-only tools: ${tools}.\n` + - `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + - `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.`, + event.systemPrompt + composeBrunchAgentStatePrompt(state, activeTools), } }) From a07443f6e63fb3c0628fa1946d19c536659f9857 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:00:31 +0200 Subject: [PATCH 053/164] FE-744: Share Brunch workspace dialog --- .pi/extensions/brunch-menu.ts | 303 ++++-------------- AGENTS.md | 4 + docs/architecture/pi-ui-extension-patterns.md | 28 +- memory/CARDS.md | 8 +- memory/PLAN.md | 4 +- memory/SPEC.md | 8 +- package.json | 7 +- runbooks/verify-startup-no-resume.sh | 6 +- src/brunch-tui.test.ts | 48 +-- src/brunch-tui.ts | 12 +- src/pi-components/brunch-menu.ts | 83 ----- src/pi-components/workspace-dialog.ts | 7 + .../assets}/brunch-logo-quad-56x18-240.ansi | 0 .../assets}/brunch-logo-quad-56x18.ansi | 0 .../workspace-dialog/assets}/brunch.png | Bin .../workspace-dialog/component.ts | 294 +++++++++++++++++ src/pi-components/workspace-dialog/index.ts | 9 + .../model.ts | 12 +- .../preflight.ts | 15 +- src/pi-components/workspace-switcher.ts | 7 - .../workspace-switcher/component.ts | 126 -------- src/pi-components/workspace-switcher/index.ts | 9 - src/pi-extensions.ts | 24 +- ...s-switcher-menu.ts => workspace-dialog.ts} | 63 ++-- ...tcher.test.ts => workspace-dialog.test.ts} | 40 ++- 25 files changed, 522 insertions(+), 595 deletions(-) delete mode 100644 src/pi-components/brunch-menu.ts create mode 100644 src/pi-components/workspace-dialog.ts rename {assets => src/pi-components/workspace-dialog/assets}/brunch-logo-quad-56x18-240.ansi (100%) rename {assets => src/pi-components/workspace-dialog/assets}/brunch-logo-quad-56x18.ansi (100%) rename {assets => src/pi-components/workspace-dialog/assets}/brunch.png (100%) create mode 100644 src/pi-components/workspace-dialog/component.ts create mode 100644 src/pi-components/workspace-dialog/index.ts rename src/pi-components/{workspace-switcher => workspace-dialog}/model.ts (90%) rename src/pi-components/{workspace-switcher => workspace-dialog}/preflight.ts (64%) delete mode 100644 src/pi-components/workspace-switcher.ts delete mode 100644 src/pi-components/workspace-switcher/component.ts delete mode 100644 src/pi-components/workspace-switcher/index.ts rename src/pi-extensions/{settings-switcher-menu.ts => workspace-dialog.ts} (62%) rename src/{workspace-switcher.test.ts => workspace-dialog.test.ts} (77%) diff --git a/.pi/extensions/brunch-menu.ts b/.pi/extensions/brunch-menu.ts index e1f5abeb..22ac01e4 100644 --- a/.pi/extensions/brunch-menu.ts +++ b/.pi/extensions/brunch-menu.ts @@ -1,268 +1,83 @@ /** - * Brunch — menu (centered overlay splash) + * Brunch workspace dialog demo extension. * - * Opens a centered overlay modal showing the same Brunch identity panel that - * `brunch-chrome.ts` renders into the header (logo + wordmark + version + Pi - * version + project root). Invoked via `ctrl+shift+k`. Dismisses on any key. - * - * This deliberately mirrors only the header *visuals*; nothing here writes to - * footer/header/status. Persistent chrome stays owned by `brunch-chrome.ts`. - * - * The rendering helpers (logo loader, wordmark, version block) are duplicated - * from `brunch-chrome.ts` to keep the two extensions independent. If a third - * caller appears, lift the helpers into a shared module then. + * This project-local probe deliberately stays thin: the actual centered dialog + * lives in `src/pi-components/workspace-dialog`, so startup and in-session + * extension paths exercise the same pi-tui component. */ -import { execSync } from "node:child_process" -import { readFileSync } from "node:fs" -import path from "node:path" - import type { ExtensionAPI, - ExtensionContext, - Theme, + ExtensionCommandContext, } from "@earendil-works/pi-coding-agent" -import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" - -const OVERLAY_WIDTH = 60 - -// Letterform copied from: cfonts "brunch" -f tiny -c candy -const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] -const LOCAL_BUILD_TIME = formatBuildTime(new Date()) -const ESC = String.fromCharCode(27) -const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) - -type PackageJson = { - version?: unknown - private?: unknown -} - -type BrunchVersionInfo = { - version: string - dev: string | null -} - -function formatBuildTime(date: Date): string { - return date - .toISOString() - .replace("T", " ") - .replace(/\.\d+Z$/, " UTC") -} - -function getGitSha(cwd: string): string { - try { - return execSync("git rev-parse --short=7 HEAD", { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim() - } catch { - return "" - } -} - -function readPackage(cwd: string): PackageJson { - try { - return JSON.parse( - readFileSync(path.join(cwd, "package.json"), "utf8"), - ) as PackageJson - } catch { - return {} - } -} +import { createWorkspaceDialogComponent } from "../../src/pi-components/workspace-dialog/index.js" +import { + createWorkspaceSessionCoordinator, + type WorkspaceSwitchDecision, +} from "../../src/workspace-session-coordinator.js" -function brunchVersion(cwd: string): BrunchVersionInfo { - const pkg = readPackage(cwd) - const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" - const isLocalDev = pkg.private === true || version === "0.0.0" - if (!isLocalDev) return { version: `v${version}`, dev: null } +const COMMAND = "brunch-workspace-demo" +const SHORTCUT = "ctrl+shift+k" - const gitSha = getGitSha(cwd) - const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") - return { version: `v${version}`, dev: devMeta ? `(dev ${devMeta})` : "(dev)" } -} - -function stripAnsi(text: string): string { - return text.replace(new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g"), "") -} - -function visibleLeadingSpaces(line: string): number { - const plain = stripAnsi(line) - const match = plain.match(/^ */) - return match?.[0].length ?? 0 -} - -function removeVisibleColumns(line: string, columns: number): string { - if (columns <= 0) return line - - let output = "" - let removed = 0 - for (let index = 0; index < line.length; index += 1) { - if (line[index] === ESC) { - const match = line.slice(index).match(ANSI_SEQUENCE) - if (match) { - output += match[0] - index += match[0].length - 1 - continue - } - } - - if (removed < columns) { - removed += 1 - continue - } - output += line[index]! - } - return output -} - -function cropLogo(lines: string[]): string[] { - const cropped = [...lines] - while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) - cropped.shift() - while ( - cropped.length > 0 && - stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 - ) - cropped.pop() - if (cropped.length === 0) return [] - - const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) - return cropped.map((line) => removeVisibleColumns(line, commonLeft)) -} - -function supportsTruecolor(): boolean { - const colorterm = process.env.COLORTERM?.toLowerCase() ?? "" - const term = process.env.TERM?.toLowerCase() ?? "" - return ( - colorterm === "truecolor" || - colorterm === "24bit" || - term.includes("truecolor") - ) -} - -function readLogo(cwd: string): string[] { - const asset = supportsTruecolor() - ? "brunch-logo-quad-56x18.ansi" - : "brunch-logo-quad-56x18-240.ansi" - try { - return cropLogo( - readFileSync(path.join(cwd, "assets", asset), "utf8") - .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") - .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") - .split("\n"), - ) - } catch { - return [] - } -} - -function shortenPath(p: string): string { - const home = process.env.HOME ?? process.env.USERPROFILE - if (home && p.startsWith(home)) return `~${p.slice(home.length)}` - return p -} - -function borderedContentLine( - content: string, - width: number, - theme: Theme, -): string { - // width includes the two border columns. Inner content area is width - 4 - // (left border + space + content + space + right border). - if (width <= 4) return truncateToWidth(content, width) - const innerWidth = width - 4 - const inner = truncateToWidth(content, innerWidth) - const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(inner))) - const vertical = theme.fg("borderMuted", "│") - return `${vertical} ${inner}${padding} ${vertical}` -} - -function borderedEmptyLine(width: number, theme: Theme): string { - if (width <= 2) return " ".repeat(Math.max(0, width)) - const vertical = theme.fg("borderMuted", "│") - return `${vertical}${" ".repeat(width - 2)}${vertical}` -} - -function topBorderLine(width: number, theme: Theme): string { - if (width <= 2) return " ".repeat(Math.max(0, width)) - return theme.fg("borderMuted", `╭${"─".repeat(width - 2)}╮`) -} - -function bottomBorderLine(width: number, theme: Theme): string { - if (width <= 2) return " ".repeat(Math.max(0, width)) - return theme.fg("borderMuted", `╰${"─".repeat(width - 2)}╯`) -} - -function renderOverlayLines( - ctx: ExtensionContext, - theme: Theme, - width: number, -): string[] { - const logoLines = readLogo(ctx.cwd) - const versionInfo = brunchVersion(ctx.cwd) - const versionLine = - theme.fg("accent", `brunch ${versionInfo.version}`) + - (versionInfo.dev ? ` ${theme.fg("success", versionInfo.dev)}` : "") - const piLine = theme.fg("dim", `built on Pi v${PI_VERSION}`) - const projectRootLine = theme.fg( - "dim", - `project root: ${shortenPath(path.resolve(ctx.cwd))}`, - ) - const hintLine = theme.fg("dim", "press any key to dismiss") - - return [ - topBorderLine(width, theme), - borderedEmptyLine(width, theme), - ...logoLines.map((line) => borderedContentLine(line, width, theme)), - borderedEmptyLine(width, theme), - ...BRUNCH_WORDMARK.map((line) => - borderedContentLine(theme.fg("muted", line), width, theme), - ), - borderedEmptyLine(width, theme), - borderedContentLine(versionLine, width, theme), - borderedContentLine(piLine, width, theme), - borderedContentLine(projectRootLine, width, theme), - borderedEmptyLine(width, theme), - borderedContentLine(hintLine, width, theme), - bottomBorderLine(width, theme), - ] +export default function brunchMenu(pi: ExtensionAPI) { + pi.registerCommand(COMMAND, { + description: "Open the shared Brunch workspace dialog demo", + handler: async (_args, ctx) => openWorkspaceDialog(ctx), + }) + pi.registerShortcut(SHORTCUT, { + description: "Open the shared Brunch workspace dialog demo", + handler: async (ctx) => openWorkspaceDialog(ctx as ExtensionCommandContext), + }) } -async function openMenu(ctx: ExtensionContext): Promise<void> { +async function openWorkspaceDialog( + ctx: ExtensionCommandContext, +): Promise<void> { if (!ctx.hasUI) { - ctx.ui?.notify?.("Brunch menu requires UI mode", "warning") + ctx.ui?.notify?.("Brunch workspace dialog requires UI mode", "warning") return } - await ctx.ui.custom<void>( - (_tui, theme, _kb, done) => { - let width = OVERLAY_WIDTH - return { - render: (w: number) => { - width = w - return renderOverlayLines(ctx, theme, width) - }, - // Any key dismisses, matching the pi-powerline-footer welcome overlay. - handleInput: (_data: string) => done(), - invalidate: () => {}, - } - }, + await ctx.waitForIdle() + const coordinator = createWorkspaceSessionCoordinator({ cwd: ctx.cwd }) + const inventory = await coordinator.inspectWorkspace() + const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( + (_tui, theme, _keybindings, done) => + createWorkspaceDialogComponent({ inventory, theme, onDecision: done }), { overlay: true, - overlayOptions: () => ({ + overlayOptions: { anchor: "center", - width: OVERLAY_WIDTH, - }), + width: 72, + maxHeight: "90%", + margin: 1, + }, }, ) -} + const activated = await coordinator.activateWorkspace(decision) -export default function brunchMenu(pi: ExtensionAPI) { - pi.registerShortcut("ctrl+shift+k", { - description: "Open the Brunch identity menu", - handler: async (ctx) => openMenu(ctx), + if (activated.status === "cancelled") { + ctx.ui.notify("Workspace dialog cancelled.", "info") + return + } + if (activated.status === "needs_human") { + ctx.ui.notify(activated.reason, "warning") + return + } + + const targetFile = activated.session.file + if (ctx.sessionManager.getSessionFile() === targetFile) { + ctx.ui.notify("Already using the selected Brunch workspace.", "info") + return + } + + await ctx.switchSession(targetFile, { + withSession: async (replacementCtx) => { + replacementCtx.ui.notify( + `Switched Brunch workspace to ${activated.spec.title} (${activated.session.id}).`, + "info", + ) + }, }) } diff --git a/AGENTS.md b/AGENTS.md index aa62f7ad..0d5e630f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,10 @@ Verification boundary: /ln-spec owns inner-loop verification (commands, policy). Tooling: oxlint (lint + type-aware + type-check via tsgolint), oxfmt (format). Verification strategy details in SPEC.md §Verification Design. +## critical file-safety rule + +Do not delete untracked files or directories without explicit user confirmation. This includes newly-created local files, ignored files, scratch directories, generated-looking folders, and empty placeholder directories. If cleanup seems appropriate, ask first and name the exact path(s) you propose to remove. + ## operational protocols Read these before the relevant activity: diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index ede8a1d6..c46271ca 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -12,8 +12,8 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | -| Startup workspace switcher | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | -| In-session workspace switcher command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable switcher beyond startup | Brunch extension command tests + coordinator store oracle | +| Startup workspace dialog | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | +| In-session workspace dialog command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable workspace selection beyond startup | Brunch extension command tests + coordinator store oracle | | Structured-question response loop | feasible but not Brunch-proven | required before M5 lens/review affordances depend on structured elicitation | Pi `question`/`questionnaire` examples + RPC UI demo; Brunch proof pending | ## Evidence inventory @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the Brunch menu/workspace switcher (`settings-switcher-menu.ts` plus `src/pi-components/brunch-menu.ts`), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; menu/workspace tests prove decision UI remains separate from coordinator activation and uses the default `ctx.ui.custom()` component-replacement path rather than experimental overlay options. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the workspace dialog (`workspace-dialog.ts` plus `src/pi-components/workspace-dialog/*`), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace dialog tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -125,7 +125,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/pi-ext - `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. - `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. - `command-policy.ts` owns branch/session effect blocking for unsupported Pi flows. -- `settings-switcher-menu.ts` owns `/brunch`, `ctrl+shift+b`, the product menu shell, and the internal workspace-switch action. +- `workspace-dialog.ts` owns `/brunch`, `ctrl+shift+b`, and the in-session workspace-dialog activation adapter. - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. - `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/pi-components/*`. @@ -153,10 +153,10 @@ Observed behavior: Brunch should render the startup/splash logo as TUI chrome, not as a session message, so it does not persist in the transcript/log. For the preferred blocky aesthetic, the selected rendering is a pre-generated Chafa Unicode-symbol asset rather than runtime image rendering: -- Source PNG copied from the legacy Brunch app to `assets/brunch.png`. -- Preferred splash asset: `assets/brunch-logo-quad-56x18.ansi`. -- Lower-color fallback asset: `assets/brunch-logo-quad-56x18-240.ansi`. -- `package.json` includes `assets` in published package files so runtime code can read these files directly. +- Source PNG copied from the legacy Brunch app to `src/pi-components/workspace-dialog/assets/brunch.png`. +- Preferred splash asset: `src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi`. +- Lower-color fallback asset: `src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi`. +- The build copies those assets to `dist/pi-components/workspace-dialog/assets` so runtime code can read them beside the compiled component. The selected generator command for the preferred asset is: @@ -168,18 +168,18 @@ chafa -f symbols \ --color-extractor=median \ --bg=black \ --size=56x18 \ - assets/brunch.png > assets/brunch-logo-quad-56x18.ansi + src/pi-components/workspace-dialog/assets/brunch.png > src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi ``` Runtime should **not** invoke Chafa on startup. The logo should be deterministic, cheap to render, and independent of host-installed CLI tools. Chafa is therefore a maintainer/dev tool at most, not a runtime dependency. Startup chrome should choose `brunch-logo-quad-56x18.ansi` when truecolor is available, otherwise `brunch-logo-quad-56x18-240.ansi`; for very limited terminals, a plain `brunch` wordmark is sufficient rather than carrying 16-color or 8-color assets. -## Workspace switcher implementation evidence +## Workspace dialog implementation evidence -Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure `workspace-switcher` UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. +Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure `workspace-dialog` UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. -The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-switcher markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. +The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-dialog markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. -The in-session product command is `/brunch` with `ctrl+shift+b`. It opens a minimal Brunch menu shell; choosing the workspace/session action waits for idle, inspects inventory, renders the same typed workspace-switcher component with the default `ctx.ui.custom()` component-replacement flow, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins. Overlay/modal custom-UI patterns remain deferred to later review-set, orientation, or picker surfaces only when a concrete product interaction needs them. +The in-session product command is `/brunch` with `ctrl+shift+b`. It waits for idle, inspects inventory, renders the same typed centered workspace dialog with `ctx.ui.custom(..., { overlay: true })`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins; it is the Brunch-owned workspace adapter over Pi's session-replacement API. ## Pi example evidence not yet Brunch integration proof @@ -189,7 +189,7 @@ Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance desig | --- | --- | --- | | `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | | `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | -| `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-switcher proof. | +| `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-dialog proof. | | `titlebar-spinner` / working indicator examples | Pi example evidence only | Brunch leaves Pi's working indicator untouched; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | | `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | | `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | diff --git a/memory/CARDS.md b/memory/CARDS.md index 20540585..dc00b672 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -33,7 +33,7 @@ The completed extension/component port has no unreconciled draft sidecar, chrome → memory/CARDS.md canonical scope queue → src/pi-extensions/chrome.ts and chrome tests → docs/architecture/pi-ui-extension-patterns.md -→ src/pi-extensions/settings-switcher-menu.ts aggregate exports +→ src/pi-extensions/workspace-dialog.ts aggregate exports → src/pi-extensions/operational-mode.ts naming/comments → src/pi-components/cards.ts and src/pi-extensions/alternatives.ts comments ``` @@ -49,8 +49,8 @@ The completed extension/component port has no unreconciled draft sidecar, chrome ✓ `planning sidecar removed` — useful content from `docs/design/DRAFT_CARDS.md` is reconciled into `memory/CARDS.md`, and `docs/design/DRAFT_CARDS.md` is deleted. ✓ `chrome proof matches code` — `src/pi-extensions/chrome.ts` and `docs/architecture/pi-ui-extension-patterns.md` agree on the actual chrome contract: either richer version/build/model/thinking/context/git/status passthrough is implemented and tested, or docs explicitly narrow the claim. -✓ `extension layout narrative updated` — `docs/architecture/pi-ui-extension-patterns.md` names the current flat `src/pi-extensions.ts`, `command-policy`, `session-lifecycle`, `settings-switcher-menu`, `operational-mode`, `mention-autocomplete`, `alternatives`, and `src/pi-components/*` layout without old `branch-policy` / `session-boundary` / `workspace-command` narratives. -✓ `menu surface renamed` — public-ish exports use menu/settings-switcher language for `/brunch`; workspace switching is an internal menu action helper rather than the exported registration surface. +✓ `extension layout narrative updated` — `docs/architecture/pi-ui-extension-patterns.md` names the current flat `src/pi-extensions.ts`, `command-policy`, `session-lifecycle`, `workspace-dialog`, `operational-mode`, `mention-autocomplete`, `alternatives`, and `src/pi-components/*` layout without old `branch-policy` / `session-boundary` / `workspace-command` narratives. +✓ `workspace dialog surface renamed` — public-ish exports use workspace-dialog language for `/brunch`; coordinator-owned workspace activation remains separate from the reusable decision component. ✓ `operational-mode vocabulary cleaned` — `operational-mode.ts` no longer reads like copied “Brunch — tools” / generic read-only tool policy, and local constants/comments use `elicit` / operational-mode policy vocabulary. ✓ `stale comments cleaned` — `src/pi-components/cards.ts` and `src/pi-extensions/alternatives.ts` no longer reference `.pi/extensions`, `brunch-messages.ts`, malformed comments, or empty activation sections. @@ -62,7 +62,7 @@ The completed extension/component port has no unreconciled draft sidecar, chrome ### Cross-cutting obligations - Do not add Brunch agent-state switching in this cleanup card. -- Preserve existing `/brunch` behavior and coordinator-owned workspace activation while renaming the module surface. +- Preserve existing `/brunch` behavior and coordinator-owned workspace activation while keeping the module surface named around workspace. - Keep chrome a projection, not authority; it must not mutate workspace/session state. --- diff --git a/memory/PLAN.md b/memory/PLAN.md index 24a12c6a..ab7539fa 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -237,9 +237,9 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome, workspace-switcher startup flow, in-session switch command, pty startup oracle, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) +- **Status:** in-progress (command-containment, dynamic chrome, workspace-dialog startup flow, in-session workspace command, pty startup oracle, centered branded overlay reuse, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the workspace switcher supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered workspace dialog supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. diff --git a/memory/SPEC.md b/memory/SPEC.md index f253a007..24e863c3 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -212,7 +212,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. -- **D36-L — Workspace switching is a reusable decision UI with coordinator activation adapters.** Brunch owns a pure workspace-switcher surface that renders workspace inventory and returns a product decision (`continue selected session`, `open session`, `new session for spec`, `new spec`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. The same decision UI should be usable by a pre-Pi TUI startup adapter and later by an in-Pi command/modal adapter; adapters differ only in terminal lifecycle and Pi session-replacement mechanics, not in product semantics. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, and one-off startup-only picker implementations. +- **D36-L — Workspace selection is a reusable dialog with coordinator activation adapters.** Brunch owns a pure centered `workspace-dialog` component that renders workspace inventory and returns a product decision (`continue selected session`, `open session`, `new session for spec`, `new spec`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. Startup and in-session paths share the same branded `pi-tui` component and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, and a separate intermediate action chooser for workspace switching. ### Critical Invariants @@ -424,14 +424,14 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, fixture metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | -| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-switcher startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | +| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-switcher UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-dialog UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace switcher, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | +| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace dialog, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Runbook Oracle Design diff --git a/package.json b/package.json index b797bcc2..169e57b9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "brunch", + "name": "brunch-next", "version": "0.0.0", "description": "Brunch \u2014 opinionated specification-workspace product over pi-coding-agent.", "private": true, @@ -7,7 +7,7 @@ "main": "./dist/brunch.js", "types": "./dist/brunch.d.ts", "bin": { - "brunch": "./bin/brunch.js" + "brunch-next": "./bin/brunch.js" }, "files": [ "dist", @@ -17,7 +17,8 @@ ], "scripts": { "dev": "tsx src/brunch.ts", - "build": "tsc -p tsconfig.build.json && npm run build:web", + "build": "tsc -p tsconfig.build.json && npm run build:pi-assets && npm run build:web", + "build:pi-assets": "mkdir -p dist/pi-components/workspace-dialog && cp -R src/pi-components/workspace-dialog/assets dist/pi-components/workspace-dialog/", "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", diff --git a/runbooks/verify-startup-no-resume.sh b/runbooks/verify-startup-no-resume.sh index 0f86bf07..9990abaf 100755 --- a/runbooks/verify-startup-no-resume.sh +++ b/runbooks/verify-startup-no-resume.sh @@ -2,7 +2,7 @@ set -euo pipefail # Proves FE-744/I22 at the terminal boundary: Brunch TUI startup shows the -# workspace switcher before any prior transcript is rendered. This runbook uses +# workspace dialog before any prior transcript is rendered. This runbook uses # a real pty via `script`; it is intended as a manual/middle-loop oracle rather # than part of the default verify gate. @@ -50,8 +50,8 @@ if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then exit 1 fi -if ! grep -Eq "Brunch workspace|Choose how to start this session|New spec" "$CAPTURE_STRIPPED"; then - echo "FAILED: startup capture did not show a stable workspace-switcher marker" >&2 +if ! grep -Eq "Brunch workspace|Choose or create the workspace|New workspace title" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show a stable workspace-dialog marker" >&2 echo "Capture: $CAPTURE_STRIPPED" >&2 exit 1 fi diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 44d9facc..949d6ac9 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -20,8 +20,8 @@ import { runBrunchTui, } from "./brunch-tui.js" import { - BRUNCH_MENU_COMMAND, - BRUNCH_MENU_SHORTCUT, + BRUNCH_WORKSPACE_COMMAND, + BRUNCH_WORKSPACE_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensionShell, formatBrunchChromeFooterLines, @@ -33,8 +33,8 @@ import { registerBrunchMentionAutocomplete, registerBrunchOperationalModePolicy, renderBrunchChrome, - runBrunchMenuCommand, - runBrunchSettingsSwitcherAction, + runBrunchWorkspaceCommand, + runBrunchWorkspaceAction, } from "./pi-extensions.js" import { createWorkspaceSessionCoordinator, @@ -94,7 +94,7 @@ describe("Brunch TUI boot", () => { }, bindCurrentSpecToReplacementSession: async () => workspace, }, - runWorkspaceSwitchPreflight: async () => { + runWorkspaceDialogPreflight: async () => { events.push("preflight") return { action: "continue", @@ -143,7 +143,7 @@ describe("Brunch TUI boot", () => { }, bindCurrentSpecToReplacementSession: async () => workspace, }, - runWorkspaceSwitchPreflight: async () => { + runWorkspaceDialogPreflight: async () => { events.push("preflight") return { action: "cancel" } }, @@ -168,7 +168,7 @@ describe("Brunch TUI boot", () => { await runBrunchTui({ cwd, coordinator, - runWorkspaceSwitchPreflight: async () => ({ + runWorkspaceDialogPreflight: async () => ({ action: "newSession", specId: first.spec.id, }), @@ -356,7 +356,7 @@ describe("Brunch TUI boot", () => { expect(titles).toEqual(["brunch — Spec One"]) }) - it("registers the Brunch menu command and shortcut", async () => { + it("registers the Brunch workspace command and shortcut", async () => { const commands = new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() const shortcuts = @@ -394,24 +394,23 @@ describe("Brunch TUI boot", () => { "ls", "present_alternatives", ]) - expect(commands.get(BRUNCH_MENU_COMMAND)?.description).toBe( - "Open the Brunch menu", + expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( + "Open the Brunch workspace dialog", ) const retiredWorkspaceCommand = ["brunch", "workspace"].join("-") expect(commands.has(retiredWorkspaceCommand)).toBe(false) - expect(shortcuts.get(BRUNCH_MENU_SHORTCUT)?.description).toBe( - "Open the Brunch menu", + expect(shortcuts.get(BRUNCH_WORKSPACE_SHORTCUT)?.description).toBe( + "Open the Brunch workspace dialog", ) expect(shortcuts.has("ctrl+b")).toBe(false) }) - it("opens the workspace switcher from the Brunch menu shell", async () => { + it("opens the workspace dialog from the Brunch command", async () => { const events: string[] = [] const target = readyWorkspace("/tmp/project", "session-target") const ctx = fakeCommandContext({ currentSessionFile: "/sessions/session-old.jsonl", decisions: [ - "workspace", { action: "openSession", specId: target.spec.id, @@ -421,7 +420,7 @@ describe("Brunch TUI boot", () => { onEvent: (event) => events.push(event), }) - await runBrunchMenuCommand(ctx, { + await runBrunchWorkspaceCommand(ctx, { inspectWorkspace: async () => { events.push("inspect") return inventoryWithWorkspace(target) @@ -434,7 +433,6 @@ describe("Brunch TUI boot", () => { expect(events).toEqual([ "waitForIdle", - "custom", "inspect", "custom", "activate:openSession", @@ -462,7 +460,7 @@ describe("Brunch TUI boot", () => { replacementUi, }) - await runBrunchSettingsSwitcherAction(ctx, { + await runBrunchWorkspaceAction(ctx, { inspectWorkspace: async () => { events.push("inspect") return inventoryWithWorkspace(target) @@ -486,7 +484,17 @@ describe("Brunch TUI boot", () => { "replacement:setTitle", "replacement:notify", ]) - expect(customOptions).toEqual([]) + expect(customOptions).toEqual([ + { + overlay: true, + overlayOptions: { + anchor: "center", + width: 72, + maxHeight: "90%", + margin: 1, + }, + }, + ]) }) it("leaves the current session untouched when workspace switch is cancelled", async () => { @@ -497,7 +505,7 @@ describe("Brunch TUI boot", () => { onEvent: (event) => events.push(event), }) - await runBrunchSettingsSwitcherAction(ctx, { + await runBrunchWorkspaceAction(ctx, { inspectWorkspace: async () => emptyInventory("/tmp/project"), activateWorkspace: async () => ({ status: "cancelled", @@ -526,7 +534,7 @@ describe("Brunch TUI boot", () => { onEvent: (event) => events.push(event), }) - await runBrunchSettingsSwitcherAction(ctx, { + await runBrunchWorkspaceAction(ctx, { inspectWorkspace: async () => emptyInventory("/tmp/project"), activateWorkspace: async () => ({ status: "needs_human", diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 6bee6fd8..e564487c 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -23,7 +23,7 @@ import { chromeStateForWorkspace, createBrunchPiExtensionShell, } from "./pi-extensions.js" -import { runWorkspaceSwitchPreflight } from "./pi-components/workspace-switcher.js" +import { runWorkspaceDialogPreflight } from "./pi-components/workspace-dialog.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, @@ -36,7 +36,7 @@ export { type BrunchChromeState, type BrunchChromeWorkerStatus, } from "./pi-extensions.js" -export { runWorkspaceSwitchPreflight } from "./pi-components/workspace-switcher.js" +export { runWorkspaceDialogPreflight } from "./pi-components/workspace-dialog.js" export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator @@ -49,7 +49,7 @@ export interface BrunchTuiOptions { cwd?: string coordinator?: BrunchTuiCoordinator selectSpecTitle?: () => Promise<string | undefined> - runWorkspaceSwitchPreflight?: ( + runWorkspaceDialogPreflight?: ( inventory: WorkspaceLaunchInventory, ) => Promise<WorkspaceSwitchDecision> launchInteractive?: (context: BrunchTuiLaunchContext) => Promise<void> @@ -83,14 +83,14 @@ async function chooseWorkspaceSwitchDecision( inventory: WorkspaceLaunchInventory, options: BrunchTuiOptions, ): Promise<WorkspaceSwitchDecision> { - if (options.runWorkspaceSwitchPreflight) { - return options.runWorkspaceSwitchPreflight(inventory) + if (options.runWorkspaceDialogPreflight) { + return options.runWorkspaceDialogPreflight(inventory) } if (options.selectSpecTitle && inventory.needsNewSpec) { const title = await options.selectSpecTitle() return title ? { action: "newSpec", title } : { action: "cancel" } } - return runWorkspaceSwitchPreflight(inventory) + return runWorkspaceDialogPreflight(inventory) } async function launchPiInteractive({ diff --git a/src/pi-components/brunch-menu.ts b/src/pi-components/brunch-menu.ts deleted file mode 100644 index 5e1439df..00000000 --- a/src/pi-components/brunch-menu.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - Key, - matchesKey, - truncateToWidth, - type Component, -} from "@earendil-works/pi-tui" - -export type BrunchMenuDecision = "workspace" | "cancel" - -export interface BrunchMenuComponentOptions { - onDecision: (decision: BrunchMenuDecision) => void -} - -interface BrunchMenuOption { - decision: BrunchMenuDecision - label: string - description: string -} - -const BRUNCH_MENU_OPTIONS: BrunchMenuOption[] = [ - { - decision: "workspace", - label: "Workspace / session", - description: "Switch specs or open/create a session", - }, - { - decision: "cancel", - label: "Cancel", - description: "Return to the current conversation", - }, -] - -export function createBrunchMenuComponent( - options: BrunchMenuComponentOptions, -): Component { - return new BrunchMenuComponent(options) -} - -class BrunchMenuComponent implements Component { - #selectedIndex = 0 - - constructor(private options: BrunchMenuComponentOptions) {} - - handleInput(data: string): void { - if (matchesKey(data, Key.up)) { - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) - return - } - if (matchesKey(data, Key.down)) { - this.#selectedIndex = Math.min( - BRUNCH_MENU_OPTIONS.length - 1, - this.#selectedIndex + 1, - ) - return - } - if (matchesKey(data, Key.escape)) { - this.options.onDecision("cancel") - return - } - if (matchesKey(data, Key.enter)) { - this.options.onDecision( - BRUNCH_MENU_OPTIONS[this.#selectedIndex]?.decision ?? "cancel", - ) - } - } - - render(width: number): string[] { - const lines = [ - "Brunch", - "Choose a product action:", - "", - ...BRUNCH_MENU_OPTIONS.flatMap((option, index) => { - const prefix = index === this.#selectedIndex ? "› " : " " - return [`${prefix}${option.label}`, ` ${option.description}`] - }), - "", - "↑↓ navigate • enter select • esc cancel", - ] - return lines.map((line) => truncateToWidth(line, width)) - } - - invalidate(): void {} -} diff --git a/src/pi-components/workspace-dialog.ts b/src/pi-components/workspace-dialog.ts new file mode 100644 index 00000000..6d29c2a1 --- /dev/null +++ b/src/pi-components/workspace-dialog.ts @@ -0,0 +1,7 @@ +export { + buildWorkspaceDialogOptions, + createWorkspaceDialogComponent, + runWorkspaceDialogPreflight, + type WorkspaceDialogComponentOptions, + type WorkspaceDialogOption, +} from "./workspace-dialog/index.js" diff --git a/assets/brunch-logo-quad-56x18-240.ansi b/src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi similarity index 100% rename from assets/brunch-logo-quad-56x18-240.ansi rename to src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi diff --git a/assets/brunch-logo-quad-56x18.ansi b/src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi similarity index 100% rename from assets/brunch-logo-quad-56x18.ansi rename to src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi diff --git a/assets/brunch.png b/src/pi-components/workspace-dialog/assets/brunch.png similarity index 100% rename from assets/brunch.png rename to src/pi-components/workspace-dialog/assets/brunch.png diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts new file mode 100644 index 00000000..4f01e18b --- /dev/null +++ b/src/pi-components/workspace-dialog/component.ts @@ -0,0 +1,294 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +import type { Theme } from "@earendil-works/pi-coding-agent" +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, +} from "@earendil-works/pi-tui" + +import type { + WorkspaceLaunchInventory, + WorkspaceSwitchDecision, +} from "../../workspace-session-coordinator.js" +import { + buildWorkspaceDialogOptions, + type WorkspaceDialogOption, +} from "./model.js" + +const DEFAULT_DIALOG_WIDTH = 72 +const ESC = String.fromCharCode(27) +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) +const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") +const ASSET_DIR = new URL("./assets/", import.meta.url) + +// Letterform copied from: cfonts "brunch" -f tiny -c candy +const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] + +export interface WorkspaceDialogComponentOptions { + inventory: WorkspaceLaunchInventory + onDecision: (decision: WorkspaceSwitchDecision) => void + theme?: Theme +} + +export function createWorkspaceDialogComponent( + options: WorkspaceDialogComponentOptions, +): Component { + return new WorkspaceDialogComponent(options) +} + +class WorkspaceDialogComponent implements Component { + #options: WorkspaceDialogOption[] + #onDecision: (decision: WorkspaceSwitchDecision) => void + #theme: Theme | undefined + #selectedIndex = 0 + #mode: "select" | "newSpecTitle" = "select" + #title = "" + + constructor(options: WorkspaceDialogComponentOptions) { + this.#options = buildWorkspaceDialogOptions(options.inventory) + this.#onDecision = options.onDecision + this.#theme = options.theme + } + + handleInput(data: string): void { + if (this.#mode === "newSpecTitle") { + this.#handleTitleInput(data) + return + } + + if (matchesKey(data, Key.up)) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#selectedIndex = Math.min( + this.#options.length - 1, + this.#selectedIndex + 1, + ) + return + } + if (matchesKey(data, Key.escape)) { + this.#onDecision({ action: "cancel" }) + return + } + if (matchesKey(data, Key.enter)) { + this.#selectCurrentOption() + } + } + + render(width: number): string[] { + const dialogWidth = Math.max(24, Math.min(width, DEFAULT_DIALOG_WIDTH)) + const content = this.#contentLines() + return renderFrame(content, dialogWidth, this.#theme) + } + + invalidate(): void {} + + #contentLines(): string[] { + const title = style(this.#theme, "accent", "Brunch workspace") + const subtitle = style( + this.#theme, + "dim", + "Choose or create the workspace before the agent loop runs.", + ) + const lines = [ + ...readLogo(), + ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), + "", + title, + subtitle, + "", + ] + + if (this.#mode === "newSpecTitle") { + lines.push("New workspace title:", `› ${this.#title}`) + lines.push("", style(this.#theme, "dim", "enter create • esc back")) + return lines + } + + for (const [index, option] of this.#options.entries()) { + const selected = index === this.#selectedIndex + const prefix = selected ? style(this.#theme, "accent", "› ") : " " + const label = selected + ? style(this.#theme, "accent", option.label) + : option.label + lines.push(`${prefix}${label}`) + lines.push(` ${style(this.#theme, "dim", option.description)}`) + } + lines.push( + "", + style(this.#theme, "dim", "↑↓ navigate • enter select • esc cancel"), + ) + return lines + } + + #selectCurrentOption(): void { + const option = this.#options[this.#selectedIndex] + if (!option) { + return + } + if (option.kind === "newSpec") { + this.#mode = "newSpecTitle" + this.#title = "" + return + } + if (option.decision) { + this.#onDecision(option.decision) + } + } + + #handleTitleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.#mode = "select" + this.#title = "" + return + } + if (matchesKey(data, Key.backspace)) { + this.#title = this.#title.slice(0, -1) + return + } + if (matchesKey(data, Key.enter)) { + const title = this.#title.trim() + if (title.length > 0) { + this.#onDecision({ action: "newSpec", title }) + } + return + } + if (isPrintableInput(data)) { + this.#title += data + } + } +} + +function renderFrame( + content: string[], + width: number, + theme: Theme | undefined, +): string[] { + return [ + topBorderLine(width, theme), + emptyLine(width, theme), + ...content.map((line) => contentLine(line, width, theme)), + emptyLine(width, theme), + bottomBorderLine(width, theme), + ] +} + +function contentLine( + content: string, + width: number, + theme: Theme | undefined, +): string { + if (width <= 4) return truncateToWidth(content, width) + const innerWidth = width - 4 + const inner = truncateToWidth(content, innerWidth) + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(inner))) + const vertical = style(theme, "borderMuted", "│") + return `${vertical} ${inner}${padding} ${vertical}` +} + +function emptyLine(width: number, theme: Theme | undefined): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + const vertical = style(theme, "borderMuted", "│") + return `${vertical}${" ".repeat(width - 2)}${vertical}` +} + +function topBorderLine(width: number, theme: Theme | undefined): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + return style(theme, "borderMuted", `╭${"─".repeat(width - 2)}╮`) +} + +function bottomBorderLine(width: number, theme: Theme | undefined): string { + if (width <= 2) return " ".repeat(Math.max(0, width)) + return style(theme, "borderMuted", `╰${"─".repeat(width - 2)}╯`) +} + +function readLogo(): string[] { + const asset = supportsTruecolor() + ? "brunch-logo-quad-56x18.ansi" + : "brunch-logo-quad-56x18-240.ansi" + try { + return cropLogo( + readFileSync(fileURLToPath(new URL(asset, ASSET_DIR)), "utf8") + .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") + .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") + .split("\n"), + ) + } catch { + return [] + } +} + +function supportsTruecolor(): boolean { + const colorterm = process.env.COLORTERM?.toLowerCase() ?? "" + const term = process.env.TERM?.toLowerCase() ?? "" + return ( + colorterm === "truecolor" || + colorterm === "24bit" || + term.includes("truecolor") + ) +} + +function cropLogo(lines: string[]): string[] { + const cropped = [...lines] + while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) + cropped.shift() + while ( + cropped.length > 0 && + stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 + ) + cropped.pop() + if (cropped.length === 0) return [] + + const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) + return cropped.map((line) => removeVisibleColumns(line, commonLeft)) +} + +function stripAnsi(text: string): string { + return text.replace(ANSI_SEQUENCE_GLOBAL, "") +} + +function visibleLeadingSpaces(line: string): number { + const match = stripAnsi(line).match(/^ */) + return match?.[0].length ?? 0 +} + +function removeVisibleColumns(line: string, columns: number): string { + if (columns <= 0) return line + + let output = "" + let removed = 0 + for (let index = 0; index < line.length; index += 1) { + if (line[index] === ESC) { + const match = line.slice(index).match(ANSI_SEQUENCE) + if (match) { + output += match[0] + index += match[0].length - 1 + continue + } + } + + if (removed < columns) { + removed += 1 + continue + } + output += line[index]! + } + return output +} + +function style( + theme: Theme | undefined, + color: Parameters<Theme["fg"]>[0], + text: string, +): string { + return theme ? theme.fg(color, text) : text +} + +function isPrintableInput(data: string): boolean { + return data.length === 1 && data >= " " && data !== "\u007f" +} diff --git a/src/pi-components/workspace-dialog/index.ts b/src/pi-components/workspace-dialog/index.ts new file mode 100644 index 00000000..d332f4b1 --- /dev/null +++ b/src/pi-components/workspace-dialog/index.ts @@ -0,0 +1,9 @@ +export { + createWorkspaceDialogComponent, + type WorkspaceDialogComponentOptions, +} from "./component.js" +export { + buildWorkspaceDialogOptions, + type WorkspaceDialogOption, +} from "./model.js" +export { runWorkspaceDialogPreflight } from "./preflight.js" diff --git a/src/pi-components/workspace-switcher/model.ts b/src/pi-components/workspace-dialog/model.ts similarity index 90% rename from src/pi-components/workspace-switcher/model.ts rename to src/pi-components/workspace-dialog/model.ts index da500966..9eb76aa3 100644 --- a/src/pi-components/workspace-switcher/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -4,7 +4,7 @@ import type { WorkspaceSwitchDecision, } from "../../workspace-session-coordinator.js" -export interface WorkspaceSwitchOption { +export interface WorkspaceDialogOption { id: string label: string description: string @@ -12,10 +12,10 @@ export interface WorkspaceSwitchOption { decision?: WorkspaceSwitchDecision } -export function buildWorkspaceSwitchOptions( +export function buildWorkspaceDialogOptions( inventory: WorkspaceLaunchInventory, -): WorkspaceSwitchOption[] { - const options: WorkspaceSwitchOption[] = [] +): WorkspaceDialogOption[] { + const options: WorkspaceDialogOption[] = [] const currentSession = findCurrentSession(inventory) if (currentSession && inventory.currentSpec) { @@ -64,14 +64,14 @@ export function buildWorkspaceSwitchOptions( options.push({ id: "new-spec", - label: "Create spec", + label: "Create workspace", description: "Name a new specification workspace", kind: "newSpec", }) options.push({ id: "cancel", label: "Cancel", - description: "Exit without opening a Brunch session", + description: "Exit without opening a Brunch workspace", kind: "cancel", decision: { action: "cancel" }, }) diff --git a/src/pi-components/workspace-switcher/preflight.ts b/src/pi-components/workspace-dialog/preflight.ts similarity index 64% rename from src/pi-components/workspace-switcher/preflight.ts rename to src/pi-components/workspace-dialog/preflight.ts index 6b72db07..3e46a7ab 100644 --- a/src/pi-components/workspace-switcher/preflight.ts +++ b/src/pi-components/workspace-dialog/preflight.ts @@ -4,9 +4,9 @@ import type { WorkspaceLaunchInventory, WorkspaceSwitchDecision, } from "../../workspace-session-coordinator.js" -import { createWorkspaceSwitchComponent } from "./component.js" +import { createWorkspaceDialogComponent } from "./component.js" -export async function runWorkspaceSwitchPreflight( +export async function runWorkspaceDialogPreflight( inventory: WorkspaceLaunchInventory, ): Promise<WorkspaceSwitchDecision> { const terminal = new ProcessTerminal() @@ -14,15 +14,20 @@ export async function runWorkspaceSwitchPreflight( return await new Promise<WorkspaceSwitchDecision>((resolve) => { const finish = (decision: WorkspaceSwitchDecision) => { + overlay.hide() tui.stop() resolve(decision) } - const component = createWorkspaceSwitchComponent({ + const component = createWorkspaceDialogComponent({ inventory, onDecision: finish, }) - tui.addChild(component) - tui.setFocus(component) + const overlay = tui.showOverlay(component, { + anchor: "center", + width: 72, + maxHeight: "90%", + margin: 1, + }) terminal.clearScreen() tui.start() }) diff --git a/src/pi-components/workspace-switcher.ts b/src/pi-components/workspace-switcher.ts deleted file mode 100644 index 05c44801..00000000 --- a/src/pi-components/workspace-switcher.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - buildWorkspaceSwitchOptions, - createWorkspaceSwitchComponent, - runWorkspaceSwitchPreflight, - type WorkspaceSwitchComponentOptions, - type WorkspaceSwitchOption, -} from "./workspace-switcher/index.js" diff --git a/src/pi-components/workspace-switcher/component.ts b/src/pi-components/workspace-switcher/component.ts deleted file mode 100644 index f762b439..00000000 --- a/src/pi-components/workspace-switcher/component.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - Key, - matchesKey, - truncateToWidth, - type Component, -} from "@earendil-works/pi-tui" - -import type { - WorkspaceLaunchInventory, - WorkspaceSwitchDecision, -} from "../../workspace-session-coordinator.js" -import { - buildWorkspaceSwitchOptions, - type WorkspaceSwitchOption, -} from "./model.js" - -export interface WorkspaceSwitchComponentOptions { - inventory: WorkspaceLaunchInventory - onDecision: (decision: WorkspaceSwitchDecision) => void -} - -export function createWorkspaceSwitchComponent( - options: WorkspaceSwitchComponentOptions, -): Component { - return new WorkspaceSwitchComponent(options) -} - -class WorkspaceSwitchComponent implements Component { - #options: WorkspaceSwitchOption[] - #onDecision: (decision: WorkspaceSwitchDecision) => void - #selectedIndex = 0 - #mode: "select" | "newSpecTitle" = "select" - #title = "" - - constructor(options: WorkspaceSwitchComponentOptions) { - this.#options = buildWorkspaceSwitchOptions(options.inventory) - this.#onDecision = options.onDecision - } - - handleInput(data: string): void { - if (this.#mode === "newSpecTitle") { - this.#handleTitleInput(data) - return - } - - if (matchesKey(data, Key.up)) { - this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) - return - } - if (matchesKey(data, Key.down)) { - this.#selectedIndex = Math.min( - this.#options.length - 1, - this.#selectedIndex + 1, - ) - return - } - if (matchesKey(data, Key.escape)) { - this.#onDecision({ action: "cancel" }) - return - } - if (matchesKey(data, Key.enter)) { - this.#selectCurrentOption() - } - } - - render(width: number): string[] { - const lines = ["Brunch workspace", "Choose how to start this session:", ""] - - if (this.#mode === "newSpecTitle") { - lines.push("New spec title:", `> ${this.#title}`) - lines.push("enter create • esc cancel") - return lines.map((line) => truncateToWidth(line, width)) - } - - for (const [index, option] of this.#options.entries()) { - const prefix = index === this.#selectedIndex ? "› " : " " - lines.push(`${prefix}${option.label}`) - lines.push(` ${option.description}`) - } - lines.push("", "↑↓ navigate • enter select • esc cancel") - return lines.map((line) => truncateToWidth(line, width)) - } - - invalidate(): void {} - - #selectCurrentOption(): void { - const option = this.#options[this.#selectedIndex] - if (!option) { - return - } - if (option.kind === "newSpec") { - this.#mode = "newSpecTitle" - this.#title = "" - return - } - if (option.decision) { - this.#onDecision(option.decision) - } - } - - #handleTitleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.#mode = "select" - this.#title = "" - return - } - if (matchesKey(data, Key.backspace)) { - this.#title = this.#title.slice(0, -1) - return - } - if (matchesKey(data, Key.enter)) { - const title = this.#title.trim() - if (title.length > 0) { - this.#onDecision({ action: "newSpec", title }) - } - return - } - if (isPrintableInput(data)) { - this.#title += data - } - } -} - -function isPrintableInput(data: string): boolean { - return data.length === 1 && data >= " " && data !== "\u007f" -} diff --git a/src/pi-components/workspace-switcher/index.ts b/src/pi-components/workspace-switcher/index.ts deleted file mode 100644 index 241e8e6e..00000000 --- a/src/pi-components/workspace-switcher/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - createWorkspaceSwitchComponent, - type WorkspaceSwitchComponentOptions, -} from "./component.js" -export { - buildWorkspaceSwitchOptions, - type WorkspaceSwitchOption, -} from "./model.js" -export { runWorkspaceSwitchPreflight } from "./preflight.js" diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 8aad19eb..e0f7cc70 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -20,9 +20,9 @@ import { type BrunchSessionBoundaryHandler, } from "./pi-extensions/session-lifecycle.js" import { - registerBrunchSettingsSwitcherMenu, - type BrunchSettingsSwitcherMenuOptions, -} from "./pi-extensions/settings-switcher-menu.js" + registerBrunchWorkspaceDialog, + type BrunchWorkspaceDialogOptions, +} from "./pi-extensions/workspace-dialog.js" export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" @@ -66,16 +66,16 @@ export { type BrunchSessionBoundaryHandler, } from "./pi-extensions/session-lifecycle.js" export { - BRUNCH_MENU_COMMAND, - BRUNCH_MENU_SHORTCUT, - registerBrunchSettingsSwitcherMenu, - runBrunchMenuCommand, - runBrunchSettingsSwitcherAction, - type BrunchSettingsSwitcherMenuOptions, -} from "./pi-extensions/settings-switcher-menu.js" + BRUNCH_WORKSPACE_COMMAND, + BRUNCH_WORKSPACE_SHORTCUT, + registerBrunchWorkspaceDialog, + runBrunchWorkspaceAction, + runBrunchWorkspaceCommand, + type BrunchWorkspaceDialogOptions, +} from "./pi-extensions/workspace-dialog.js" export interface BrunchPiExtensionShellOptions - extends BrunchSettingsSwitcherMenuOptions { + extends BrunchWorkspaceDialogOptions { graphMentionSource?: GraphMentionSource } @@ -97,6 +97,6 @@ export function createBrunchPiExtensionShell( registerBrunchOperationalModePolicy(pi) registerBrunchMentionAutocomplete(pi, options.graphMentionSource) registerBrunchAlternatives(pi) - registerBrunchSettingsSwitcherMenu(pi, options) + registerBrunchWorkspaceDialog(pi, options) } } diff --git a/src/pi-extensions/settings-switcher-menu.ts b/src/pi-extensions/workspace-dialog.ts similarity index 62% rename from src/pi-extensions/settings-switcher-menu.ts rename to src/pi-extensions/workspace-dialog.ts index 19a34ad9..3612d670 100644 --- a/src/pi-extensions/settings-switcher-menu.ts +++ b/src/pi-extensions/workspace-dialog.ts @@ -8,59 +8,45 @@ import { type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "../workspace-session-coordinator.js" -import { - createBrunchMenuComponent, - type BrunchMenuDecision, -} from "../pi-components/brunch-menu.js" -import { createWorkspaceSwitchComponent } from "../pi-components/workspace-switcher/index.js" +import { createWorkspaceDialogComponent } from "../pi-components/workspace-dialog/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" -export const BRUNCH_MENU_COMMAND = "brunch" -export const BRUNCH_MENU_SHORTCUT = "ctrl+shift+b" +export const BRUNCH_WORKSPACE_COMMAND = "brunch" +export const BRUNCH_WORKSPACE_SHORTCUT = "ctrl+shift+b" -export interface BrunchSettingsSwitcherMenuOptions { +export interface BrunchWorkspaceDialogOptions { coordinator: WorkspaceSwitchCoordinator } -export function registerBrunchSettingsSwitcherMenu( +export function registerBrunchWorkspaceDialog( pi: ExtensionAPI, - { coordinator }: BrunchSettingsSwitcherMenuOptions, + { coordinator }: BrunchWorkspaceDialogOptions, ): void { - pi.registerCommand(BRUNCH_MENU_COMMAND, { - description: "Open the Brunch menu", + pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { + description: "Open the Brunch workspace dialog", handler: async (_args, ctx) => { - await runBrunchMenuCommand(ctx, coordinator) + await runBrunchWorkspaceCommand(ctx, coordinator) }, }) - pi.registerShortcut?.(BRUNCH_MENU_SHORTCUT, { - description: "Open the Brunch menu", + pi.registerShortcut?.(BRUNCH_WORKSPACE_SHORTCUT, { + description: "Open the Brunch workspace dialog", handler: async (ctx) => { - await runBrunchMenuCommand(ctx as ExtensionCommandContext, coordinator) + await runBrunchWorkspaceCommand( + ctx as ExtensionCommandContext, + coordinator, + ) }, }) } -export async function runBrunchMenuCommand( +export async function runBrunchWorkspaceCommand( ctx: ExtensionCommandContext, coordinator: WorkspaceSwitchCoordinator, ): Promise<void> { - await ctx.waitForIdle() - const decision = await ctx.ui.custom<BrunchMenuDecision>( - (_tui, _theme, _keybindings, done) => - createBrunchMenuComponent({ onDecision: done }), - ) - - if (decision === "cancel") { - ctx.ui.notify("Brunch menu closed.", "info") - return - } - - await runBrunchSettingsSwitcherAction(ctx, coordinator, { - waitForIdle: false, - }) + await runBrunchWorkspaceAction(ctx, coordinator) } -export async function runBrunchSettingsSwitcherAction( +export async function runBrunchWorkspaceAction( ctx: ExtensionCommandContext, coordinator: WorkspaceSwitchCoordinator, options: { waitForIdle?: boolean } = {}, @@ -70,8 +56,17 @@ export async function runBrunchSettingsSwitcherAction( } const inventory = await coordinator.inspectWorkspace() const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( - (_tui, _theme, _keybindings, done) => - createWorkspaceSwitchComponent({ inventory, onDecision: done }), + (_tui, theme, _keybindings, done) => + createWorkspaceDialogComponent({ inventory, theme, onDecision: done }), + { + overlay: true, + overlayOptions: { + anchor: "center", + width: 72, + maxHeight: "90%", + margin: 1, + }, + }, ) const activated = await coordinator.activateWorkspace(decision) diff --git a/src/workspace-switcher.test.ts b/src/workspace-dialog.test.ts similarity index 77% rename from src/workspace-switcher.test.ts rename to src/workspace-dialog.test.ts index 963a7ea3..cea72502 100644 --- a/src/workspace-switcher.test.ts +++ b/src/workspace-dialog.test.ts @@ -5,14 +5,14 @@ import { visibleWidth } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" import { - buildWorkspaceSwitchOptions, - createWorkspaceSwitchComponent, -} from "./pi-components/workspace-switcher.js" + buildWorkspaceDialogOptions, + createWorkspaceDialogComponent, +} from "./pi-components/workspace-dialog/index.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" -describe("workspace switcher", () => { +describe("workspace dialog", () => { it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { - const options = buildWorkspaceSwitchOptions(inventory()) + const options = buildWorkspaceDialogOptions(inventory()) expect(options.map((option) => option.kind)).toEqual([ "continue", @@ -32,7 +32,7 @@ describe("workspace switcher", () => { }, }) expect(options.at(-2)).toMatchObject({ - label: "Create spec", + label: "Create workspace", }) expect(options.at(-2)).not.toHaveProperty("decision") expect(options.at(-1)).toMatchObject({ @@ -43,7 +43,7 @@ describe("workspace switcher", () => { it("selects current resume and existing sessions as typed decisions", () => { const decisions: unknown[] = [] - const component = createWorkspaceSwitchComponent({ + const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: (decision) => decisions.push(decision), }) @@ -69,7 +69,7 @@ describe("workspace switcher", () => { it("returns new-spec decisions from title entry and cancel on escape", () => { const decisions: unknown[] = [] - const component = createWorkspaceSwitchComponent({ + const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: (decision) => decisions.push(decision), }) @@ -82,7 +82,7 @@ describe("workspace switcher", () => { component.handleInput!(char) } component.handleInput!("\r") - const cancelComponent = createWorkspaceSwitchComponent({ + const cancelComponent = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: (decision) => decisions.push(decision), }) @@ -94,15 +94,29 @@ describe("workspace switcher", () => { ]) }) - it("keeps rendered lines within the requested width", () => { - const component = createWorkspaceSwitchComponent({ + it("renders a branded centered-dialog frame within the requested width", () => { + const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: () => {}, }) - expect(component.render(24).every((line) => visibleWidth(line) <= 24)).toBe( - true, + const lines = component.render(64) + + expect(lines[0]).toContain("╭") + expect(lines.some((line) => line.includes("Brunch workspace"))).toBe(true) + expect(lines.every((line) => visibleWidth(line) <= 64)).toBe(true) + }) + + it("keeps logo assets colocated with the workspace dialog component", async () => { + const source = await readFile( + new URL( + "./pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", + import.meta.url, + ), + "utf8", ) + + expect(source).toContain("\x1B[") }) it("declares pi-tui as a direct dependency", async () => { From 7d483088da613df48ed4ddf84114cc44b2562c6e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:08:28 +0200 Subject: [PATCH 054/164] FE-744 persist brunch agent runtime switches --- memory/CARDS.md | 2 +- src/pi-extensions.ts | 3 + src/pi-extensions/operational-mode.test.ts | 96 ++++++++++++++++++++++ src/pi-extensions/operational-mode.ts | 92 +++++++++++++++++++-- 4 files changed, 187 insertions(+), 6 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index dc00b672..5b1b9379 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -208,7 +208,7 @@ Before each agent turn, `operational-mode.ts` applies the reconstructed and reso ## Card 3 — Persist Brunch agent-state switches as selected-state snapshots -**Status:** queued +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index e0f7cc70..6462c631 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -35,6 +35,8 @@ export { export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, + appendBrunchAgentRuntimeInit, + appendBrunchAgentRuntimeSwitch, projectBrunchAgentState, registerBrunchOperationalModePolicy, type AgentLensId, @@ -43,6 +45,7 @@ export { type AgentStrategyId, type BrunchAgentState, type BrunchAgentStateEntryData, + type BrunchAgentStateEntrySessionManager, type OperationalModeDefinition, type OperationalModeId, type ResolvedBrunchAgentState, diff --git a/src/pi-extensions/operational-mode.test.ts b/src/pi-extensions/operational-mode.test.ts index c283ddd5..e8e1ae38 100644 --- a/src/pi-extensions/operational-mode.test.ts +++ b/src/pi-extensions/operational-mode.test.ts @@ -9,9 +9,12 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, + appendBrunchAgentRuntimeInit, + appendBrunchAgentRuntimeSwitch, projectBrunchAgentState, registerBrunchOperationalModePolicy, type BrunchAgentState, + type BrunchAgentStateEntryData, } from "./operational-mode.js" function runtimeEntry( @@ -31,6 +34,23 @@ function runtimeEntry( } } +class FakeRuntimeStateSessionManager { + entries: Array<{ + type: "custom" + customType: string + data: BrunchAgentStateEntryData + }> = [] + + getEntries() { + return this.entries + } + + appendCustomEntry(customType: string, data: BrunchAgentStateEntryData) { + this.entries.push({ type: "custom", customType, data }) + return `entry-${this.entries.length}` + } +} + describe("Brunch agent runtime-state projection", () => { it("projects the deterministic elicit/elicitor default when no runtime entries exist", () => { expect(projectBrunchAgentState([])).toMatchObject({ @@ -146,6 +166,82 @@ describe("Brunch agent runtime-state projection", () => { }) }) + it("appends init only when the transcript has no valid runtime state", () => { + const manager = new FakeRuntimeStateSessionManager() + + expect(appendBrunchAgentRuntimeInit(manager)).toBe("entry-1") + expect(appendBrunchAgentRuntimeInit(manager)).toBeUndefined() + expect(manager.entries).toHaveLength(1) + expect(manager.entries[0]?.data).toEqual({ + schemaVersion: 1, + reason: "init", + state: DEFAULT_BRUNCH_AGENT_STATE, + source: "extension", + }) + }) + + it("appends validated runtime switches as full state snapshots", () => { + const manager = new FakeRuntimeStateSessionManager() + appendBrunchAgentRuntimeInit(manager) + const latestState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "disambiguate-via-examples", + agentLens: "disambiguate-via-examples", + } + + expect(appendBrunchAgentRuntimeSwitch(manager, latestState, "user")).toBe( + "entry-2", + ) + + expect(manager.entries[1]?.data).toEqual({ + schemaVersion: 1, + reason: "switch", + state: latestState, + previous: DEFAULT_BRUNCH_AGENT_STATE, + source: "user", + }) + expect(projectBrunchAgentState(manager.getEntries())).toMatchObject( + latestState, + ) + }) + + it("rejects invalid runtime switch combinations before appending", () => { + const manager = new FakeRuntimeStateSessionManager() + + expect(() => + appendBrunchAgentRuntimeSwitch(manager, { + schemaVersion: 1, + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "not-a-strategy", + agentLens: "step-by-step", + } as unknown as BrunchAgentState), + ).toThrow("Invalid BrunchAgentState runtime selection.") + expect(manager.entries).toEqual([]) + }) + + it("appends runtime init from the extension session-start hook", async () => { + const manager = new FakeRuntimeStateSessionManager() + const events: Record<string, (event: never, ctx?: never) => unknown> = {} + + registerBrunchOperationalModePolicy({ + registerTool: (_tool: { name: string }) => {}, + getAllTools: () => ["read"].map((name) => ({ name })), + setActiveTools: (_tools: string[]) => {}, + on: (event: string, handler: (event: never, ctx?: never) => unknown) => { + events[event] = handler + }, + } as never) + + await events.session_start?.({} as never, { + sessionManager: manager, + } as never) + + expect(manager.entries[0]?.data.reason).toBe("init") + }) + it("reprojects runtime-state snapshots after Pi JSONL reload", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-agent-state-")) const sessionDir = join(cwd, ".brunch", "sessions") diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index 5bb97cf4..87351356 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -204,10 +204,10 @@ function resolveBrunchAgentState( } } -export function projectBrunchAgentState( +function latestValidBrunchAgentStateEntryData( entries: readonly CustomEntryLike[], -): ResolvedBrunchAgentState { - let state = DEFAULT_BRUNCH_AGENT_STATE +): BrunchAgentStateEntryData | undefined { + let latest: BrunchAgentStateEntryData | undefined for (const entry of entries) { if ( @@ -217,10 +217,79 @@ export function projectBrunchAgentState( continue } const data = parseBrunchAgentStateEntryData(entry.data) - if (data) state = data.state + if (data) latest = data } - return resolveBrunchAgentState(state) + return latest +} + +export function projectBrunchAgentState( + entries: readonly CustomEntryLike[], +): ResolvedBrunchAgentState { + return resolveBrunchAgentState( + latestValidBrunchAgentStateEntryData(entries)?.state ?? + DEFAULT_BRUNCH_AGENT_STATE, + ) +} + +export interface BrunchAgentStateEntrySessionManager { + getEntries(): readonly CustomEntryLike[] + appendCustomEntry(customType: string, data: BrunchAgentStateEntryData): string +} + +function requireValidBrunchAgentState( + state: BrunchAgentState, +): BrunchAgentState { + const valid = parseBrunchAgentState(state) + if (!valid) { + throw new Error("Invalid BrunchAgentState runtime selection.") + } + return valid +} + +export function appendBrunchAgentRuntimeInit( + sessionManager: BrunchAgentStateEntrySessionManager, + source: BrunchAgentStateEntryData["source"] = "extension", +): string | undefined { + if (latestValidBrunchAgentStateEntryData(sessionManager.getEntries())) { + return undefined + } + + return sessionManager.appendCustomEntry( + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + { + schemaVersion: 1, + reason: "init", + state: DEFAULT_BRUNCH_AGENT_STATE, + source, + }, + ) +} + +export function appendBrunchAgentRuntimeSwitch( + sessionManager: BrunchAgentStateEntrySessionManager, + state: BrunchAgentState, + source: BrunchAgentStateEntryData["source"] = "user", +): string { + const validState = requireValidBrunchAgentState(state) + const previous = projectBrunchAgentState(sessionManager.getEntries()) + + return sessionManager.appendCustomEntry( + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + { + schemaVersion: 1, + reason: "switch", + state: validState, + previous: { + schemaVersion: previous.schemaVersion, + operationalMode: previous.operationalMode, + agentRole: previous.agentRole, + agentStrategy: previous.agentStrategy, + agentLens: previous.agentLens, + }, + source, + }, + ) } function shortenPath(path: string): string { @@ -244,6 +313,16 @@ function projectBrunchAgentStateFromSessionManager( return projectBrunchAgentState(sessionManager?.getEntries() ?? []) } +function supportsBrunchAgentStateEntries( + sessionManager: SessionManagerLike | undefined, +): sessionManager is BrunchAgentStateEntrySessionManager { + return ( + sessionManager !== undefined && + typeof (sessionManager as Partial<BrunchAgentStateEntrySessionManager>) + .appendCustomEntry === "function" + ) +} + function activeToolNamesForState( pi: ExtensionAPI, state: ResolvedBrunchAgentState, @@ -481,6 +560,9 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { }) pi.on("session_start", async (_event, ctx) => { + if (supportsBrunchAgentStateEntries(ctx?.sessionManager)) { + appendBrunchAgentRuntimeInit(ctx.sessionManager) + } const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager) applyBrunchToolPolicy(pi, state) }) From 11e3a026da58610cdc6e8e084a749ccec7b98e98 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:11:05 +0200 Subject: [PATCH 055/164] FE-744 reconcile runtime state cards --- memory/CARDS.md | 253 ------------------------------------------------ memory/PLAN.md | 4 +- memory/SPEC.md | 2 +- 3 files changed, 3 insertions(+), 256 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 5b1b9379..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,253 +0,0 @@ -# Scope Cards — sealed-pi-profile-runtime-state follow-up - -## Orientation - -- **Containing frontier:** `sealed-pi-profile-runtime-state` in `memory/PLAN.md`; this remains one frontier/Linear/branch boundary, now following the completed FE-744 extension/component port. -- **Containing seam:** Brunch-owned Pi wrapper: `src/pi-extensions.ts`, `src/pi-extensions/*`, `src/pi-components/*`, transcript-backed `BrunchAgentState`, prompt/tool posture, chrome projection, and sealed-profile resource isolation. -- **Volatile state:** Prior Cards 1–8 for the extension/component port have landed on `ln/fe-744-pi-ui-extension-patterns`; review found post-port cleanup and overclaim issues that must be fixed before runtime-state expansion. -- **Main open risk:** Runtime-state work will be built on shaky footing if the just-ported extension layout, chrome contract, menu naming, and operational-mode seam still contain stale probe-era vocabulary. - -## Frontier-level obligations - -- Preserve sealed-profile posture: Brunch product behavior comes from programmatic Brunch extension factories and profile policy, not ambient `.pi/` discovery. -- Preserve D23-L/D40-L/I25-L: transport mode, operational mode, agent role, strategy, and lens are separate axes, and active agent posture must be reconstructable from linear transcript entries at turn start. -- Preserve D25-L/D32-L: lenses are elicitor metadata and establishment offers are orientation artifacts, not a persistent default strategy menu. -- Preserve current elicit-safe tool policy: `elicit` must not expose side-effecting tools such as raw `bash`, `edit`, or `write` unless explicitly allowed by a future operational mode. -- Keep derivative planning state disciplined: scope-card queues live in `memory/CARDS.md`; temporary sidecar drafts must be reconciled and deleted. - ---- - -## Card 0 — Reconcile post-port review findings before runtime-state work - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The completed extension/component port has no unreconciled draft sidecar, chrome overclaim, or stale probe-era naming in product code and architecture evidence. - -### Boundary Crossings - -```text -→ docs/design/DRAFT_CARDS.md temporary sidecar -→ memory/CARDS.md canonical scope queue -→ src/pi-extensions/chrome.ts and chrome tests -→ docs/architecture/pi-ui-extension-patterns.md -→ src/pi-extensions/workspace-dialog.ts aggregate exports -→ src/pi-extensions/operational-mode.ts naming/comments -→ src/pi-components/cards.ts and src/pi-extensions/alternatives.ts comments -``` - -### Risks and Assumptions - -- RISK: Chrome code and architecture docs can drift in opposite directions → MITIGATION: either finish the richer chrome port or narrow the docs/acceptance in the same slice; do not leave proof language stronger than code. -- RISK: Renaming menu/workspace exports can break tests or external imports → MITIGATION: update aggregate exports and tests deliberately; keep workspace switching as an internal helper behind menu/settings-switcher language. -- RISK: Card 0 becomes a grab bag → MITIGATION: limit it to review findings #1–#6 from the completed port and stop before adding new runtime-state behavior. -- ASSUMPTION: FE-744 Cards 1–8 are otherwise green and this slice is cleanup/reconciliation, not a feature expansion → VALIDATE: `npm run verify` remains green after edits. - -### Acceptance Criteria - -✓ `planning sidecar removed` — useful content from `docs/design/DRAFT_CARDS.md` is reconciled into `memory/CARDS.md`, and `docs/design/DRAFT_CARDS.md` is deleted. -✓ `chrome proof matches code` — `src/pi-extensions/chrome.ts` and `docs/architecture/pi-ui-extension-patterns.md` agree on the actual chrome contract: either richer version/build/model/thinking/context/git/status passthrough is implemented and tested, or docs explicitly narrow the claim. -✓ `extension layout narrative updated` — `docs/architecture/pi-ui-extension-patterns.md` names the current flat `src/pi-extensions.ts`, `command-policy`, `session-lifecycle`, `workspace-dialog`, `operational-mode`, `mention-autocomplete`, `alternatives`, and `src/pi-components/*` layout without old `branch-policy` / `session-boundary` / `workspace-command` narratives. -✓ `workspace dialog surface renamed` — public-ish exports use workspace-dialog language for `/brunch`; coordinator-owned workspace activation remains separate from the reusable decision component. -✓ `operational-mode vocabulary cleaned` — `operational-mode.ts` no longer reads like copied “Brunch — tools” / generic read-only tool policy, and local constants/comments use `elicit` / operational-mode policy vocabulary. -✓ `stale comments cleaned` — `src/pi-components/cards.ts` and `src/pi-extensions/alternatives.ts` no longer reference `.pi/extensions`, `brunch-messages.ts`, malformed comments, or empty activation sections. - -### Verification Approach - -- Inner: `npm run fix`; targeted unit/source tests for chrome formatting, menu command registration/export shape, and operational-mode policy where present. -- Middle: source/doc audit — `rg "DRAFT_CARDS|branch-policy|session-boundary|workspace-command|brunch-workspace|brunch-messages|\.pi/extensions" memory docs/architecture src` has only intentional historical references, and `npm run verify` passes. - -### Cross-cutting obligations - -- Do not add Brunch agent-state switching in this cleanup card. -- Preserve existing `/brunch` behavior and coordinator-owned workspace activation while keeping the module surface named around workspace. -- Keep chrome a projection, not authority; it must not mutate workspace/session state. - ---- - -## Card 1 — Project Brunch agent state from transcript - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`src/pi-extensions/operational-mode.ts` reconstructs the active `BrunchAgentState` from `brunch.agent_runtime_state` custom entries with a deterministic default when no runtime entries exist. - -### Boundary Crossings - -```text -→ Pi SessionManager linear entries -→ Brunch agent-runtime-entry parser/projection helpers -→ Brunch operational-mode / agent-role definition registry -→ operational-mode policy state used by extension handlers -``` - -### Risks and Assumptions - -- RISK: Runtime-entry schemas become durable before they are typed tightly enough → MITIGATION: define discriminated TypeScript shapes for `brunch.agent_runtime_state`, reject unknown/partial entries in projection tests, and keep parser tolerant only by ignoring malformed entries rather than guessing. -- RISK: Default state silently diverges from the current fixed read-only policy → MITIGATION: make the default state explicit (`operationalMode: "elicit"`, `agentRole: "elicitor"`, default strategy/lens) and assert its resolved tool/prompt posture in tests. -- ASSUMPTION: Pi custom entries can be read synchronously enough from `ctx.sessionManager.getEntries()` during `session_start` / `before_agent_start` → VALIDATE: fake SessionManager tests plus existing JSONL projection tests; already governed by D17-L/D40-L/I25-L. - -### Acceptance Criteria - -✓ `projects default runtime` — with no runtime custom entries, projection returns a `BrunchAgentState` with operational mode `elicit`, agent role `elicitor`, and role-default strategy/lens selections. -✓ `last valid runtime state wins` — a later `brunch.agent_runtime_state` supersedes earlier snapshots without mutating older transcript state. -✓ `rejects ambient config authority` — projection does not read `.pi/presets.json`, `.pi/modes.json`, environment mode files, or extension-local persisted booleans. -✓ `exports typed runtime state` — tests can import a narrow `projectBrunchAgentState`/equivalent helper without instantiating a full Pi runtime. - -### Verification Approach - -- Inner: unit/schema tests — runtime-entry parsing, default projection, last-valid-entry-wins ordering, malformed-entry handling. -- Middle: JSONL fixture/projection test — append representative runtime init/switch custom entries and reload/project them through the same helper used by the extension. - -### Cross-cutting obligations - -- Runtime state is transcript-backed, not hidden extension memory. -- Keep the concept named `BrunchAgentState` / `operational mode`, not generic Pi mode or plan mode. -- This card should not add user-facing strategy/lens menus. - -### Terminology and types - -```ts -export interface BrunchAgentState { - schemaVersion: 1 - operationalMode: OperationalModeId - agentRole: AgentRoleId - agentStrategy: AgentStrategyId - agentLens: AgentLensId | null -} - -export interface OperationalModeDefinition { - id: OperationalModeId - defaultRole: AgentRoleId - allowedRoles: readonly AgentRoleId[] - toolPolicyId: ToolPolicyId - promptPackIds: readonly PromptPackId[] -} - -export interface AgentRoleDefinition { - id: AgentRoleId - operationalMode: OperationalModeId - defaultStrategy: AgentStrategyId - allowedStrategies: readonly AgentStrategyId[] - defaultLens: AgentLensId | null - allowedLenses: readonly AgentLensId[] - promptPackIds: readonly PromptPackId[] - modelPreference?: ModelPreference - thinkingLevel?: ThinkingLevel -} - -export interface ResolvedBrunchAgentState extends BrunchAgentState { - operationalModeDefinition: OperationalModeDefinition - agentRoleDefinition: AgentRoleDefinition -} - -export interface BrunchAgentStateEntryData { - schemaVersion: 1 - reason: "init" | "switch" - state: BrunchAgentState - previous?: BrunchAgentState - source: "system" | "user" | "agent" | "extension" -} -``` - -Custom entry kind: `brunch.agent_runtime_state`. - -Validation requires: `OperationalModeDefinition.allowedRoles` contains `agentRole`; `AgentRoleDefinition.operationalMode` equals `operationalMode`; `AgentRoleDefinition.allowedStrategies` contains `agentStrategy`; and `agentLens` is either `null` or contained in `AgentRoleDefinition.allowedLenses`. - ---- - -## Card 2 — Apply active Brunch agent state to prompt and tools - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -Before each agent turn, `operational-mode.ts` applies the reconstructed and resolved `BrunchAgentState` tool policy and prompt packs. - -### Boundary Crossings - -```text -→ runtime-state projection helper -→ Pi before_agent_start hook -→ Pi active-tool registry -→ Pi tool_call / user_bash enforcement hooks -→ model-facing system prompt -``` - -### Risks and Assumptions - -- RISK: `setActiveTools()` is only a visibility layer and cannot be the whole authority boundary → MITIGATION: preserve `tool_call` and `user_bash` blockers as defense-in-depth. -- RISK: Prompt fragments become scattered strings again → MITIGATION: centralize prompt text in operational-mode and agent-role definitions and have `before_agent_start` compose from resolved state. -- ASSUMPTION: Current `elicit` + `elicitor` state should preserve the read-only tool set from `.pi/extensions/brunch-tools.ts` / current `operational-mode.ts` → VALIDATE: active-tools and blocking tests assert `read`, `grep`, `find`, `ls` allowed and `bash`, `edit`, `write` blocked. - -### Acceptance Criteria - -✓ `applies elicit tools` — `before_agent_start` sets active tools from the resolved operational mode / agent role definitions for `elicit` + `elicitor`. -✓ `injects resolved prompt` — the system prompt includes operational-mode and agent-role guidance from the resolved `BrunchAgentState`. -✓ `blocks side effects` — `tool_call` blocks `bash`, `edit`, `write`, and any non-allowed tool under `elicit` + `elicitor` with deterministic Brunch wording. -✓ `blocks user bash` — `user_bash` returns a deterministic blocked result under `elicit` + `elicitor`. -✓ `does not hardcode plan-mode vocabulary` — product prompt/status strings refer to Brunch operational mode and agent role, not borrowed plan-mode terminology. - -### Verification Approach - -- Inner: fake ExtensionAPI tests — active tool application, prompt composition, tool-call blocking, user-bash blocking. -- Middle: aggregate extension factory test — `createBrunchPiExtensionShell` loads operational-mode policy programmatically and no ambient `.pi` tool policy is required. - -### Cross-cutting obligations - -- Preserve I25-L: tool gating follows reconstructed operational mode. -- Preserve sealed-profile posture: ambient Pi settings/resources must not decide the tool set. -- Keep future `execute` as a new operational-mode definition, not a contradiction of current `elicit` safety. - ---- - -## Card 3 — Persist Brunch agent-state switches as selected-state snapshots - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -Brunch-owned runtime switch helpers persist accepted agent-state changes as full selected `BrunchAgentState` snapshots. - -### Boundary Crossings - -```text -→ product command/helper entry point -→ operational-mode / agent-role registry validation -→ Pi appendEntry custom transcript persistence -→ runtime-state projection helper -→ Brunch chrome/status projection input -→ future observer/reviewer routing metadata -``` - -### Risks and Assumptions - -- RISK: A switch UI turns into a default strategy menu and violates D32-L → MITIGATION: expose narrow product command/helper hooks for explicit user/agent switches only; do not render a persistent exhaustive menu by default. -- RISK: Runtime axes drift into invalid combinations → MITIGATION: validate every requested change against the operational-mode / agent-role registry hierarchy and append only a full valid selected `BrunchAgentState` snapshot. -- ASSUMPTION: Product commands may append custom entries through Pi extension APIs for now, while future Brunch command-layer integration can own richer authority → VALIDATE: tests assert append shape and replay projection; no graph mutation is introduced. - -### Acceptance Criteria - -✓ `appends runtime init` — session initialization appends one `brunch.agent_runtime_state` entry when no valid runtime state exists. -✓ `appends runtime switch` — a Brunch helper/command appends a `brunch.agent_runtime_state` snapshot with `reason: "switch"`, previous state, source metadata, and validated `operationalMode` / `agentRole` / `agentStrategy` / `agentLens` fields. -✓ `projects latest runtime state` — projection reconstructs and resolves the active mode/role/strategy/lens from the latest valid full-state snapshot. -✓ `updates chrome input only when producers exist` — chrome/status may consume projected active lens/strategy, but no speculative worker/coherence/offer state is fabricated. -✓ `no persistent strategy menu` — no default exhaustive lens/strategy chooser is added to idle UI. - -### Verification Approach - -- Inner: unit tests — append payload shape, registry validation, projection last-valid-snapshot wins, invalid combination rejection. -- Middle: JSONL reload/projection test — selected runtime-state snapshots survive reload and resolve active mode/role/strategy/lens. -- Outer: optional manual TUI/RPC smoke — explicit switch command/helper is inspectable in transcript and reflected in status/chrome where currently wired. - -### Cross-cutting obligations - -- Preserve D25-L: lens is metadata within the `elicitor` role, not an agent role or operational mode. -- Preserve D32-L: establishment offers remain orientation artifacts, not a default next-action menu. -- Do not introduce graph writes or observer/reviewer routing behavior in this card; only provide the transcript-backed state seam. diff --git a/memory/PLAN.md b/memory/PLAN.md index ab7539fa..02393594 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -118,12 +118,12 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Status:** not-started - **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into flat product modules under `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. - **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, observer/reviewer jobs, and authority gating depend on the embedded harness. -- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Transcript entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch` can be appended by Brunch commands and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. - **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) -- **Current execution pointer:** product extension/component port queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, settings/menu switching, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; duplicate project-local Pi probe runtime files and package/tooling references were retired. Next scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage before adding runtime-bundle switch entries. +- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Next scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. ### graph-data-plane diff --git a/memory/SPEC.md b/memory/SPEC.md index 24e863c3..4457f131 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -122,7 +122,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. -- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Slash/key commands append product custom entries such as `brunch.runtime_init`, `brunch.runtime_switch`, `brunch.strategy_switch`, and `brunch.lens_switch`; turn preparation projects the latest linear transcript state into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. From 7b5d1803a758dd0edfbcf25977a48f459b9f428e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:11:59 +0200 Subject: [PATCH 056/164] config housekeeping --- .pi/components/.gitkeep | 0 .pi/extensions/.gitkeep | 0 @types/oxfmt_configuration_schema.json | 648 ++++++++++++++++++++++++ @types/oxlint_configuration_schema.json | 554 ++++++++++++++++++++ AGENTS.md | 2 +- 5 files changed, 1203 insertions(+), 1 deletion(-) create mode 100644 .pi/components/.gitkeep create mode 100644 .pi/extensions/.gitkeep create mode 100644 @types/oxfmt_configuration_schema.json create mode 100644 @types/oxlint_configuration_schema.json diff --git a/.pi/components/.gitkeep b/.pi/components/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.pi/extensions/.gitkeep b/.pi/extensions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/@types/oxfmt_configuration_schema.json b/@types/oxfmt_configuration_schema.json new file mode 100644 index 00000000..ee3ded8a --- /dev/null +++ b/@types/oxfmt_configuration_schema.json @@ -0,0 +1,648 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Oxfmtrc", + "description": "Configuration options for the Oxfmt.\n\nMost options are the same as Prettier's options, but not all of them.\nIn addition, some options are our own extensions.", + "type": "object", + "properties": { + "arrowParens": { + "description": "Include parentheses around a sole arrow function parameter.\n\n- Default: `\"always\"`", + "allOf": [ + { + "$ref": "#/definitions/ArrowParensConfig" + } + ], + "markdownDescription": "Include parentheses around a sole arrow function parameter.\n\n- Default: `\"always\"`" + }, + "bracketSameLine": { + "description": "Put the `>` of a multi-line HTML (HTML, JSX, Vue, Angular) element at the end of the last line,\ninstead of being alone on the next line (does not apply to self closing elements).\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Put the `>` of a multi-line HTML (HTML, JSX, Vue, Angular) element at the end of the last line,\ninstead of being alone on the next line (does not apply to self closing elements).\n\n- Default: `false`" + }, + "bracketSpacing": { + "description": "Print spaces between brackets in object literals.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Print spaces between brackets in object literals.\n\n- Default: `true`" + }, + "embeddedLanguageFormatting": { + "description": "Control whether to format embedded parts (For example, CSS-in-JS, or JS-in-Vue, etc.) in the file.\n\nNOTE: XXX-in-JS support is incomplete.\n\n- Default: `\"auto\"`", + "allOf": [ + { + "$ref": "#/definitions/EmbeddedLanguageFormattingConfig" + } + ], + "markdownDescription": "Control whether to format embedded parts (For example, CSS-in-JS, or JS-in-Vue, etc.) in the file.\n\nNOTE: XXX-in-JS support is incomplete.\n\n- Default: `\"auto\"`" + }, + "endOfLine": { + "description": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`", + "allOf": [ + { + "$ref": "#/definitions/EndOfLineConfig" + } + ], + "markdownDescription": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`" + }, + "htmlWhitespaceSensitivity": { + "description": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Default: `\"css\"`", + "allOf": [ + { + "$ref": "#/definitions/HtmlWhitespaceSensitivityConfig" + } + ], + "markdownDescription": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Default: `\"css\"`" + }, + "ignorePatterns": { + "description": "Ignore files matching these glob patterns.\nPatterns are based on the location of the Oxfmt configuration file.\n\n- Default: `[]`", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Ignore files matching these glob patterns.\nPatterns are based on the location of the Oxfmt configuration file.\n\n- Default: `[]`" + }, + "insertFinalNewline": { + "description": "Whether to insert a final newline at the end of the file.\n\n- Default: `true`\n- Overrides `.editorconfig.insert_final_newline`", + "type": "boolean", + "markdownDescription": "Whether to insert a final newline at the end of the file.\n\n- Default: `true`\n- Overrides `.editorconfig.insert_final_newline`" + }, + "jsxSingleQuote": { + "description": "Use single quotes instead of double quotes in JSX.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Use single quotes instead of double quotes in JSX.\n\n- Default: `false`" + }, + "objectWrap": { + "description": "How to wrap object literals when they could fit on one line or span multiple lines.\n\nBy default, formats objects as multi-line if there is a newline prior to the first property.\nAuthors can use this heuristic to contextually improve readability, though it has some downsides.\n\n- Default: `\"preserve\"`", + "allOf": [ + { + "$ref": "#/definitions/ObjectWrapConfig" + } + ], + "markdownDescription": "How to wrap object literals when they could fit on one line or span multiple lines.\n\nBy default, formats objects as multi-line if there is a newline prior to the first property.\nAuthors can use this heuristic to contextually improve readability, though it has some downsides.\n\n- Default: `\"preserve\"`" + }, + "overrides": { + "description": "File-specific overrides.\nWhen a file matches multiple overrides, the later override takes precedence (array order matters).\n\n- Default: `[]`", + "type": "array", + "items": { + "$ref": "#/definitions/OxfmtOverrideConfig" + }, + "markdownDescription": "File-specific overrides.\nWhen a file matches multiple overrides, the later override takes precedence (array order matters).\n\n- Default: `[]`" + }, + "printWidth": { + "description": "Specify the line length that the printer will wrap on.\n\nIf you don't want line wrapping when formatting Markdown, you can set the `proseWrap` option to disable it.\n\n- Default: `100`\n- Overrides `.editorconfig.max_line_length`", + "type": "integer", + "format": "uint16", + "minimum": 0.0, + "markdownDescription": "Specify the line length that the printer will wrap on.\n\nIf you don't want line wrapping when formatting Markdown, you can set the `proseWrap` option to disable it.\n\n- Default: `100`\n- Overrides `.editorconfig.max_line_length`" + }, + "proseWrap": { + "description": "How to wrap prose.\n\nBy default, formatter will not change wrapping in markdown text since some services use a linebreak-sensitive renderer, e.g. GitHub comments and BitBucket.\nTo wrap prose to the print width, change this option to \"always\".\nIf you want to force all prose blocks to be on a single line and rely on editor/viewer soft wrapping instead, you can use \"never\".\n\n- Default: `\"preserve\"`", + "allOf": [ + { + "$ref": "#/definitions/ProseWrapConfig" + } + ], + "markdownDescription": "How to wrap prose.\n\nBy default, formatter will not change wrapping in markdown text since some services use a linebreak-sensitive renderer, e.g. GitHub comments and BitBucket.\nTo wrap prose to the print width, change this option to \"always\".\nIf you want to force all prose blocks to be on a single line and rely on editor/viewer soft wrapping instead, you can use \"never\".\n\n- Default: `\"preserve\"`" + }, + "quoteProps": { + "description": "Change when properties in objects are quoted.\n\n- Default: `\"as-needed\"`", + "allOf": [ + { + "$ref": "#/definitions/QuotePropsConfig" + } + ], + "markdownDescription": "Change when properties in objects are quoted.\n\n- Default: `\"as-needed\"`" + }, + "semi": { + "description": "Print semicolons at the ends of statements.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Print semicolons at the ends of statements.\n\n- Default: `true`" + }, + "singleAttributePerLine": { + "description": "Enforce single attribute per line in HTML, Vue, and JSX.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Enforce single attribute per line in HTML, Vue, and JSX.\n\n- Default: `false`" + }, + "singleQuote": { + "description": "Use single quotes instead of double quotes.\n\nFor JSX, you can set the `jsxSingleQuote` option.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Use single quotes instead of double quotes.\n\nFor JSX, you can set the `jsxSingleQuote` option.\n\n- Default: `false`" + }, + "sortImports": { + "description": "Sort import statements.\n\nUsing the similar algorithm as [eslint-plugin-perfectionist/sort-imports](https://perfectionist.dev/rules/sort-imports).\nFor details, see each field's documentation.\n\n- Default: Disabled", + "allOf": [ + { + "$ref": "#/definitions/SortImportsConfig" + } + ], + "markdownDescription": "Sort import statements.\n\nUsing the similar algorithm as [eslint-plugin-perfectionist/sort-imports](https://perfectionist.dev/rules/sort-imports).\nFor details, see each field's documentation.\n\n- Default: Disabled" + }, + "sortPackageJson": { + "description": "Sort `package.json` keys.\n\nThe algorithm is NOT compatible with [prettier-plugin-sort-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson).\nBut we believe it is clearer and easier to navigate.\nFor details, see each field's documentation.\n\n- Default: `true`", + "allOf": [ + { + "$ref": "#/definitions/SortPackageJsonUserConfig" + } + ], + "markdownDescription": "Sort `package.json` keys.\n\nThe algorithm is NOT compatible with [prettier-plugin-sort-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson).\nBut we believe it is clearer and easier to navigate.\nFor details, see each field's documentation.\n\n- Default: `true`" + }, + "sortTailwindcss": { + "description": "Sort Tailwind CSS classes.\n\nUsing the same algorithm as [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss).\nOption names omit the `tailwind` prefix used in the original plugin (e.g., `config` instead of `tailwindConfig`).\nFor details, see each field's documentation.\n\n- Default: Disabled", + "allOf": [ + { + "$ref": "#/definitions/SortTailwindcssConfig" + } + ], + "markdownDescription": "Sort Tailwind CSS classes.\n\nUsing the same algorithm as [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss).\nOption names omit the `tailwind` prefix used in the original plugin (e.g., `config` instead of `tailwindConfig`).\nFor details, see each field's documentation.\n\n- Default: Disabled" + }, + "tabWidth": { + "description": "Specify the number of spaces per indentation-level.\n\n- Default: `2`\n- Overrides `.editorconfig.indent_size`", + "type": "integer", + "format": "uint8", + "minimum": 0.0, + "markdownDescription": "Specify the number of spaces per indentation-level.\n\n- Default: `2`\n- Overrides `.editorconfig.indent_size`" + }, + "trailingComma": { + "description": "Print trailing commas wherever possible in multi-line comma-separated syntactic structures.\n\nA single-line array, for example, never gets trailing commas.\n\n- Default: `\"all\"`", + "allOf": [ + { + "$ref": "#/definitions/TrailingCommaConfig" + } + ], + "markdownDescription": "Print trailing commas wherever possible in multi-line comma-separated syntactic structures.\n\nA single-line array, for example, never gets trailing commas.\n\n- Default: `\"all\"`" + }, + "useTabs": { + "description": "Indent lines with tabs instead of spaces.\n\n- Default: `false`\n- Overrides `.editorconfig.indent_style`", + "type": "boolean", + "markdownDescription": "Indent lines with tabs instead of spaces.\n\n- Default: `false`\n- Overrides `.editorconfig.indent_style`" + }, + "vueIndentScriptAndStyle": { + "description": "Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.\n\n- Default: `false`" + } + }, + "allowComments": true, + "allowTrailingCommas": true, + "definitions": { + "ArrowParensConfig": { + "type": "string", + "enum": [ + "always", + "avoid" + ] + }, + "CustomGroupItemConfig": { + "type": "object", + "properties": { + "elementNamePattern": { + "description": "List of glob patterns to match import sources for this group.", + "default": [], + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "List of glob patterns to match import sources for this group." + }, + "groupName": { + "description": "Name of the custom group, used in the `groups` option.", + "default": "", + "type": "string", + "markdownDescription": "Name of the custom group, used in the `groups` option." + }, + "modifiers": { + "description": "Modifiers to match the import characteristics.\nAll specified modifiers must be present (AND logic).\n\nPossible values: `\"side_effect\"`, `\"type\"`, `\"value\"`, `\"default\"`, `\"wildcard\"`, `\"named\"`", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Modifiers to match the import characteristics.\nAll specified modifiers must be present (AND logic).\n\nPossible values: `\"side_effect\"`, `\"type\"`, `\"value\"`, `\"default\"`, `\"wildcard\"`, `\"named\"`" + }, + "selector": { + "description": "Selector to match the import kind.\n\nPossible values: `\"type\"`, `\"side_effect_style\"`, `\"side_effect\"`, `\"style\"`, `\"index\"`,\n`\"sibling\"`, `\"parent\"`, `\"subpath\"`, `\"internal\"`, `\"builtin\"`, `\"external\"`, `\"import\"`", + "type": "string", + "markdownDescription": "Selector to match the import kind.\n\nPossible values: `\"type\"`, `\"side_effect_style\"`, `\"side_effect\"`, `\"style\"`, `\"index\"`,\n`\"sibling\"`, `\"parent\"`, `\"subpath\"`, `\"internal\"`, `\"builtin\"`, `\"external\"`, `\"import\"`" + } + } + }, + "EmbeddedLanguageFormattingConfig": { + "type": "string", + "enum": [ + "auto", + "off" + ] + }, + "EndOfLineConfig": { + "type": "string", + "enum": [ + "lf", + "crlf", + "cr" + ] + }, + "FormatConfig": { + "type": "object", + "properties": { + "arrowParens": { + "description": "Include parentheses around a sole arrow function parameter.\n\n- Default: `\"always\"`", + "allOf": [ + { + "$ref": "#/definitions/ArrowParensConfig" + } + ], + "markdownDescription": "Include parentheses around a sole arrow function parameter.\n\n- Default: `\"always\"`" + }, + "bracketSameLine": { + "description": "Put the `>` of a multi-line HTML (HTML, JSX, Vue, Angular) element at the end of the last line,\ninstead of being alone on the next line (does not apply to self closing elements).\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Put the `>` of a multi-line HTML (HTML, JSX, Vue, Angular) element at the end of the last line,\ninstead of being alone on the next line (does not apply to self closing elements).\n\n- Default: `false`" + }, + "bracketSpacing": { + "description": "Print spaces between brackets in object literals.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Print spaces between brackets in object literals.\n\n- Default: `true`" + }, + "embeddedLanguageFormatting": { + "description": "Control whether to format embedded parts (For example, CSS-in-JS, or JS-in-Vue, etc.) in the file.\n\nNOTE: XXX-in-JS support is incomplete.\n\n- Default: `\"auto\"`", + "allOf": [ + { + "$ref": "#/definitions/EmbeddedLanguageFormattingConfig" + } + ], + "markdownDescription": "Control whether to format embedded parts (For example, CSS-in-JS, or JS-in-Vue, etc.) in the file.\n\nNOTE: XXX-in-JS support is incomplete.\n\n- Default: `\"auto\"`" + }, + "endOfLine": { + "description": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`", + "allOf": [ + { + "$ref": "#/definitions/EndOfLineConfig" + } + ], + "markdownDescription": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`" + }, + "htmlWhitespaceSensitivity": { + "description": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Default: `\"css\"`", + "allOf": [ + { + "$ref": "#/definitions/HtmlWhitespaceSensitivityConfig" + } + ], + "markdownDescription": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Default: `\"css\"`" + }, + "insertFinalNewline": { + "description": "Whether to insert a final newline at the end of the file.\n\n- Default: `true`\n- Overrides `.editorconfig.insert_final_newline`", + "type": "boolean", + "markdownDescription": "Whether to insert a final newline at the end of the file.\n\n- Default: `true`\n- Overrides `.editorconfig.insert_final_newline`" + }, + "jsxSingleQuote": { + "description": "Use single quotes instead of double quotes in JSX.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Use single quotes instead of double quotes in JSX.\n\n- Default: `false`" + }, + "objectWrap": { + "description": "How to wrap object literals when they could fit on one line or span multiple lines.\n\nBy default, formats objects as multi-line if there is a newline prior to the first property.\nAuthors can use this heuristic to contextually improve readability, though it has some downsides.\n\n- Default: `\"preserve\"`", + "allOf": [ + { + "$ref": "#/definitions/ObjectWrapConfig" + } + ], + "markdownDescription": "How to wrap object literals when they could fit on one line or span multiple lines.\n\nBy default, formats objects as multi-line if there is a newline prior to the first property.\nAuthors can use this heuristic to contextually improve readability, though it has some downsides.\n\n- Default: `\"preserve\"`" + }, + "printWidth": { + "description": "Specify the line length that the printer will wrap on.\n\nIf you don't want line wrapping when formatting Markdown, you can set the `proseWrap` option to disable it.\n\n- Default: `100`\n- Overrides `.editorconfig.max_line_length`", + "type": "integer", + "format": "uint16", + "minimum": 0.0, + "markdownDescription": "Specify the line length that the printer will wrap on.\n\nIf you don't want line wrapping when formatting Markdown, you can set the `proseWrap` option to disable it.\n\n- Default: `100`\n- Overrides `.editorconfig.max_line_length`" + }, + "proseWrap": { + "description": "How to wrap prose.\n\nBy default, formatter will not change wrapping in markdown text since some services use a linebreak-sensitive renderer, e.g. GitHub comments and BitBucket.\nTo wrap prose to the print width, change this option to \"always\".\nIf you want to force all prose blocks to be on a single line and rely on editor/viewer soft wrapping instead, you can use \"never\".\n\n- Default: `\"preserve\"`", + "allOf": [ + { + "$ref": "#/definitions/ProseWrapConfig" + } + ], + "markdownDescription": "How to wrap prose.\n\nBy default, formatter will not change wrapping in markdown text since some services use a linebreak-sensitive renderer, e.g. GitHub comments and BitBucket.\nTo wrap prose to the print width, change this option to \"always\".\nIf you want to force all prose blocks to be on a single line and rely on editor/viewer soft wrapping instead, you can use \"never\".\n\n- Default: `\"preserve\"`" + }, + "quoteProps": { + "description": "Change when properties in objects are quoted.\n\n- Default: `\"as-needed\"`", + "allOf": [ + { + "$ref": "#/definitions/QuotePropsConfig" + } + ], + "markdownDescription": "Change when properties in objects are quoted.\n\n- Default: `\"as-needed\"`" + }, + "semi": { + "description": "Print semicolons at the ends of statements.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Print semicolons at the ends of statements.\n\n- Default: `true`" + }, + "singleAttributePerLine": { + "description": "Enforce single attribute per line in HTML, Vue, and JSX.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Enforce single attribute per line in HTML, Vue, and JSX.\n\n- Default: `false`" + }, + "singleQuote": { + "description": "Use single quotes instead of double quotes.\n\nFor JSX, you can set the `jsxSingleQuote` option.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Use single quotes instead of double quotes.\n\nFor JSX, you can set the `jsxSingleQuote` option.\n\n- Default: `false`" + }, + "sortImports": { + "description": "Sort import statements.\n\nUsing the similar algorithm as [eslint-plugin-perfectionist/sort-imports](https://perfectionist.dev/rules/sort-imports).\nFor details, see each field's documentation.\n\n- Default: Disabled", + "allOf": [ + { + "$ref": "#/definitions/SortImportsConfig" + } + ], + "markdownDescription": "Sort import statements.\n\nUsing the similar algorithm as [eslint-plugin-perfectionist/sort-imports](https://perfectionist.dev/rules/sort-imports).\nFor details, see each field's documentation.\n\n- Default: Disabled" + }, + "sortPackageJson": { + "description": "Sort `package.json` keys.\n\nThe algorithm is NOT compatible with [prettier-plugin-sort-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson).\nBut we believe it is clearer and easier to navigate.\nFor details, see each field's documentation.\n\n- Default: `true`", + "allOf": [ + { + "$ref": "#/definitions/SortPackageJsonUserConfig" + } + ], + "markdownDescription": "Sort `package.json` keys.\n\nThe algorithm is NOT compatible with [prettier-plugin-sort-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson).\nBut we believe it is clearer and easier to navigate.\nFor details, see each field's documentation.\n\n- Default: `true`" + }, + "sortTailwindcss": { + "description": "Sort Tailwind CSS classes.\n\nUsing the same algorithm as [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss).\nOption names omit the `tailwind` prefix used in the original plugin (e.g., `config` instead of `tailwindConfig`).\nFor details, see each field's documentation.\n\n- Default: Disabled", + "allOf": [ + { + "$ref": "#/definitions/SortTailwindcssConfig" + } + ], + "markdownDescription": "Sort Tailwind CSS classes.\n\nUsing the same algorithm as [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss).\nOption names omit the `tailwind` prefix used in the original plugin (e.g., `config` instead of `tailwindConfig`).\nFor details, see each field's documentation.\n\n- Default: Disabled" + }, + "tabWidth": { + "description": "Specify the number of spaces per indentation-level.\n\n- Default: `2`\n- Overrides `.editorconfig.indent_size`", + "type": "integer", + "format": "uint8", + "minimum": 0.0, + "markdownDescription": "Specify the number of spaces per indentation-level.\n\n- Default: `2`\n- Overrides `.editorconfig.indent_size`" + }, + "trailingComma": { + "description": "Print trailing commas wherever possible in multi-line comma-separated syntactic structures.\n\nA single-line array, for example, never gets trailing commas.\n\n- Default: `\"all\"`", + "allOf": [ + { + "$ref": "#/definitions/TrailingCommaConfig" + } + ], + "markdownDescription": "Print trailing commas wherever possible in multi-line comma-separated syntactic structures.\n\nA single-line array, for example, never gets trailing commas.\n\n- Default: `\"all\"`" + }, + "useTabs": { + "description": "Indent lines with tabs instead of spaces.\n\n- Default: `false`\n- Overrides `.editorconfig.indent_style`", + "type": "boolean", + "markdownDescription": "Indent lines with tabs instead of spaces.\n\n- Default: `false`\n- Overrides `.editorconfig.indent_style`" + }, + "vueIndentScriptAndStyle": { + "description": "Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Whether or not to indent the code inside `<script>` and `<style>` tags in Vue files.\n\n- Default: `false`" + } + } + }, + "HtmlWhitespaceSensitivityConfig": { + "type": "string", + "enum": [ + "css", + "strict", + "ignore" + ] + }, + "NewlinesBetweenMarker": { + "description": "A marker object for overriding `newlinesBetween` at a specific group boundary.", + "type": "object", + "required": [ + "newlinesBetween" + ], + "properties": { + "newlinesBetween": { + "type": "boolean" + } + }, + "markdownDescription": "A marker object for overriding `newlinesBetween` at a specific group boundary." + }, + "ObjectWrapConfig": { + "type": "string", + "enum": [ + "preserve", + "collapse" + ] + }, + "OxfmtOverrideConfig": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "excludeFiles": { + "description": "Glob patterns to exclude from this override.", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Glob patterns to exclude from this override." + }, + "files": { + "description": "Glob patterns to match files for this override.\nAll patterns are relative to the Oxfmt configuration file.", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Glob patterns to match files for this override.\nAll patterns are relative to the Oxfmt configuration file." + }, + "options": { + "description": "Format options to apply for matched files.", + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/FormatConfig" + } + ], + "markdownDescription": "Format options to apply for matched files." + } + } + }, + "ProseWrapConfig": { + "type": "string", + "enum": [ + "always", + "never", + "preserve" + ] + }, + "QuotePropsConfig": { + "type": "string", + "enum": [ + "as-needed", + "consistent", + "preserve" + ] + }, + "SortGroupItemConfig": { + "anyOf": [ + { + "description": "A `{ \"newlinesBetween\": bool }` marker object that overrides the global `newlinesBetween`\nsetting for the boundary between the previous and next groups.", + "allOf": [ + { + "$ref": "#/definitions/NewlinesBetweenMarker" + } + ], + "markdownDescription": "A `{ \"newlinesBetween\": bool }` marker object that overrides the global `newlinesBetween`\nsetting for the boundary between the previous and next groups." + }, + { + "description": "A single group name string (e.g. `\"value-builtin\"`).", + "type": "string", + "markdownDescription": "A single group name string (e.g. `\"value-builtin\"`)." + }, + { + "description": "Multiple group names treated as one group (e.g. `[\"value-builtin\", \"value-external\"]`).", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Multiple group names treated as one group (e.g. `[\"value-builtin\", \"value-external\"]`)." + } + ] + }, + "SortImportsConfig": { + "type": "object", + "properties": { + "customGroups": { + "description": "Define your own groups for matching very specific imports.\n\nThe `customGroups` list is ordered: The first definition that matches an element will be used.\nCustom groups have a higher priority than any predefined group.\n\nIf you want a predefined group to take precedence over a custom group,\nyou must write a custom group definition that does the same as what the predefined group does, and put it first in the list.\n\nIf you specify multiple conditions like `elementNamePattern`, `selector`, and `modifiers`,\nall conditions must be met for an import to match the custom group (AND logic).\n\n- Default: `[]`", + "type": "array", + "items": { + "$ref": "#/definitions/CustomGroupItemConfig" + }, + "markdownDescription": "Define your own groups for matching very specific imports.\n\nThe `customGroups` list is ordered: The first definition that matches an element will be used.\nCustom groups have a higher priority than any predefined group.\n\nIf you want a predefined group to take precedence over a custom group,\nyou must write a custom group definition that does the same as what the predefined group does, and put it first in the list.\n\nIf you specify multiple conditions like `elementNamePattern`, `selector`, and `modifiers`,\nall conditions must be met for an import to match the custom group (AND logic).\n\n- Default: `[]`" + }, + "groups": { + "description": "Specifies a list of predefined import groups for sorting.\n\nEach import will be assigned a single group specified in the groups option (or the `unknown` group if no match is found).\nThe order of items in the `groups` option determines how groups are ordered.\n\nWithin a given group, members will be sorted according to the type, order, ignoreCase, etc. options.\n\nIndividual groups can be combined together by placing them in an array.\nThe order of groups in that array does not matter.\nAll members of the groups in the array will be sorted together as if they were part of a single group.\n\nPredefined groups are characterized by a single selector and potentially multiple modifiers.\nYou may enter modifiers in any order, but the selector must always come at the end.\n\nThe list of selectors is sorted from most to least important:\n- `type` — TypeScript type imports.\n- `side_effect_style` — Side effect style imports.\n- `side_effect` — Side effect imports.\n- `style` — Style imports.\n- `index` — Main file from the current directory.\n- `sibling` — Modules from the same directory.\n- `parent` — Modules from the parent directory.\n- `subpath` — Node.js subpath imports.\n- `internal` — Your internal modules.\n- `builtin` — Node.js Built-in Modules.\n- `external` — External modules installed in the project.\n- `import` — Any import.\n\nThe list of modifiers is sorted from most to least important:\n- `side_effect` — Side effect imports.\n- `type` — TypeScript type imports.\n- `value` — Value imports.\n- `default` — Imports containing the default specifier.\n- `wildcard` — Imports containing the wildcard (`* as`) specifier.\n- `named` — Imports containing at least one named specifier.\n\n- Default: See below\n```json\n[\n\"builtin\",\n\"external\",\n[\"internal\", \"subpath\"],\n[\"parent\", \"sibling\", \"index\"],\n\"style\",\n\"unknown\"\n]\n```\n\nAlso, you can override the global `newlinesBetween` setting for specific group boundaries\nby including a `{ \"newlinesBetween\": boolean }` marker object in the `groups` list at the desired position.", + "type": "array", + "items": { + "$ref": "#/definitions/SortGroupItemConfig" + }, + "markdownDescription": "Specifies a list of predefined import groups for sorting.\n\nEach import will be assigned a single group specified in the groups option (or the `unknown` group if no match is found).\nThe order of items in the `groups` option determines how groups are ordered.\n\nWithin a given group, members will be sorted according to the type, order, ignoreCase, etc. options.\n\nIndividual groups can be combined together by placing them in an array.\nThe order of groups in that array does not matter.\nAll members of the groups in the array will be sorted together as if they were part of a single group.\n\nPredefined groups are characterized by a single selector and potentially multiple modifiers.\nYou may enter modifiers in any order, but the selector must always come at the end.\n\nThe list of selectors is sorted from most to least important:\n- `type` — TypeScript type imports.\n- `side_effect_style` — Side effect style imports.\n- `side_effect` — Side effect imports.\n- `style` — Style imports.\n- `index` — Main file from the current directory.\n- `sibling` — Modules from the same directory.\n- `parent` — Modules from the parent directory.\n- `subpath` — Node.js subpath imports.\n- `internal` — Your internal modules.\n- `builtin` — Node.js Built-in Modules.\n- `external` — External modules installed in the project.\n- `import` — Any import.\n\nThe list of modifiers is sorted from most to least important:\n- `side_effect` — Side effect imports.\n- `type` — TypeScript type imports.\n- `value` — Value imports.\n- `default` — Imports containing the default specifier.\n- `wildcard` — Imports containing the wildcard (`* as`) specifier.\n- `named` — Imports containing at least one named specifier.\n\n- Default: See below\n```json\n[\n\"builtin\",\n\"external\",\n[\"internal\", \"subpath\"],\n[\"parent\", \"sibling\", \"index\"],\n\"style\",\n\"unknown\"\n]\n```\n\nAlso, you can override the global `newlinesBetween` setting for specific group boundaries\nby including a `{ \"newlinesBetween\": boolean }` marker object in the `groups` list at the desired position." + }, + "ignoreCase": { + "description": "Specifies whether sorting should be case-sensitive.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Specifies whether sorting should be case-sensitive.\n\n- Default: `true`" + }, + "internalPattern": { + "description": "Specifies a prefix for identifying internal imports.\n\nThis is useful for distinguishing your own modules from external dependencies.\n\n- Default: `[\"~/\", \"@/\"]`", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "Specifies a prefix for identifying internal imports.\n\nThis is useful for distinguishing your own modules from external dependencies.\n\n- Default: `[\"~/\", \"@/\"]`" + }, + "newlinesBetween": { + "description": "Specifies whether to add newlines between groups.\n\nWhen `false`, no newlines are added between groups.\n\n- Default: `true`", + "type": "boolean", + "markdownDescription": "Specifies whether to add newlines between groups.\n\nWhen `false`, no newlines are added between groups.\n\n- Default: `true`" + }, + "order": { + "description": "Specifies whether to sort items in ascending or descending order.\n\n- Default: `\"asc\"`", + "allOf": [ + { + "$ref": "#/definitions/SortOrderConfig" + } + ], + "markdownDescription": "Specifies whether to sort items in ascending or descending order.\n\n- Default: `\"asc\"`" + }, + "partitionByComment": { + "description": "Enables the use of comments to separate imports into logical groups.\n\nWhen `true`, all comments will be treated as delimiters, creating partitions.\n\n```js\nimport { b1, b2 } from 'b'\n// PARTITION\nimport { a } from 'a'\nimport { c } from 'c'\n```\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Enables the use of comments to separate imports into logical groups.\n\nWhen `true`, all comments will be treated as delimiters, creating partitions.\n\n```js\nimport { b1, b2 } from 'b'\n// PARTITION\nimport { a } from 'a'\nimport { c } from 'c'\n```\n\n- Default: `false`" + }, + "partitionByNewline": { + "description": "Enables the empty line to separate imports into logical groups.\n\nWhen `true`, formatter will not sort imports if there is an empty line between them.\nThis helps maintain the defined order of logically separated groups of members.\n\n```js\nimport { b1, b2 } from 'b'\n\nimport { a } from 'a'\nimport { c } from 'c'\n```\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Enables the empty line to separate imports into logical groups.\n\nWhen `true`, formatter will not sort imports if there is an empty line between them.\nThis helps maintain the defined order of logically separated groups of members.\n\n```js\nimport { b1, b2 } from 'b'\n\nimport { a } from 'a'\nimport { c } from 'c'\n```\n\n- Default: `false`" + }, + "sortSideEffects": { + "description": "Specifies whether side effect imports should be sorted.\n\nBy default, sorting side-effect imports is disabled for security reasons.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Specifies whether side effect imports should be sorted.\n\nBy default, sorting side-effect imports is disabled for security reasons.\n\n- Default: `false`" + } + } + }, + "SortOrderConfig": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "SortPackageJsonConfig": { + "type": "object", + "properties": { + "sortScripts": { + "description": "Sort the `scripts` field alphabetically.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Sort the `scripts` field alphabetically.\n\n- Default: `false`" + } + } + }, + "SortPackageJsonUserConfig": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/SortPackageJsonConfig" + } + ] + }, + "SortTailwindcssConfig": { + "type": "object", + "properties": { + "attributes": { + "description": "List of additional attributes to sort beyond `class` and `className` (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- Default: `[]`\n- Example: `[\"myClassProp\", \":class\"]`", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "List of additional attributes to sort beyond `class` and `className` (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- Default: `[]`\n- Example: `[\"myClassProp\", \":class\"]`" + }, + "config": { + "description": "Path to your Tailwind CSS configuration file (v3).\n\nNOTE: Paths are resolved relative to the Oxfmt configuration file.\n\n- Default: Automatically find `\"tailwind.config.js\"`", + "type": "string", + "markdownDescription": "Path to your Tailwind CSS configuration file (v3).\n\nNOTE: Paths are resolved relative to the Oxfmt configuration file.\n\n- Default: Automatically find `\"tailwind.config.js\"`" + }, + "functions": { + "description": "List of custom function names whose arguments should be sorted (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- Default: `[]`\n- Example: `[\"clsx\", \"cn\", \"cva\", \"tw\"]`", + "type": "array", + "items": { + "type": "string" + }, + "markdownDescription": "List of custom function names whose arguments should be sorted (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- Default: `[]`\n- Example: `[\"clsx\", \"cn\", \"cva\", \"tw\"]`" + }, + "preserveDuplicates": { + "description": "Preserve duplicate classes.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Preserve duplicate classes.\n\n- Default: `false`" + }, + "preserveWhitespace": { + "description": "Preserve whitespace around classes.\n\n- Default: `false`", + "type": "boolean", + "markdownDescription": "Preserve whitespace around classes.\n\n- Default: `false`" + }, + "stylesheet": { + "description": "Path to your Tailwind CSS stylesheet (v4).\n\nNOTE: Paths are resolved relative to the Oxfmt configuration file.\n\n- Default: Installed Tailwind CSS's `theme.css`", + "type": "string", + "markdownDescription": "Path to your Tailwind CSS stylesheet (v4).\n\nNOTE: Paths are resolved relative to the Oxfmt configuration file.\n\n- Default: Installed Tailwind CSS's `theme.css`" + } + } + }, + "TrailingCommaConfig": { + "type": "string", + "enum": [ + "all", + "es5", + "none" + ] + } + }, + "markdownDescription": "Configuration options for the Oxfmt.\n\nMost options are the same as Prettier's options, but not all of them.\nIn addition, some options are our own extensions." +} diff --git a/@types/oxlint_configuration_schema.json b/@types/oxlint_configuration_schema.json new file mode 100644 index 00000000..f5c144f7 --- /dev/null +++ b/@types/oxlint_configuration_schema.json @@ -0,0 +1,554 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Oxlintrc", + "description": "Oxlint Configuration File\n\nThis configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).\n\nUsage: `oxlint -c oxlintrc.json --import-plugin`\n\n::: danger NOTE\n\nOnly the `.json` format is supported. You can use comments in configuration files.\n\n:::\n\nExample\n\n`.oxlintrc.json`\n\n```json\n{\n\"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n\"plugins\": [\"import\", \"typescript\", \"unicorn\"],\n\"env\": {\n\"browser\": true\n},\n\"globals\": {\n\"foo\": \"readonly\"\n},\n\"settings\": {\n},\n\"rules\": {\n\"eqeqeq\": \"warn\",\n\"import/no-cycle\": \"error\",\n\"react/self-closing-comp\": [\"error\", { \"html\": false }]\n},\n\"overrides\": [\n{\n\"files\": [\"*.test.ts\", \"*.spec.ts\"],\n\"rules\": {\n\"@typescript-eslint/no-explicit-any\": \"off\"\n}\n}\n]\n}\n```", + "type": "object", + "properties": { + "categories": { + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintCategories" + } + ] + }, + "env": { + "description": "Environments enable and disable collections of global variables.", + "default": { + "builtin": true + }, + "allOf": [ + { + "$ref": "#/definitions/OxlintEnv" + } + ] + }, + "extends": { + "description": "Paths of configuration files that this configuration file extends (inherits from). The files\nare resolved relative to the location of the configuration file that contains the `extends`\nproperty. The configuration files are merged from the first to the last, with the last file\noverriding the previous ones.", + "type": "array", + "items": { + "type": "string" + } + }, + "globals": { + "description": "Enabled or disabled specific global variables.", + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintGlobals" + } + ] + }, + "ignorePatterns": { + "description": "Globs to ignore during linting. These are resolved from the configuration file path.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "overrides": { + "description": "Add, remove, or otherwise reconfigure rules for specific files or groups of files.", + "allOf": [ + { + "$ref": "#/definitions/OxlintOverrides" + } + ] + }, + "plugins": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/LintPlugins" + }, + { + "type": "null" + } + ] + }, + "rules": { + "description": "Example\n\n`.oxlintrc.json`\n\n```json\n{\n\"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n\"rules\": {\n\"eqeqeq\": \"warn\",\n\"import/no-cycle\": \"error\",\n\"prefer-const\": [\"error\", { \"ignoreReadBeforeAssign\": true }]\n}\n}\n```\n\nSee [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html) for the list of\nrules.", + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintRules" + } + ] + }, + "settings": { + "default": { + "jsx-a11y": { + "polymorphicPropName": null, + "components": {} + }, + "next": { + "rootDir": [] + }, + "react": { + "formComponents": [], + "linkComponents": [] + }, + "jsdoc": { + "ignorePrivate": false, + "ignoreInternal": false, + "ignoreReplacesDocs": true, + "overrideReplacesDocs": true, + "augmentsExtendsReplacesDocs": false, + "implementsReplacesDocs": false, + "exemptDestructuredRootsFromChecks": false, + "tagNamePreference": {} + } + }, + "allOf": [ + { + "$ref": "#/definitions/OxlintSettings" + } + ] + } + }, + "definitions": { + "AllowWarnDeny": { + "oneOf": [ + { + "description": "Oxlint rule.\n- \"allow\" or \"off\": Turn off the rule.\n- \"warn\": Turn the rule on as a warning (doesn't affect exit code).\n- \"error\" or \"deny\": Turn the rule on as an error (will exit with a failure code).", + "type": "string", + "enum": [ + "allow", + "off", + "warn", + "error", + "deny" + ] + }, + { + "description": "Oxlint rule.\n \n- 0: Turn off the rule.\n- 1: Turn the rule on as a warning (doesn't affect exit code).\n- 2: Turn the rule on as an error (will exit with a failure code).", + "type": "integer", + "format": "uint32", + "maximum": 2.0, + "minimum": 0.0 + } + ] + }, + "CustomComponent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "attribute", + "name" + ], + "properties": { + "attribute": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "attributes", + "name" + ], + "properties": { + "attributes": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + } + } + } + ] + }, + "DummyRule": { + "anyOf": [ + { + "$ref": "#/definitions/AllowWarnDeny" + }, + { + "type": "array", + "items": true + } + ] + }, + "DummyRuleMap": { + "description": "See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/DummyRule" + } + }, + "GlobSet": { + "type": "array", + "items": { + "type": "string" + } + }, + "GlobalValue": { + "type": "string", + "enum": [ + "readonly", + "writeable", + "off" + ] + }, + "JSDocPluginSettings": { + "type": "object", + "properties": { + "augmentsExtendsReplacesDocs": { + "description": "Only for `require-(yields|returns|description|example|param|throws)` rule", + "default": false, + "type": "boolean" + }, + "exemptDestructuredRootsFromChecks": { + "description": "Only for `require-param-type` and `require-param-description` rule", + "default": false, + "type": "boolean" + }, + "ignoreInternal": { + "description": "For all rules but NOT apply to `empty-tags` rule", + "default": false, + "type": "boolean" + }, + "ignorePrivate": { + "description": "For all rules but NOT apply to `check-access` and `empty-tags` rule", + "default": false, + "type": "boolean" + }, + "ignoreReplacesDocs": { + "description": "Only for `require-(yields|returns|description|example|param|throws)` rule", + "default": true, + "type": "boolean" + }, + "implementsReplacesDocs": { + "description": "Only for `require-(yields|returns|description|example|param|throws)` rule", + "default": false, + "type": "boolean" + }, + "overrideReplacesDocs": { + "description": "Only for `require-(yields|returns|description|example|param|throws)` rule", + "default": true, + "type": "boolean" + }, + "tagNamePreference": { + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/TagNamePreference" + } + } + } + }, + "JSXA11yPluginSettings": { + "description": "Configure JSX A11y plugin rules.\n\nSee\n[eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y#configurations)'s\nconfiguration for a full reference.", + "type": "object", + "properties": { + "components": { + "description": "To have your custom components be checked as DOM elements, you can\nprovide a mapping of your component names to the DOM element name.\n\nExample:\n\n```json\n{\n\"settings\": {\n\"jsx-a11y\": {\n\"components\": {\n\"Link\": \"a\",\n\"IconButton\": \"button\"\n}\n}\n}\n}\n```", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "polymorphicPropName": { + "description": "An optional setting that define the prop your code uses to create polymorphic components.\nThis setting will be used to determine the element type in rules that\nrequire semantic context.\n\nFor example, if you set the `polymorphicPropName` to `as`, then this element:\n\n```jsx\n<Box as=\"h3\">Hello</Box>\n```\n\nWill be treated as an `h3`. If not set, this component will be treated\nas a `Box`.", + "type": [ + "string", + "null" + ] + } + } + }, + "LintPluginOptionsSchema": { + "type": "string", + "enum": [ + "eslint", + "react", + "unicorn", + "typescript", + "oxc", + "import", + "jsdoc", + "jest", + "vitest", + "jsx-a11y", + "nextjs", + "react-perf", + "promise", + "node" + ] + }, + "LintPlugins": { + "type": "array", + "items": { + "$ref": "#/definitions/LintPluginOptionsSchema" + } + }, + "NextPluginSettings": { + "description": "Configure Next.js plugin rules.", + "type": "object", + "properties": { + "rootDir": { + "description": "The root directory of the Next.js project.\n\nThis is particularly useful when you have a monorepo and your Next.js\nproject is in a subfolder.\n\nExample:\n\n```json\n{\n\"settings\": {\n\"next\": {\n\"rootDir\": \"apps/dashboard/\"\n}\n}\n}\n```", + "default": [], + "allOf": [ + { + "$ref": "#/definitions/OneOrMany_for_String" + } + ] + } + } + }, + "OneOrMany_for_String": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "OxlintCategories": { + "title": "Rule Categories", + "description": "Configure an entire category of rules all at once.\n\nRules enabled or disabled this way will be overwritten by individual rules in the `rules` field.\n\nExample\n```json\n{\n \"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n \"categories\": {\n \"correctness\": \"warn\"\n },\n \"rules\": {\n \"eslint/no-unused-vars\": \"error\"\n }\n}\n```", + "examples": [ + { + "correctness": "warn" + } + ], + "type": "object", + "properties": { + "correctness": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "nursery": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "pedantic": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "perf": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "restriction": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "style": { + "$ref": "#/definitions/AllowWarnDeny" + }, + "suspicious": { + "$ref": "#/definitions/AllowWarnDeny" + } + } + }, + "OxlintEnv": { + "description": "Predefine global variables.\n\nEnvironments specify what global variables are predefined. See [ESLint's\nlist of\nenvironments](https://eslint.org/docs/v8.x/use/configure/language-options#specifying-environments)\nfor what environments are available and what each one provides.", + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "OxlintGlobals": { + "description": "Add or remove global variables.\n\nFor each global variable, set the corresponding value equal to `\"writable\"`\nto allow the variable to be overwritten or `\"readonly\"` to disallow overwriting.\n\nGlobals can be disabled by setting their value to `\"off\"`. For example, in\nan environment where most Es2015 globals are available but `Promise` is unavailable,\nyou might use this config:\n\n```json\n\n{\n\"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n\"env\": {\n\"es6\": true\n},\n\"globals\": {\n\"Promise\": \"off\"\n}\n}\n\n```\n\nYou may also use `\"readable\"` or `false` to represent `\"readonly\"`, and\n`\"writeable\"` or `true` to represent `\"writable\"`.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/GlobalValue" + } + }, + "OxlintOverride": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "env": { + "description": "Environments enable and disable collections of global variables.", + "anyOf": [ + { + "$ref": "#/definitions/OxlintEnv" + }, + { + "type": "null" + } + ] + }, + "files": { + "description": "A list of glob patterns to override.\n\n## Example\n`[ \"*.test.ts\", \"*.spec.ts\" ]`", + "allOf": [ + { + "$ref": "#/definitions/GlobSet" + } + ] + }, + "globals": { + "description": "Enabled or disabled specific global variables.", + "anyOf": [ + { + "$ref": "#/definitions/OxlintGlobals" + }, + { + "type": "null" + } + ] + }, + "plugins": { + "description": "Optionally change what plugins are enabled for this override. When\nomitted, the base config's plugins are used.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/LintPlugins" + }, + { + "type": "null" + } + ] + }, + "rules": { + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintRules" + } + ] + } + } + }, + "OxlintOverrides": { + "type": "array", + "items": { + "$ref": "#/definitions/OxlintOverride" + } + }, + "OxlintRules": { + "$ref": "#/definitions/DummyRuleMap" + }, + "OxlintSettings": { + "title": "Oxlint Plugin Settings", + "description": "Configure the behavior of linter plugins.\n\nHere's an example if you're using Next.js in a monorepo:\n\n```json\n{\n\"settings\": {\n\"next\": {\n\"rootDir\": \"apps/dashboard/\"\n},\n\"react\": {\n\"linkComponents\": [\n{ \"name\": \"Link\", \"linkAttribute\": \"to\" }\n]\n},\n\"jsx-a11y\": {\n\"components\": {\n\"Link\": \"a\",\n\"Button\": \"button\"\n}\n}\n}\n}\n```", + "type": "object", + "properties": { + "jsdoc": { + "default": { + "ignorePrivate": false, + "ignoreInternal": false, + "ignoreReplacesDocs": true, + "overrideReplacesDocs": true, + "augmentsExtendsReplacesDocs": false, + "implementsReplacesDocs": false, + "exemptDestructuredRootsFromChecks": false, + "tagNamePreference": {} + }, + "allOf": [ + { + "$ref": "#/definitions/JSDocPluginSettings" + } + ] + }, + "jsx-a11y": { + "default": { + "polymorphicPropName": null, + "components": {} + }, + "allOf": [ + { + "$ref": "#/definitions/JSXA11yPluginSettings" + } + ] + }, + "next": { + "default": { + "rootDir": [] + }, + "allOf": [ + { + "$ref": "#/definitions/NextPluginSettings" + } + ] + }, + "react": { + "default": { + "formComponents": [], + "linkComponents": [] + }, + "allOf": [ + { + "$ref": "#/definitions/ReactPluginSettings" + } + ] + } + } + }, + "ReactPluginSettings": { + "description": "Configure React plugin rules.\n\nDerived from [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react#configuration-legacy-eslintrc-)", + "type": "object", + "properties": { + "formComponents": { + "description": "Components used as alternatives to `<form>` for forms, such as `<Formik>`.\n\nExample:\n\n```jsonc\n{\n\"settings\": {\n\"react\": {\n\"formComponents\": [\n\"CustomForm\",\n// OtherForm is considered a form component and has an endpoint attribute\n{ \"name\": \"OtherForm\", \"formAttribute\": \"endpoint\" },\n// allows specifying multiple properties if necessary\n{ \"name\": \"Form\", \"formAttribute\": [\"registerEndpoint\", \"loginEndpoint\"] }\n]\n}\n}\n}\n```", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/CustomComponent" + } + }, + "linkComponents": { + "description": "Components used as alternatives to `<a>` for linking, such as `<Link>`.\n\nExample:\n\n```jsonc\n{\n\"settings\": {\n\"react\": {\n\"linkComponents\": [\n\"HyperLink\",\n// Use `linkAttribute` for components that use a different prop name\n// than `href`.\n{ \"name\": \"MyLink\", \"linkAttribute\": \"to\" },\n// allows specifying multiple properties if necessary\n{ \"name\": \"Link\", \"linkAttribute\": [\"to\", \"href\"] }\n]\n}\n}\n}\n```", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/CustomComponent" + } + } + } + }, + "TagNamePreference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "message", + "replacement" + ], + "properties": { + "message": { + "type": "string" + }, + "replacement": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + { + "type": "boolean" + } + ] + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0d5e630f..87cb1bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ Tooling: oxlint (lint + type-aware + type-check via tsgolint), oxfmt (format). V ## critical file-safety rule -Do not delete untracked files or directories without explicit user confirmation. This includes newly-created local files, ignored files, scratch directories, generated-looking folders, and empty placeholder directories. If cleanup seems appropriate, ask first and name the exact path(s) you propose to remove. +Do not delete untracked files or directories without explicit user confirmation. Do not overwrite, revert, reset, reformat, or otherwise clobber uncommitted changes unless you know they are yours from this session or the user explicitly approves. Treat any uncommitted work from the user or another agent as protected. This includes newly-created local files, ignored files, scratch directories, generated-looking folders, empty placeholder directories, and modified tracked files. If cleanup or rollback seems appropriate, ask first and name the exact path(s) and action you propose. ## operational protocols From f38d27eb82f42a7d2fd7a6bf6d5ac891ceac3892 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:54:44 +0200 Subject: [PATCH 057/164] WIP on workspace-dialog, essential style and flow is right --- .pi/extensions/brunch-menu.ts | 83 -------------- bin/brunch.js | 12 ++- package-lock.json | 6 +- src/brunch-tui.test.ts | 36 ++++++- .../workspace-dialog/component.ts | 102 +++++++++++++++--- src/pi-components/workspace-dialog/index.ts | 1 + .../workspace-dialog/preflight.ts | 62 ++++++++++- src/pi-extensions/workspace-dialog.ts | 15 ++- src/workspace-dialog.test.ts | 76 ++++++++++++- 9 files changed, 281 insertions(+), 112 deletions(-) delete mode 100644 .pi/extensions/brunch-menu.ts diff --git a/.pi/extensions/brunch-menu.ts b/.pi/extensions/brunch-menu.ts deleted file mode 100644 index 22ac01e4..00000000 --- a/.pi/extensions/brunch-menu.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Brunch workspace dialog demo extension. - * - * This project-local probe deliberately stays thin: the actual centered dialog - * lives in `src/pi-components/workspace-dialog`, so startup and in-session - * extension paths exercise the same pi-tui component. - */ - -import type { - ExtensionAPI, - ExtensionCommandContext, -} from "@earendil-works/pi-coding-agent" - -import { createWorkspaceDialogComponent } from "../../src/pi-components/workspace-dialog/index.js" -import { - createWorkspaceSessionCoordinator, - type WorkspaceSwitchDecision, -} from "../../src/workspace-session-coordinator.js" - -const COMMAND = "brunch-workspace-demo" -const SHORTCUT = "ctrl+shift+k" - -export default function brunchMenu(pi: ExtensionAPI) { - pi.registerCommand(COMMAND, { - description: "Open the shared Brunch workspace dialog demo", - handler: async (_args, ctx) => openWorkspaceDialog(ctx), - }) - pi.registerShortcut(SHORTCUT, { - description: "Open the shared Brunch workspace dialog demo", - handler: async (ctx) => openWorkspaceDialog(ctx as ExtensionCommandContext), - }) -} - -async function openWorkspaceDialog( - ctx: ExtensionCommandContext, -): Promise<void> { - if (!ctx.hasUI) { - ctx.ui?.notify?.("Brunch workspace dialog requires UI mode", "warning") - return - } - - await ctx.waitForIdle() - const coordinator = createWorkspaceSessionCoordinator({ cwd: ctx.cwd }) - const inventory = await coordinator.inspectWorkspace() - const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( - (_tui, theme, _keybindings, done) => - createWorkspaceDialogComponent({ inventory, theme, onDecision: done }), - { - overlay: true, - overlayOptions: { - anchor: "center", - width: 72, - maxHeight: "90%", - margin: 1, - }, - }, - ) - const activated = await coordinator.activateWorkspace(decision) - - if (activated.status === "cancelled") { - ctx.ui.notify("Workspace dialog cancelled.", "info") - return - } - if (activated.status === "needs_human") { - ctx.ui.notify(activated.reason, "warning") - return - } - - const targetFile = activated.session.file - if (ctx.sessionManager.getSessionFile() === targetFile) { - ctx.ui.notify("Already using the selected Brunch workspace.", "info") - return - } - - await ctx.switchSession(targetFile, { - withSession: async (replacementCtx) => { - replacementCtx.ui.notify( - `Switched Brunch workspace to ${activated.spec.title} (${activated.session.id}).`, - "info", - ) - }, - }) -} diff --git a/bin/brunch.js b/bin/brunch.js index e00d8b2a..740c69df 100755 --- a/bin/brunch.js +++ b/bin/brunch.js @@ -1,2 +1,12 @@ #!/usr/bin/env node -import "../dist/brunch.js" +import { runBrunchCli } from "../dist/brunch.js" + +runBrunchCli() + .then((code) => { + process.exitCode = code + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 + }) diff --git a/package-lock.json b/package-lock.json index 1b1ea4db..226eb984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "brunch", + "name": "brunch-next", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "brunch", + "name": "brunch-next", "version": "0.0.0", "dependencies": { "@earendil-works/pi-coding-agent": "^0.75.3", @@ -17,7 +17,7 @@ "ws": "^8.20.1" }, "bin": { - "brunch": "bin/brunch.js" + "brunch-next": "bin/brunch.js" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 949d6ac9..e69df8c3 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -489,7 +489,7 @@ describe("Brunch TUI boot", () => { overlay: true, overlayOptions: { anchor: "center", - width: 72, + width: 80, maxHeight: "90%", margin: 1, }, @@ -497,6 +497,40 @@ describe("Brunch TUI boot", () => { ]) }) + it("opens the workspace dialog from shortcut contexts without waitForIdle", async () => { + const events: string[] = [] + const target = readyWorkspace("/tmp/project", "session-target") + const ctx = fakeCommandContext({ + currentSessionFile: "/sessions/session-old.jsonl", + decision: { + action: "openSession", + specId: target.spec.id, + sessionFile: target.session.file, + }, + onEvent: (event) => events.push(event), + }) + delete (ctx as Partial<ExtensionCommandContext>).waitForIdle + + await runBrunchWorkspaceAction(ctx, { + inspectWorkspace: async () => { + events.push("inspect") + return inventoryWithWorkspace(target) + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`) + return target + }, + }) + + expect(events).toEqual([ + "inspect", + "custom", + "activate:openSession", + `switch:${target.session.file}`, + "notify:info", + ]) + }) + it("leaves the current session untouched when workspace switch is cancelled", async () => { const events: string[] = [] const ctx = fakeCommandContext({ diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index 4f01e18b..9433d86e 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -1,7 +1,9 @@ +import { execSync } from "node:child_process" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" -import type { Theme } from "@earendil-works/pi-coding-agent" +import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" +import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent" import { Key, matchesKey, @@ -19,19 +21,23 @@ import { type WorkspaceDialogOption, } from "./model.js" -const DEFAULT_DIALOG_WIDTH = 72 +export const WORKSPACE_DIALOG_WIDTH = 80 const ESC = String.fromCharCode(27) const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") const ASSET_DIR = new URL("./assets/", import.meta.url) +const PACKAGE_JSON_URL = new URL("../../../package.json", import.meta.url) +const LOCAL_BUILD_TIME = formatBuildTime(new Date()) // Letterform copied from: cfonts "brunch" -f tiny -c candy const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] +export type WorkspaceDialogTheme = Pick<Theme, "fg"> + export interface WorkspaceDialogComponentOptions { inventory: WorkspaceLaunchInventory onDecision: (decision: WorkspaceSwitchDecision) => void - theme?: Theme + theme?: WorkspaceDialogTheme } export function createWorkspaceDialogComponent( @@ -43,7 +49,7 @@ export function createWorkspaceDialogComponent( class WorkspaceDialogComponent implements Component { #options: WorkspaceDialogOption[] #onDecision: (decision: WorkspaceSwitchDecision) => void - #theme: Theme | undefined + #theme: WorkspaceDialogTheme | undefined #selectedIndex = 0 #mode: "select" | "newSpecTitle" = "select" #title = "" @@ -81,7 +87,7 @@ class WorkspaceDialogComponent implements Component { } render(width: number): string[] { - const dialogWidth = Math.max(24, Math.min(width, DEFAULT_DIALOG_WIDTH)) + const dialogWidth = Math.max(24, Math.min(width, WORKSPACE_DIALOG_WIDTH)) const content = this.#contentLines() return renderFrame(content, dialogWidth, this.#theme) } @@ -95,10 +101,21 @@ class WorkspaceDialogComponent implements Component { "dim", "Choose or create the workspace before the agent loop runs.", ) + const logo = readLogo() + const version = brunchVersion() + const versionLines = [ + style(this.#theme, "accent", `brunch ${version.version}`), + ...(version.dev ? [style(this.#theme, "success", version.dev)] : []), + ] + const piLine = style(this.#theme, "dim", `built on Pi v${PI_VERSION}`) const lines = [ - ...readLogo(), + ...logo, + ...(logo.length > 0 ? [""] : []), ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), "", + ...versionLines, + piLine, + "", title, subtitle, "", @@ -167,7 +184,7 @@ class WorkspaceDialogComponent implements Component { function renderFrame( content: string[], width: number, - theme: Theme | undefined, + theme: WorkspaceDialogTheme | undefined, ): string[] { return [ topBorderLine(width, theme), @@ -178,10 +195,60 @@ function renderFrame( ] } +interface PackageJson { + version?: unknown + private?: unknown +} + +interface BrunchVersionInfo { + version: string + dev: string | null +} + +function formatBuildTime(date: Date): string { + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, " UTC") +} + +function readPackage(): PackageJson { + try { + return JSON.parse( + readFileSync(fileURLToPath(PACKAGE_JSON_URL), "utf8"), + ) as PackageJson + } catch { + return {} + } +} + +function getGitSha(): string { + try { + return execSync("git rev-parse --short=7 HEAD", { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() + } catch { + return "" + } +} + +function brunchVersion(): BrunchVersionInfo { + const pkg = readPackage() + const version = typeof pkg.version === "string" ? pkg.version : "0.0.0" + const isLocalDev = pkg.private === true || version === "0.0.0" + if (!isLocalDev) return { version: `v${version}`, dev: null } + + const gitSha = getGitSha() + const devMeta = [gitSha, `@ ${LOCAL_BUILD_TIME}`].filter(Boolean).join(" ") + return { version: `v${version}`, dev: devMeta ? `(dev ${devMeta})` : "(dev)" } +} + function contentLine( content: string, width: number, - theme: Theme | undefined, + theme: WorkspaceDialogTheme | undefined, ): string { if (width <= 4) return truncateToWidth(content, width) const innerWidth = width - 4 @@ -191,18 +258,27 @@ function contentLine( return `${vertical} ${inner}${padding} ${vertical}` } -function emptyLine(width: number, theme: Theme | undefined): string { +function emptyLine( + width: number, + theme: WorkspaceDialogTheme | undefined, +): string { if (width <= 2) return " ".repeat(Math.max(0, width)) const vertical = style(theme, "borderMuted", "│") return `${vertical}${" ".repeat(width - 2)}${vertical}` } -function topBorderLine(width: number, theme: Theme | undefined): string { +function topBorderLine( + width: number, + theme: WorkspaceDialogTheme | undefined, +): string { if (width <= 2) return " ".repeat(Math.max(0, width)) return style(theme, "borderMuted", `╭${"─".repeat(width - 2)}╮`) } -function bottomBorderLine(width: number, theme: Theme | undefined): string { +function bottomBorderLine( + width: number, + theme: WorkspaceDialogTheme | undefined, +): string { if (width <= 2) return " ".repeat(Math.max(0, width)) return style(theme, "borderMuted", `╰${"─".repeat(width - 2)}╯`) } @@ -282,8 +358,8 @@ function removeVisibleColumns(line: string, columns: number): string { } function style( - theme: Theme | undefined, - color: Parameters<Theme["fg"]>[0], + theme: WorkspaceDialogTheme | undefined, + color: ThemeColor, text: string, ): string { return theme ? theme.fg(color, text) : text diff --git a/src/pi-components/workspace-dialog/index.ts b/src/pi-components/workspace-dialog/index.ts index d332f4b1..58fa4070 100644 --- a/src/pi-components/workspace-dialog/index.ts +++ b/src/pi-components/workspace-dialog/index.ts @@ -1,4 +1,5 @@ export { + WORKSPACE_DIALOG_WIDTH, createWorkspaceDialogComponent, type WorkspaceDialogComponentOptions, } from "./component.js" diff --git a/src/pi-components/workspace-dialog/preflight.ts b/src/pi-components/workspace-dialog/preflight.ts index 3e46a7ab..a9f1173b 100644 --- a/src/pi-components/workspace-dialog/preflight.ts +++ b/src/pi-components/workspace-dialog/preflight.ts @@ -1,30 +1,44 @@ -import { ProcessTerminal, TUI } from "@earendil-works/pi-tui" +import type { ThemeColor } from "@earendil-works/pi-coding-agent" +import { ProcessTerminal, TUI, type Terminal } from "@earendil-works/pi-tui" import type { WorkspaceLaunchInventory, WorkspaceSwitchDecision, } from "../../workspace-session-coordinator.js" -import { createWorkspaceDialogComponent } from "./component.js" +import { + WORKSPACE_DIALOG_WIDTH, + createWorkspaceDialogComponent, + type WorkspaceDialogTheme, +} from "./component.js" + +interface WorkspaceDialogPreflightOptions { + terminal?: Terminal + theme?: WorkspaceDialogTheme +} export async function runWorkspaceDialogPreflight( inventory: WorkspaceLaunchInventory, + options: WorkspaceDialogPreflightOptions = {}, ): Promise<WorkspaceSwitchDecision> { - const terminal = new ProcessTerminal() + const terminal = options.terminal ?? new ProcessTerminal() const tui = new TUI(terminal) + const dialogTheme = options.theme ?? resolveStartupDialogTheme() return await new Promise<WorkspaceSwitchDecision>((resolve) => { const finish = (decision: WorkspaceSwitchDecision) => { overlay.hide() tui.stop() + terminal.clearScreen() resolve(decision) } const component = createWorkspaceDialogComponent({ inventory, + theme: dialogTheme, onDecision: finish, }) const overlay = tui.showOverlay(component, { anchor: "center", - width: 72, + width: WORKSPACE_DIALOG_WIDTH, maxHeight: "90%", margin: 1, }) @@ -32,3 +46,43 @@ export async function runWorkspaceDialogPreflight( tui.start() }) } + +function resolveStartupDialogTheme(): WorkspaceDialogTheme { + const colors = startupPalette(detectStartupThemeName()) + return { + fg(color: ThemeColor, text: string) { + const ansi = colors[color] + return ansi ? `${ansi}${text}\x1B[39m` : text + }, + } +} + +function detectStartupThemeName(): "dark" | "light" { + const colorfgbg = process.env.COLORFGBG ?? "" + const background = Number.parseInt(colorfgbg.split(";").at(-1) ?? "", 10) + if (!Number.isNaN(background)) { + return background < 8 ? "dark" : "light" + } + return "dark" +} + +function startupPalette( + themeName: "dark" | "light", +): Partial<Record<ThemeColor, string>> { + if (themeName === "light") { + return { + accent: "\x1B[38;2;90;128;128m", + borderMuted: "\x1B[38;2;176;176;176m", + dim: "\x1B[38;2;118;118;118m", + muted: "\x1B[38;2;108;108;108m", + success: "\x1B[38;2;88;132;88m", + } + } + return { + accent: "\x1B[38;2;138;190;183m", + borderMuted: "\x1B[38;2;80;80;80m", + dim: "\x1B[38;2;102;102;102m", + muted: "\x1B[38;2;128;128;128m", + success: "\x1B[38;2;181;189;104m", + } +} diff --git a/src/pi-extensions/workspace-dialog.ts b/src/pi-extensions/workspace-dialog.ts index 3612d670..5978b890 100644 --- a/src/pi-extensions/workspace-dialog.ts +++ b/src/pi-extensions/workspace-dialog.ts @@ -8,7 +8,10 @@ import { type WorkspaceSwitchCoordinator, type WorkspaceSwitchDecision, } from "../workspace-session-coordinator.js" -import { createWorkspaceDialogComponent } from "../pi-components/workspace-dialog/index.js" +import { + WORKSPACE_DIALOG_WIDTH, + createWorkspaceDialogComponent, +} from "../pi-components/workspace-dialog/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch" @@ -51,7 +54,7 @@ export async function runBrunchWorkspaceAction( coordinator: WorkspaceSwitchCoordinator, options: { waitForIdle?: boolean } = {}, ): Promise<void> { - if (options.waitForIdle !== false) { + if (options.waitForIdle !== false && canWaitForIdle(ctx)) { await ctx.waitForIdle() } const inventory = await coordinator.inspectWorkspace() @@ -62,7 +65,7 @@ export async function runBrunchWorkspaceAction( overlay: true, overlayOptions: { anchor: "center", - width: 72, + width: WORKSPACE_DIALOG_WIDTH, maxHeight: "90%", margin: 1, }, @@ -82,6 +85,12 @@ export async function runBrunchWorkspaceAction( await switchToActivatedWorkspace(ctx, activated) } +function canWaitForIdle( + ctx: ExtensionCommandContext, +): ctx is ExtensionCommandContext & { waitForIdle: () => Promise<void> } { + return typeof ctx.waitForIdle === "function" +} + async function switchToActivatedWorkspace( ctx: ExtensionCommandContext, activated: WorkspaceSessionReadyState, diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index cea72502..88019d72 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -1,12 +1,13 @@ import { readFile } from "node:fs/promises" -import { visibleWidth } from "@earendil-works/pi-tui" +import { visibleWidth, type Terminal } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" import { buildWorkspaceDialogOptions, createWorkspaceDialogComponent, + runWorkspaceDialogPreflight, } from "./pi-components/workspace-dialog/index.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" @@ -94,17 +95,21 @@ describe("workspace dialog", () => { ]) }) - it("renders a branded centered-dialog frame within the requested width", () => { + it("renders a branded centered-dialog frame with version metadata", () => { const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: () => {}, }) - const lines = component.render(64) + const lines = component.render(80) expect(lines[0]).toContain("╭") + expect(lines[1]).toMatch(/^│\s+│$/) expect(lines.some((line) => line.includes("Brunch workspace"))).toBe(true) - expect(lines.every((line) => visibleWidth(line) <= 64)).toBe(true) + expect(lines.some((line) => line.includes("brunch v0.0.0"))).toBe(true) + expect(lines.some((line) => line.includes("(dev"))).toBe(true) + expect(lines.some((line) => line.includes("built on Pi v"))).toBe(true) + expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true) }) it("keeps logo assets colocated with the workspace dialog component", async () => { @@ -126,8 +131,71 @@ describe("workspace dialog", () => { expect(manifest.dependencies).toHaveProperty("@earendil-works/pi-tui") }) + + it("clears the startup preflight frame after a workspace decision", async () => { + const terminal = new FakeTerminal() + const decision = runWorkspaceDialogPreflight(inventory(), { terminal }) + + terminal.emit("\r") + + await expect(decision).resolves.toMatchObject({ action: "continue" }) + expect(terminal.events.at(-2)).toBe("stop") + expect(terminal.events.at(-1)).toBe("clearScreen") + }) }) +class FakeTerminal implements Terminal { + events: string[] = [] + #onInput: ((data: string) => void) | undefined + + get columns(): number { + return 100 + } + + get rows(): number { + return 32 + } + + get kittyProtocolActive(): boolean { + return false + } + + start(onInput: (data: string) => void): void { + this.events.push("start") + this.#onInput = onInput + } + + stop(): void { + this.events.push("stop") + } + + async drainInput(): Promise<void> {} + + write(_data: string): void {} + + moveBy(_lines: number): void {} + + hideCursor(): void {} + + showCursor(): void {} + + clearLine(): void {} + + clearFromCursor(): void {} + + clearScreen(): void { + this.events.push("clearScreen") + } + + setTitle(_title: string): void {} + + setProgress(_active: boolean): void {} + + emit(data: string): void { + this.#onInput?.(data) + } +} + function inventory(): WorkspaceLaunchInventory { return { cwd: "/project", From b837ac9ce7865c5b2e0f58c761866e5e723919df Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:21:28 +0200 Subject: [PATCH 058/164] workspace menu ux refinement plan --- memory/CARDS.md | 184 ++++++++++++++++++++++++++++++++++++++++++++++++ memory/PLAN.md | 8 +-- memory/SPEC.md | 31 ++++---- 3 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..02691ab2 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,184 @@ +# Scope cards — FE-744 spec/session picker correction + +Status key: `next` / `in progress` / `done` / `dropped`. + +## Orientation + +- **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), specifically the Brunch-owned startup/in-session selection seam over Pi TUI extension affordances. +- **Canonical model:** SPEC D11-L / D36-L: `workspace(cwd) → spec → session`; workspace is cwd scope, not a user-created object; spec/session selection is Brunch-owned before agent loop entry. +- **Volatile state:** The current implementation still lives under `workspace-dialog` file/module names and renders a flat list with labels like “Start new session in X” / “Open X” / “Create workspace”. Those names are implementation lag, not product vocabulary. +- **Main open risk:** The TUI redesign must improve hierarchy without coupling UI components to session creation/opening; the RPC/headless path must expose equivalent activation decisions without invoking TUI picker code. +- **Cross-cutting obligations:** Preserve linear transcript policy (D24-L/I19-L), coordinator-owned activation and session binding (D21-L/I8-L/I22-L), no implicit transcript resume before explicit TUI activation (D22-L/I22-L), and RPC/headless non-TUI startup selection (D36-L/I22-L). + +--- + +## Card 1 — Pure spec/session selection model + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The selection model turns workspace inventory into hierarchical spec/session stages whose top-level actions are `continue last session`, `create new spec`, `resume existing spec`, and `cancel` without listing individual specs as top-level actions. + +### Boundary Crossings + +```text +→ WorkspaceLaunchInventory +→ src/pi-components/workspace-dialog/model.ts selection-state/model helpers +→ WorkspaceSwitchDecision values consumed by coordinator/TUI adapters +``` + +### Risks and Assumptions + +- RISK: Trying to rename every `workspace-*` implementation symbol in the same slice creates noisy churn. → MITIGATION: Fix product-facing labels and model shape first; leave file/module renames to a later cleanup unless they block clarity. +- RISK: The existing flat `WorkspaceDialogOption[]` shape may not express nested screens cleanly. → MITIGATION: Replace or wrap it with explicit stage/view data (`home`, `newSpecTitle`, `specList`, `specAction`, `sessionList`) while keeping `WorkspaceSwitchDecision` as the activation boundary. +- ASSUMPTION: Existing coordinator decision variants are sufficient for the new hierarchy. → VALIDATE: Model tests prove new-spec, new-session, open-session, continue, and cancel all still produce existing `WorkspaceSwitchDecision` variants. + +### Acceptance Criteria + +✓ `src/workspace-dialog.test.ts` — inventory with a valid selected session produces a home stage containing a continue-last option, create-new-spec, resume-existing-spec, and cancel; it does not include `resume spec X` / `open X` / per-spec labels at top level. +✓ `src/workspace-dialog.test.ts` — selecting `resume existing spec` yields a spec-list stage populated by existing specs; selecting a spec yields a stage with `create new session` and `resume existing session`. +✓ `src/workspace-dialog.test.ts` — selecting `resume existing session` yields a session-list stage for the chosen spec and returns `openSession` only after a session is chosen. +✓ `src/workspace-dialog.test.ts` — selecting `create new spec` enters title-entry state and returns `newSpec` with the entered title; no session-selection step is required for this path. + +### Verification Approach + +- Inner: Unit tests over the pure selection model — prove hierarchy, labels, and decision mapping independent of terminal rendering. +- Middle: Architectural boundary assertion in tests — model emits decisions only; it does not call coordinator/session APIs or mutate `.brunch/state.json`. + +### Cross-cutting obligations + +- Keep `WorkspaceSessionCoordinator` as the only owner of activation, session creation/opening, `.brunch/state.json`, and `brunch.session_binding` writes. +- Keep `WorkspaceSwitchDecision` product-shaped and transport-neutral so TUI and RPC/headless activation can share it. +- Retire stale user-facing “workspace” wording in model labels/descriptions touched by this slice. + +--- + +## Card 2 — Hierarchical TUI spec/session picker + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The startup and in-session TUI picker renders the hierarchical spec/session flow with a continue-last fast path and navigates through each stage using keyboard input. + +### Boundary Crossings + +```text +→ createWorkspaceDialogComponent(options) +→ selection model from Card 1 +→ @earendil-works/pi-tui Component render/handleInput +→ runWorkspaceDialogPreflight / ctx.ui.custom overlay adapters +→ WorkspaceSwitchDecision callback +``` + +### Risks and Assumptions + +- RISK: Multi-screen state can become a local UI state machine that diverges from the pure model. → MITIGATION: Keep screen/view derivation in the model module where possible; component stores only current stage, selected index, and text input. +- RISK: Scrollable spec/session lists may be more work than needed for first pass. → MITIGATION: Implement bounded visible-window scrolling only if list length exceeds available content height; otherwise keep list rendering simple but ensure selected index can move through all entries. +- RISK: Current tests assume flat-list arrow counts. → MITIGATION: Replace those tests with stage-by-stage input tests matching the new hierarchy. + +### Acceptance Criteria + +✓ `src/workspace-dialog.test.ts` — rendered copy says “Choose a specification” / “Create new specification” / “Resume existing specification” and does not say “Brunch workspace”, “Create workspace”, or “Open workspace” in user-facing text. +✓ `src/workspace-dialog.test.ts` — pressing Enter on continue-last returns the existing `continue` decision when valid prior state exists. +✓ `src/workspace-dialog.test.ts` — keyboard path `resume existing specification → choose spec → create new session` returns `newSession` for that spec. +✓ `src/workspace-dialog.test.ts` — keyboard path `resume existing specification → choose spec → resume existing session → choose session` returns `openSession` for that session. +✓ `src/workspace-dialog.test.ts` — escape backs out one picker stage where possible and cancels from the home stage. +✓ `src/brunch-tui.test.ts` — startup preflight and in-session overlay still pass the same overlay width/lifecycle expectations and clear after decision. + +### Verification Approach + +- Inner: Component render/input tests — prove keyboard navigation, visible labels, and decision callbacks. +- Middle: Existing startup preflight lifecycle test — proves no stale overlay remains after activation. +- Outer: Manual/pty smoke after build — launch `brunch-next` in a scratch cwd with multiple specs/sessions and capture that no prior transcript renders before explicit continue/open. + +### Cross-cutting obligations + +- Preserve the startup invariant: no prior transcript or agent loop before explicit activation. +- Preserve shared startup/in-session component reuse; adapters may differ only in terminal lifecycle and Pi session replacement mechanics. +- Keep copy aligned to SPEC lexicon: workspace = cwd label only; spec/session are the user choices. + +--- + +## Card 3 — RPC/headless initial selection contract + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +RPC mode exposes initial spec/session selection as structured JSON-RPC state and activation methods without constructing or invoking the TUI picker. + +### Boundary Crossings + +```text +→ brunch --mode rpc / createRpcHandlers +→ WorkspaceSwitchCoordinator.inspectWorkspace / activateWorkspace +→ JSON-RPC method family +→ product-shaped selection/inventory and activation responses +``` + +### Risks and Assumptions + +- RISK: Reusing `workspace.snapshot` for activation would blur read vs mutation behavior. → MITIGATION: Add explicit method names, e.g. `workspace.selectionState` for inventory/requirements and `workspace.activate` for submitting a `WorkspaceSwitchDecision`. +- RISK: JSON-RPC params may accidentally accept impossible decision shapes. → MITIGATION: Add narrow runtime parsing for `continue`, `openSession`, `newSession`, `newSpec`, and `cancel` decisions; invalid params return `-32602`. +- RISK: Activation can return a ready state containing non-serializable `SessionManager`. → MITIGATION: Return a serializable snapshot/activation DTO derived from `WorkspaceActivationState`, not the raw state object. + +### Acceptance Criteria + +✓ `src/rpc.test.ts` — `workspace.selectionState` returns cwd, current spec/session acceleration, specs/sessions inventory, unavailable sessions, and a `requiresSelection`/status field when no ready default exists. +✓ `src/rpc.test.ts` — `workspace.activate` accepts `newSpec`, `newSession`, `openSession`, `continue`, and `cancel` decision params and delegates to `coordinator.activateWorkspace` without importing or constructing the TUI picker/component. +✓ `src/rpc.test.ts` — successful activation returns a serializable product snapshot including selected spec/session ids and status; needs-human/cancelled activation returns structured reason/status without switching sessions. +✓ `src/rpc.test.ts` — invalid activation params return JSON-RPC `-32602` and unknown methods still return `-32601`. + +### Verification Approach + +- Inner: JSON-RPC handler contract tests — prove method names, param validation, coordinator delegation, and serializable responses. +- Middle: Architectural import/boundary test or source assertion — RPC module does not import `pi-components/workspace-dialog` or TUI picker code. + +### Cross-cutting obligations + +- RPC/headless must not invoke TUI picker code; it exposes the same product selection requirement and activation decisions through JSON-RPC. +- Keep transport modes distinct from product state: RPC connections are client attachments, not sessions. +- Keep coordinator as the only activation/session-binding writer. + +--- + +## Card 4 — Terminology cleanup and compatibility retirement + +**Status:** next +**Weight:** light scope card + +### Objective + +Remove stale user-facing “workspace dialog/switcher” terminology from tests, descriptions, commands, and documentation-adjacent strings touched by the picker work while preserving stable internal APIs unless renaming is cheap. + +### Acceptance Criteria + +✓ User-facing command/shortcut descriptions say “Open the Brunch spec/session picker” or equivalent, not “workspace dialog”. +✓ Tests assert the new lexicon for visible UI text and no longer expect “Create workspace” / “Brunch workspace”. +✓ Any implementation names left as `workspace-dialog` are either private/file-path compatibility or explicitly deferred; no product copy depends on them. + +### Verification Approach + +- Inner: `rg` checks plus existing unit tests. +- Middle: Manual screenshot/smoke review for startup and Ctrl-Shift-B copy. + +### Cross-cutting obligations + +- Do not rename public/product decision variants purely for aesthetics if doing so would create avoidable churn for coordinator/RPC clients. +- Delete obsolete copy/tests rather than preserving aliases for old “workspace” wording. + +### Promotion checklist + +- [ ] Does this change a requirement? No — SPEC already changed; this card implements terminology cleanup. +- [ ] Does this create, retire, or invalidate an assumption? No. +- [ ] Does this make or reverse a non-trivial design decision? No. +- [ ] Does this establish a new seam-level invariant? No. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. +- [ ] Does it cross more than two major seams? No, if kept to user-facing strings/tests. +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/memory/PLAN.md b/memory/PLAN.md index 02393594..b5237906 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -123,7 +123,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) -- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Next scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. +- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Immediate UI correction before continuing profile audit: rename/reframe the current workspace dialog around SPEC D11-L/D36-L terminology (`workspace(cwd) → spec → session`) and reshape it into the hierarchical spec/session selection model: optional continue-last fast path; create spec → name it → implicit first session; resume existing spec → choose spec from a scrollable selector → create new session or resume existing session → choose session. Preserve RPC/headless startup as structured initial-selection state/results, not a TUI picker. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. ### graph-data-plane @@ -239,13 +239,13 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** structural (spike-flavored) - **Status:** in-progress (command-containment, dynamic chrome, workspace-dialog startup flow, in-session workspace command, pty startup oracle, centered branded overlay reuse, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered workspace dialog supports explicit continue/open-session/new-session/new-spec/cancel decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit workspace-switch decision. Workspace switcher UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Scope the spec/session picker correction before the structured-question spike: update terminology and interaction shape, preserve the startup/in-session shared component, and add RPC/headless non-TUI initial-selection coverage. Then scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 4457f131..11b4ad51 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -18,7 +18,7 @@ ### Concept -Brunch is an opinionated local product that helps a human and an agent co-author a **specification workspace** as a graph-native artifact. It runs as a single installable CLI over the `pi-coding-agent` harness and exposes one host through four presentation modes (TUI, web, RPC, print). The intent graph is canonical specification meaning; oracle, design, and plan graphs are accountable downstream planes. Coherence is shared product state, not an implicit hope. +Brunch is an opinionated local product that helps a human and an agent co-author a **specification** as a graph-native artifact inside the current working directory. It runs as a single installable CLI over the `pi-coding-agent` harness and exposes one host through four presentation modes (TUI, web, RPC, print). The intent graph is canonical specification meaning; oracle, design, and plan graphs are accountable downstream planes. Coherence is shared product state, not an implicit hope. The POC's purpose is to prove three things: (a) that pi's coding-agent harness can be the substrate without forking it; (b) that a graph-native spec workspace plus a JSONL-first transcript can coexist coherently under one mutation authority; (c) that elicitation-first sessions can project inspectable prompt/response exchanges for observer extraction, replay, and fixture pressure without reintroducing a parallel chat/turn store. @@ -47,7 +47,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Modes & authority 4. Brunch must expose TUI, web, RPC, and print modes over the same local host authority. -5. Brunch must support structured `needs_human` outcomes for human-only actions in headless modes. +5. Brunch must support structured `needs_human` outcomes for human-only actions in headless modes, and headless/RPC clients must receive product-shaped initial spec/session selection or creation requirements instead of TUI-only dialogs. 6. Brunch must support three authority tiers (autonomous / requires confirmation / human-only) with consistent enforcement across modes. #### Persistence & data model @@ -74,7 +74,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. 17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. -19. Brunch must enforce a workspace state hierarchy `cwd → spec → session`, where the active spec and session are selected or created through Brunch-owned workspace flow before any agent loop runs, spec selection persists across `/new`, and each session binds to exactly one spec. +19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. 21. Brunch must distinguish *extractive* lenses (single-exchange, observer-extracted) from *generative* lenses (batch-proposal, captured at proposal time as structured entity-draft payloads, reviewer-analyzed post-acceptance). 22. Brunch must establish a minimum grounding bundle (domain, protagonist, pain/pull, and constraint anchors) before generative lenses produce non-speculative output; lenses remain always-available with epistemic-status signaling honestly reflecting grounding density. @@ -199,7 +199,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Interaction & UI shape -- **D11-L — Workspace state hierarchy `cwd → spec → session`, with spec selection gated before any agent loop.** Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: —. +- **D11-L — Workspace state hierarchy `workspace(cwd) → spec → session`, with spec and session selection gated before any agent loop.** A Brunch workspace is the single cwd where the CLI is invoked; it is not a user-created container and there is only one per launch context. The cwd's human-readable label may be derived by `src/project-identity.ts` from shallow project manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) or directory basename, but that label is presentation metadata, not a second selectable container. The first durable choice is the spec: create a new spec, or resume an existing spec. Within an existing spec, the second durable choice is the session: create a new session or resume an existing session. Creating a new spec implicitly creates its first session. Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: treating “workspace” as the user-created product object in the boot dialog. - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. @@ -212,7 +212,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. -- **D36-L — Workspace selection is a reusable dialog with coordinator activation adapters.** Brunch owns a pure centered `workspace-dialog` component that renders workspace inventory and returns a product decision (`continue selected session`, `open session`, `new session for spec`, `new spec`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. Startup and in-session paths share the same branded `pi-tui` component and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, and a separate intermediate action chooser for workspace switching. +- **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. ### Critical Invariants @@ -239,13 +239,17 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | -| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a workspace-switch decision; creating a new session lands in a binding-only session for the selected spec, while resuming a prior transcript is opt-in. | covered (FE-744 startup-switcher coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered for current startup-switcher behavior (FE-744 coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation); planned for hierarchical picker and RPC/headless non-TUI startup coverage | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | ## Future Direction Register +### Workspace identity and configuration + +- **Local Brunch config.** A future `.brunch/config.json` may identify the project root and provide a UI-readable project name, superseding shallow manifest/directory-name inference for display. This would let Brunch launch from subdirectories while still resolving the intended workspace root, but it must preserve the invariant that workspace is a filesystem root/cwd scope rather than a user-created object alongside specs. + ### Framework alignment & deferred subsystems - **Geolog (TA1.2 data store).** Datalog-shaped logical store eventually backing intent/oracle queries. Domain modelling itself is non-trivial and parallel to Brunch. See pi-seam-extensions §Framework alignment. @@ -307,14 +311,15 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Brunch Pi Profile** | The sealed programmatic wrapper around embedded Pi: settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. It allows Brunch-owned resources while suppressing ambient `.pi/` behavior. | | **Prompt pack** | A Brunch-owned prompt fragment selected by operational mode, role preset, strategy, lens, or spec phase/maturity. Prompt packs compose at turn boundaries; they are product control-plane state, not ambient Pi prompt templates. | | **Print snapshot** | The M1 meaning of the print transport mode: boot the Brunch host, resolve workspace/spec/session state through the coordinator, render product-shaped state, and exit without running an agent turn. | -| **Spec** | A specification workspace, identified by its intent-graph root. Lives under `.brunch/`. Multiple specs may coexist per project. | +| **Workspace** | The current working directory where the Brunch CLI was invoked. It scopes `.brunch/` state for the launch context. It is not user-created, not selectable within the dialog, and there is only one active workspace per Brunch process. The UI may display a project identity/name derived from cwd-local manifests or directory basename, but that name labels the cwd; it does not create a separate workspace object. | +| **Spec / specification** | The user-created specification container within a workspace, identified by its intent-graph root. Multiple specs may coexist under one workspace. A spec contains sessions and the graph data gathered through those sessions (intent nodes, design nodes, oracle/plan data as they land). Future plan-execution mode operates on a selected spec. | | **Session** | An elicitation transcript belonging to one spec. Backed by a linear pi JSONL session under `.brunch/sessions/`. A spec may have many sessions over time; a session never changes specs. Pi branch/tree mechanics are unsupported Brunch product behavior in the POC. | | **Session binding** | The first Brunch custom entry in a session that binds the Pi session id to exactly one spec id and schema version. Makes JSONL self-describing; registry/index state is an acceleration, not the canonical binding. | | **Client attachment** | An ephemeral TUI instance, browser tab, stdio stream, or WebSocket connection attached to one or more Brunch product resources for viewing or driving. Client attachment state may guide subscriptions and UI routing, but it is not durable spec/session truth. | -| **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/state.json`, and derives chrome state for callers. | -| **Workspace state hierarchy** | `cwd → spec → session`. Each level scopes the one below it; active spec/session activation is Brunch-owned before any agent loop runs, and spec selection persists across `/new`. | +| **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/state.json`, and derives chrome state for callers. “Workspace” in this name refers to cwd scope, not a selectable product object. | +| **Workspace state hierarchy** | `workspace(cwd) → spec → session`. Each level scopes the one below it; active spec/session activation is Brunch-owned before any agent loop runs, and spec selection persists across `/new`. | | **Workspace default state** | Lightweight `.brunch/state.json` acceleration for reopening the last selected spec/session in a cwd. It is a launch/default convenience, not the canonical binding of a session, not an instruction to resume without product flow, and not a multi-client concurrency authority. | -| **Workspace switcher** | Brunch-owned decision UI over workspace inventory. It lets the user continue/open a session, create a new session for a selected spec, create a new spec, or cancel/quit. The switcher returns a decision; the `WorkspaceSessionCoordinator` activates it and owns all Pi session and binding effects. | +| **Spec/session selection model** | Brunch-owned hierarchy over cwd-scoped inventory. In TUI, it can render as a picker with a continue-last fast path, then a tree: create new spec → name it → implicit first session; resume existing spec → choose spec → create session or resume existing session → choose session. In RPC/headless modes, the same requirement is exposed as structured product state and activation methods, not a TUI dialog. The model returns a decision; the `WorkspaceSessionCoordinator` activates it and owns all Pi session and binding effects. | | **Intent graph** | The canonical specification-meaning plane. Authority over what the system is for. | | **Oracle graph** | Verification-strategy plane accountable to intent. Houses Checks, Validation Methods, Evidence, Obligations. | | **Design graph** | Modules, interfaces, seams, and adapters accountable to intent. Stubbed in POC. | @@ -428,10 +433,10 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; workspace-dialog UI returns decisions rather than opening/mutating sessions; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, workspace dialog, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | +| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Runbook Oracle Design @@ -470,7 +475,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | -| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id. | +| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | | I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | From 66087bbb218c12f27ffa88caa8bbfa043d48af5e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:24:54 +0200 Subject: [PATCH 059/164] Add hierarchical spec session selection model --- memory/CARDS.md | 2 +- src/pi-components/workspace-dialog/index.ts | 6 + src/pi-components/workspace-dialog/model.ts | 184 ++++++++++++++++++++ src/workspace-dialog.test.ts | 86 +++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 02691ab2..6d8ab87f 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -14,7 +14,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Card 1 — Pure spec/session selection model -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/pi-components/workspace-dialog/index.ts b/src/pi-components/workspace-dialog/index.ts index 58fa4070..0e7c41ef 100644 --- a/src/pi-components/workspace-dialog/index.ts +++ b/src/pi-components/workspace-dialog/index.ts @@ -5,6 +5,12 @@ export { } from "./component.js" export { buildWorkspaceDialogOptions, + buildWorkspaceSelectionView, + selectWorkspaceSelectionOption, type WorkspaceDialogOption, + type WorkspaceSelectionOption, + type WorkspaceSelectionResult, + type WorkspaceSelectionStage, + type WorkspaceSelectionView, } from "./model.js" export { runWorkspaceDialogPreflight } from "./preflight.js" diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index 9eb76aa3..429d147e 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -12,6 +12,183 @@ export interface WorkspaceDialogOption { decision?: WorkspaceSwitchDecision } +export type WorkspaceSelectionStage = { stage: "home" } | { + stage: "newSpecTitle" + title: string +} | { stage: "specList" } | { + stage: "specAction" + specId: string +} | { + stage: "sessionList" + specId: string +} + +export interface WorkspaceSelectionOption { + id: string + label: string + description: string + kind: "continue" | "newSpec" | "resumeSpec" | "cancel" | "spec" | "newSession" | "resumeSession" | "session" + decision?: WorkspaceSwitchDecision + nextStage?: WorkspaceSelectionStage +} + +export interface WorkspaceSelectionView { + stage: WorkspaceSelectionStage["stage"] + title: string + options: WorkspaceSelectionOption[] + specId?: string +} + +export type WorkspaceSelectionResult = { decision: WorkspaceSwitchDecision } | { + view: WorkspaceSelectionView +} + +export function buildWorkspaceSelectionView( + inventory: WorkspaceLaunchInventory, + stage: WorkspaceSelectionStage = { stage: "home" }, +): WorkspaceSelectionView { + if (stage.stage === "newSpecTitle") { + return { + stage: "newSpecTitle", + title: "Create new specification", + options: [], + } + } + + if (stage.stage === "specList") { + return { + stage: "specList", + title: "Choose a specification", + options: inventory.specs.map(({ spec }) => ({ + id: `spec:${spec.id}`, + label: spec.title, + description: "Choose how to continue this specification", + kind: "spec", + nextStage: { stage: "specAction", specId: spec.id }, + })), + } + } + + if (stage.stage === "specAction") { + const spec = findSpec(inventory, stage.specId) + return { + stage: "specAction", + specId: stage.specId, + title: spec ? `Continue ${spec.spec.title}` : "Continue specification", + options: [ + { + id: `new-session:${stage.specId}`, + label: "Create new session", + description: "Start a binding-only session for this specification", + kind: "newSession", + decision: { action: "newSession", specId: stage.specId }, + }, + { + id: `resume-session:${stage.specId}`, + label: "Resume existing session", + description: "Choose a prior session transcript explicitly", + kind: "resumeSession", + nextStage: { stage: "sessionList", specId: stage.specId }, + }, + ], + } + } + + if (stage.stage === "sessionList") { + const spec = findSpec(inventory, stage.specId) + return { + stage: "sessionList", + specId: stage.specId, + title: spec + ? `Choose a session for ${spec.spec.title}` + : "Choose a session", + options: (spec?.sessions ?? []).map((session) => ({ + id: `session:${session.file}`, + label: session.name ?? session.id, + description: sessionDescription(session, "Open existing session"), + kind: "session", + decision: { + action: "openSession", + specId: stage.specId, + sessionFile: session.file, + }, + })), + } + } + + return buildHomeSelectionView(inventory) +} + +export function selectWorkspaceSelectionOption( + view: WorkspaceSelectionView, + index: number, + inventory?: WorkspaceLaunchInventory, +): WorkspaceSelectionResult { + const option = view.options[index] + if (!option) return { decision: { action: "cancel" } } + if (option.decision) return { decision: option.decision } + if (!inventory) { + return { view: stageOnlyView(option.nextStage ?? { stage: "home" }) } + } + return { view: buildWorkspaceSelectionView(inventory, option.nextStage) } +} + +function stageOnlyView(stage: WorkspaceSelectionStage): WorkspaceSelectionView { + return { + stage: stage.stage, + title: stage.stage === "newSpecTitle" ? stage.title : "", + ...("specId" in stage ? { specId: stage.specId } : {}), + options: [], + } +} + +function buildHomeSelectionView( + inventory: WorkspaceLaunchInventory, +): WorkspaceSelectionView { + const options: WorkspaceSelectionOption[] = [] + const currentSession = findCurrentSession(inventory) + + if (currentSession && inventory.currentSpec) { + options.push({ + id: `continue:${currentSession.file}`, + label: "Continue last session", + description: `${inventory.currentSpec.title} · ${currentSession.id}`, + kind: "continue", + decision: { + action: "continue", + specId: inventory.currentSpec.id, + sessionFile: currentSession.file, + }, + }) + } + + options.push( + { + id: "new-spec", + label: "Create new specification", + description: "Name a new spec and create its first session", + kind: "newSpec", + nextStage: { stage: "newSpecTitle", title: "" }, + }, + { + id: "resume-spec", + label: "Resume existing specification", + description: "Choose a spec, then create or resume a session", + kind: "resumeSpec", + nextStage: { stage: "specList" }, + }, + { + id: "cancel", + label: "Cancel", + description: "Exit without activating a spec/session", + kind: "cancel", + decision: { action: "cancel" }, + }, + ) + + return { stage: "home", title: "Choose a specification", options } +} + export function buildWorkspaceDialogOptions( inventory: WorkspaceLaunchInventory, ): WorkspaceDialogOption[] { @@ -96,6 +273,13 @@ function findCurrentSession( return undefined } +function findSpec( + inventory: WorkspaceLaunchInventory, + specId: string, +): WorkspaceLaunchInventory["specs"][number] | undefined { + return inventory.specs.find((candidate) => candidate.spec.id === specId) +} + function sessionDescription( session: WorkspaceLaunchSession, prefix: string, diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 88019d72..7a9fe075 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -6,12 +6,98 @@ import { describe, expect, it } from "vitest" import { buildWorkspaceDialogOptions, + buildWorkspaceSelectionView, createWorkspaceDialogComponent, + selectWorkspaceSelectionOption, runWorkspaceDialogPreflight, } from "./pi-components/workspace-dialog/index.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" describe("workspace dialog", () => { + it("builds a hierarchical spec/session selection home without per-spec top-level actions", () => { + const view = buildWorkspaceSelectionView(inventory()) + + expect(view.stage).toBe("home") + expect(view.options.map((option) => option.kind)).toEqual([ + "continue", + "newSpec", + "resumeSpec", + "cancel", + ]) + expect(view.options.map((option) => option.label)).toEqual([ + "Continue last session", + "Create new specification", + "Resume existing specification", + "Cancel", + ]) + expect(view.options.map((option) => option.label).join("\n")).not.toMatch( + /Resume Alpha|Open Alpha|Start new session in Alpha/, + ) + expect(selectWorkspaceSelectionOption(view, 0)).toEqual({ + decision: { + action: "continue", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-current.jsonl", + }, + }) + }) + + it("navigates resume-existing-spec to spec actions without emitting activation early", () => { + const currentInventory = inventory() + const home = buildWorkspaceSelectionView(currentInventory) + const specList = selectWorkspaceSelectionOption(home, 2, currentInventory) + + expect(specList).toMatchObject({ view: { stage: "specList" } }) + if (!("view" in specList)) throw new Error("expected spec list") + expect(specList.view.options.map((option) => option.label)).toEqual([ + "Alpha", + "Beta", + ]) + + const specAction = selectWorkspaceSelectionOption( + specList.view, + 0, + currentInventory, + ) + + expect(specAction).toMatchObject({ view: { stage: "specAction" } }) + if (!("view" in specAction)) throw new Error("expected spec action") + expect(specAction.view.options.map((option) => option.label)).toEqual([ + "Create new session", + "Resume existing session", + ]) + expect(selectWorkspaceSelectionOption(specAction.view, 0)).toEqual({ + decision: { action: "newSession", specId: "spec-alpha" }, + }) + }) + + it("emits open-session only after a session is selected", () => { + const sessionList = buildWorkspaceSelectionView(inventory(), { + stage: "sessionList", + specId: "spec-alpha", + }) + + expect(sessionList.options.map((option) => option.label)).toEqual([ + "session-alpha-current", + "session-alpha-older", + ]) + expect(selectWorkspaceSelectionOption(sessionList, 1)).toEqual({ + decision: { + action: "openSession", + specId: "spec-alpha", + sessionFile: "/sessions/alpha-older.jsonl", + }, + }) + }) + + it("enters new-spec title state before emitting a new-spec decision", () => { + const home = buildWorkspaceSelectionView(inventory()) + + expect(selectWorkspaceSelectionOption(home, 1)).toMatchObject({ + view: { stage: "newSpecTitle", title: "", options: [] }, + }) + }) + it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { const options = buildWorkspaceDialogOptions(inventory()) From 47d14831c9bdfd9813b5c6af8e887825132aa591 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:30:12 +0200 Subject: [PATCH 060/164] Render hierarchical spec session picker --- memory/CARDS.md | 2 +- runbooks/verify-startup-no-resume.sh | 6 +- .../workspace-dialog/component.ts | 84 +++++++++++++------ src/workspace-dialog.test.ts | 83 ++++++++++++++++-- 4 files changed, 136 insertions(+), 39 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 6d8ab87f..eded50ee 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -57,7 +57,7 @@ The selection model turns workspace inventory into hierarchical spec/session sta ## Card 2 — Hierarchical TUI spec/session picker -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/runbooks/verify-startup-no-resume.sh b/runbooks/verify-startup-no-resume.sh index 9990abaf..9a8bf44d 100755 --- a/runbooks/verify-startup-no-resume.sh +++ b/runbooks/verify-startup-no-resume.sh @@ -2,7 +2,7 @@ set -euo pipefail # Proves FE-744/I22 at the terminal boundary: Brunch TUI startup shows the -# workspace dialog before any prior transcript is rendered. This runbook uses +# spec/session picker before any prior transcript is rendered. This runbook uses # a real pty via `script`; it is intended as a manual/middle-loop oracle rather # than part of the default verify gate. @@ -50,8 +50,8 @@ if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then exit 1 fi -if ! grep -Eq "Brunch workspace|Choose or create the workspace|New workspace title" "$CAPTURE_STRIPPED"; then - echo "FAILED: startup capture did not show a stable workspace-dialog marker" >&2 +if ! grep -Eq "Choose a specification|Create new specification|New specification title" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show a stable spec/session picker marker" >&2 echo "Capture: $CAPTURE_STRIPPED" >&2 exit 1 fi diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index 9433d86e..f6f0d560 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -17,8 +17,10 @@ import type { WorkspaceSwitchDecision, } from "../../workspace-session-coordinator.js" import { - buildWorkspaceDialogOptions, - type WorkspaceDialogOption, + buildWorkspaceSelectionView, + selectWorkspaceSelectionOption, + type WorkspaceSelectionStage, + type WorkspaceSelectionView, } from "./model.js" export const WORKSPACE_DIALOG_WIDTH = 80 @@ -47,38 +49,41 @@ export function createWorkspaceDialogComponent( } class WorkspaceDialogComponent implements Component { - #options: WorkspaceDialogOption[] + #inventory: WorkspaceLaunchInventory #onDecision: (decision: WorkspaceSwitchDecision) => void #theme: WorkspaceDialogTheme | undefined #selectedIndex = 0 - #mode: "select" | "newSpecTitle" = "select" + #stage: WorkspaceSelectionStage = { stage: "home" } + #history: WorkspaceSelectionStage[] = [] #title = "" constructor(options: WorkspaceDialogComponentOptions) { - this.#options = buildWorkspaceDialogOptions(options.inventory) + this.#inventory = options.inventory this.#onDecision = options.onDecision this.#theme = options.theme } handleInput(data: string): void { - if (this.#mode === "newSpecTitle") { + if (this.#stage.stage === "newSpecTitle") { this.#handleTitleInput(data) return } + const view = this.#view() + if (matchesKey(data, Key.up)) { this.#selectedIndex = Math.max(0, this.#selectedIndex - 1) return } if (matchesKey(data, Key.down)) { this.#selectedIndex = Math.min( - this.#options.length - 1, + view.options.length - 1, this.#selectedIndex + 1, ) return } if (matchesKey(data, Key.escape)) { - this.#onDecision({ action: "cancel" }) + this.#backOrCancel() return } if (matchesKey(data, Key.enter)) { @@ -95,11 +100,12 @@ class WorkspaceDialogComponent implements Component { invalidate(): void {} #contentLines(): string[] { - const title = style(this.#theme, "accent", "Brunch workspace") + const view = this.#view() + const title = style(this.#theme, "accent", view.title) const subtitle = style( this.#theme, "dim", - "Choose or create the workspace before the agent loop runs.", + "Choose or create the spec/session before the agent loop runs.", ) const logo = readLogo() const version = brunchVersion() @@ -109,8 +115,6 @@ class WorkspaceDialogComponent implements Component { ] const piLine = style(this.#theme, "dim", `built on Pi v${PI_VERSION}`) const lines = [ - ...logo, - ...(logo.length > 0 ? [""] : []), ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), "", ...versionLines, @@ -121,13 +125,13 @@ class WorkspaceDialogComponent implements Component { "", ] - if (this.#mode === "newSpecTitle") { - lines.push("New workspace title:", `› ${this.#title}`) + if (this.#stage.stage === "newSpecTitle") { + lines.push("New specification title:", `› ${this.#title}`) lines.push("", style(this.#theme, "dim", "enter create • esc back")) return lines } - for (const [index, option] of this.#options.entries()) { + for (const [index, option] of view.options.entries()) { const selected = index === this.#selectedIndex const prefix = selected ? style(this.#theme, "accent", "› ") : " " const label = selected @@ -140,28 +144,29 @@ class WorkspaceDialogComponent implements Component { "", style(this.#theme, "dim", "↑↓ navigate • enter select • esc cancel"), ) + lines.push("", ...logo) return lines } #selectCurrentOption(): void { - const option = this.#options[this.#selectedIndex] - if (!option) { - return - } - if (option.kind === "newSpec") { - this.#mode = "newSpecTitle" - this.#title = "" + const result = selectWorkspaceSelectionOption( + this.#view(), + this.#selectedIndex, + this.#inventory, + ) + if ("decision" in result) { + this.#onDecision(result.decision) return } - if (option.decision) { - this.#onDecision(option.decision) - } + this.#history.push(this.#stage) + this.#stage = viewToStage(result.view) + this.#selectedIndex = 0 + if (this.#stage.stage === "newSpecTitle") this.#title = "" } #handleTitleInput(data: string): void { if (matchesKey(data, Key.escape)) { - this.#mode = "select" - this.#title = "" + this.#backOrCancel() return } if (matchesKey(data, Key.backspace)) { @@ -179,6 +184,31 @@ class WorkspaceDialogComponent implements Component { this.#title += data } } + + #view(): WorkspaceSelectionView { + return buildWorkspaceSelectionView(this.#inventory, this.#stage) + } + + #backOrCancel(): void { + const previous = this.#history.pop() + if (!previous) { + this.#onDecision({ action: "cancel" }) + return + } + this.#stage = previous + this.#selectedIndex = 0 + this.#title = "" + } +} + +function viewToStage(view: WorkspaceSelectionView): WorkspaceSelectionStage { + if (view.stage === "newSpecTitle") return { stage: "newSpecTitle", title: "" } + if (view.stage === "specAction" && view.specId) + return { stage: "specAction", specId: view.specId } + if (view.stage === "sessionList" && view.specId) + return { stage: "sessionList", specId: view.specId } + if (view.stage === "specList") return { stage: "specList" } + return { stage: "home" } } function renderFrame( diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 7a9fe075..29ef30fd 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -128,16 +128,29 @@ describe("workspace dialog", () => { }) }) - it("selects current resume and existing sessions as typed decisions", () => { + it("renders specification copy without user-created workspace wording", () => { + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + onDecision: () => {}, + }) + + const text = component.render(80).join("\n") + + expect(text).toContain("Choose a specification") + expect(text).toContain("Create new specification") + expect(text).toContain("Resume existing specification") + expect(text).not.toContain("Brunch workspace") + expect(text).not.toContain("Create workspace") + expect(text).not.toContain("Open workspace") + }) + + it("selects current continue as a typed decision", () => { const decisions: unknown[] = [] const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: (decision) => decisions.push(decision), }) - component.handleInput!("\r") - component.handleInput!("\x1B[B") - component.handleInput!("\x1B[B") component.handleInput!("\r") expect(decisions).toEqual([ @@ -146,6 +159,42 @@ describe("workspace dialog", () => { specId: "spec-alpha", sessionFile: "/sessions/alpha-current.jsonl", }, + ]) + }) + + it("returns new-session through the hierarchical keyboard path", () => { + const decisions: unknown[] = [] + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput!("\x1B[B") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + component.handleInput!("\r") + component.handleInput!("\r") + + expect(decisions).toEqual([{ action: "newSession", specId: "spec-alpha" }]) + }) + + it("returns open-session through the hierarchical keyboard path", () => { + const decisions: unknown[] = [] + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput!("\x1B[B") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + component.handleInput!("\r") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + + expect(decisions).toEqual([ { action: "openSession", specId: "spec-alpha", @@ -161,9 +210,7 @@ describe("workspace dialog", () => { onDecision: (decision) => decisions.push(decision), }) - for (let index = 0; index < 5; index += 1) { - component.handleInput!("\x1B[B") - } + component.handleInput!("\x1B[B") component.handleInput!("\r") for (const char of "Gamma") { component.handleInput!(char) @@ -181,6 +228,24 @@ describe("workspace dialog", () => { ]) }) + it("backs out one picker stage on escape and cancels from the home stage", () => { + const decisions: unknown[] = [] + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput!("\x1B[B") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + expect(component.render(80).join("\n")).toContain("Choose a specification") + component.handleInput!("\x1B") + expect(component.render(80).join("\n")).toContain("Continue last session") + component.handleInput!("\x1B") + + expect(decisions).toEqual([{ action: "cancel" }]) + }) + it("renders a branded centered-dialog frame with version metadata", () => { const component = createWorkspaceDialogComponent({ inventory: inventory(), @@ -191,7 +256,9 @@ describe("workspace dialog", () => { expect(lines[0]).toContain("╭") expect(lines[1]).toMatch(/^│\s+│$/) - expect(lines.some((line) => line.includes("Brunch workspace"))).toBe(true) + expect(lines.some((line) => line.includes("Choose a specification"))).toBe( + true, + ) expect(lines.some((line) => line.includes("brunch v0.0.0"))).toBe(true) expect(lines.some((line) => line.includes("(dev"))).toBe(true) expect(lines.some((line) => line.includes("built on Pi v"))).toBe(true) From a3dcec2cbdd90f00a302907574d3daacbe0d6831 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:33:33 +0200 Subject: [PATCH 061/164] Expose RPC spec session activation --- memory/CARDS.md | 2 +- src/rpc.test.ts | 153 +++++++++++++++++++++++++++++++++++++++++++++++- src/rpc.ts | 125 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 275 insertions(+), 5 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index eded50ee..a2083ca2 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -105,7 +105,7 @@ The startup and in-session TUI picker renders the hierarchical spec/session flow ## Card 3 — RPC/headless initial selection contract -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/rpc.test.ts b/src/rpc.test.ts index bb268488..f2c670c0 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { PassThrough } from "node:stream" @@ -12,22 +12,80 @@ import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinat import { assistantMessage, userMessage } from "./test-helpers.js" import type { DefaultWorkspaceCoordinator, + WorkspaceActivationState, + WorkspaceLaunchInventory, + WorkspaceSessionReadyState, WorkspaceSessionState, + WorkspaceSwitchCoordinator, + WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" function coordinator( state: WorkspaceSessionState = readyState( "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", ), -): DefaultWorkspaceCoordinator { +): DefaultWorkspaceCoordinator & WorkspaceSwitchCoordinator { + const inventory = launchInventory() return { async openDefaultWorkspace() { return state }, + async inspectWorkspace() { + return inventory + }, + async activateWorkspace( + decision: WorkspaceSwitchDecision, + ): Promise<WorkspaceActivationState> { + if (decision.action === "cancel") return cancelledState() + return readyState("/tmp/brunch-project/.brunch/sessions/session-1.jsonl") + }, + } +} + +function launchInventory(): WorkspaceLaunchInventory { + return { + cwd: "/tmp/brunch-project", + currentSpec: { id: "spec-1", title: "Alpha spec" }, + currentSessionFile: "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", + needsNewSpec: false, + specs: [ + { + spec: { id: "spec-1", title: "Alpha spec" }, + sessions: [ + { + id: "session-1", + file: "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", + specId: "spec-1", + specTitle: "Alpha spec", + available: true, + }, + ], + }, + ], + unavailableSessions: [ + { + file: "/tmp/missing.jsonl", + reason: "missing_header", + available: false, + }, + ], } } -function readyState(sessionFile: string): WorkspaceSessionState { +function cancelledState(): WorkspaceActivationState { + return { + status: "cancelled", + cwd: "/tmp/brunch-project", + chrome: { + cwd: "/tmp/brunch-project", + spec: { id: "spec-1", title: "Alpha spec" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }, + } +} + +function readyState(sessionFile: string): WorkspaceSessionReadyState { return { status: "ready", cwd: "/tmp/brunch-project", @@ -118,6 +176,95 @@ function sessionBindingEntry(sessionId = "session-1", specId = "spec-1") { } describe("JSON-RPC handlers", () => { + it("serves structured workspace selection state without invoking the TUI picker", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 20, + method: "workspace.selectionState", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 20, + result: { + status: "select_spec", + requiresSelection: true, + cwd: "/tmp/brunch-project", + currentSpec: { id: "spec-1", title: "Alpha spec" }, + currentSessionFile: + "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", + specs: [{ spec: { id: "spec-1" }, sessions: [{ id: "session-1" }] }], + unavailableSessions: [{ reason: "missing_header" }], + }, + }) + }) + + it("activates valid workspace decisions and returns a serializable product snapshot", async () => { + const decisions: WorkspaceSwitchDecision[] = [] + const handlers = createRpcHandlers({ + cwd: "/tmp/brunch-project", + coordinator: { + ...coordinator(), + async activateWorkspace(decision): Promise<WorkspaceActivationState> { + decisions.push(decision) + return readyState( + "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", + ) + }, + }, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 21, + method: "workspace.activate", + params: { decision: { action: "newSession", specId: "spec-1" } }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 21, + result: { + status: "ready", + spec: { id: "spec-1" }, + session: { id: "session-1" }, + }, + }) + expect(decisions).toEqual([{ action: "newSession", specId: "spec-1" }]) + }) + + it("rejects invalid workspace activation params", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 22, + method: "workspace.activate", + params: { decision: { action: "openSession", specId: "spec-1" } }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 22, + error: { code: -32602, message: "Invalid params" }, + }) + }) + + it("keeps RPC initial selection independent from TUI picker imports", async () => { + const source = await readFile(new URL("./rpc.ts", import.meta.url), "utf8") + + expect(source).not.toContain("workspace-dialog") + expect(source).not.toContain("createWorkspaceDialogComponent") + }) + it("serves a named workspace snapshot method", async () => { const handlers = createRpcHandlers({ coordinator: coordinator(), diff --git a/src/rpc.ts b/src/rpc.ts index dc74ed45..172acee1 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -27,7 +27,11 @@ import { } from "./session-projection-reader.js" import type { DefaultWorkspaceCoordinator, + WorkspaceActivationState, + WorkspaceLaunchInventory, WorkspaceSessionState, + WorkspaceSwitchCoordinator, + WorkspaceSwitchDecision, } from "./workspace-session-coordinator.js" export interface RpcHandlers { @@ -35,7 +39,7 @@ export interface RpcHandlers { } export function createRpcHandlers(options: { - coordinator: DefaultWorkspaceCoordinator + coordinator: DefaultWorkspaceCoordinator & Partial<WorkspaceSwitchCoordinator> cwd: string }): RpcHandlers { return { @@ -57,6 +61,40 @@ export function createRpcHandlers(options: { ) } + if (request.method === "workspace.selectionState") { + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, "Invalid params") + } + if (!options.coordinator.inspectWorkspace) { + return createJsonRpcFailure(requestId, -32603, "Internal error") + } + const [state, inventory] = await Promise.all([ + options.coordinator.openDefaultWorkspace(), + options.coordinator.inspectWorkspace(), + ]) + return createJsonRpcSuccess( + requestId, + workspaceSelectionStateFromInventory(state, inventory), + ) + } + + if (request.method === "workspace.activate") { + const decision = parseWorkspaceActivationParams(request.params) + if (!decision.ok) { + return createJsonRpcFailure(requestId, -32602, "Invalid params") + } + if (!options.coordinator.activateWorkspace) { + return createJsonRpcFailure(requestId, -32603, "Internal error") + } + const state = await options.coordinator.activateWorkspace( + decision.value, + ) + return createJsonRpcSuccess( + requestId, + workspaceActivationSnapshotFromState(state), + ) + } + if (request.method === "session.elicitationExchanges") { return handleSessionProjection( requestId, @@ -80,6 +118,91 @@ export function createRpcHandlers(options: { } } +function workspaceSelectionStateFromInventory( + state: WorkspaceSessionState, + inventory: WorkspaceLaunchInventory, +): WorkspaceLaunchInventory & { + status: WorkspaceSessionState["status"] + requiresSelection: boolean +} { + return { + ...inventory, + status: state.status, + requiresSelection: state.status !== "ready", + } +} + +function workspaceActivationSnapshotFromState( + state: WorkspaceActivationState, +): ReturnType<typeof workspaceSnapshotFromState> | { + status: "cancelled" + cwd: string + spec: WorkspaceActivationState["chrome"]["spec"] + chrome: { + phase: "select_spec" | "elicitation" + chatMode: "select-spec" | "responding-to-elicitation" + } +} { + if (state.status === "cancelled") { + return { + status: "cancelled", + cwd: state.cwd, + spec: state.chrome.spec, + chrome: { + phase: state.chrome.phase, + chatMode: state.chrome.chatMode, + }, + } + } + return workspaceSnapshotFromState(state) +} + +type WorkspaceActivationParamsParseResult = { + ok: true + value: WorkspaceSwitchDecision +} | { ok: false } + +function parseWorkspaceActivationParams( + value: unknown, +): WorkspaceActivationParamsParseResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { ok: false } + } + const decision = (value as { decision?: unknown }).decision + if ( + typeof decision !== "object" || + decision === null || + Array.isArray(decision) + ) { + return { ok: false } + } + const action = (decision as { action?: unknown }).action + if (action === "cancel") return { ok: true, value: { action } } + if (action === "newSpec") { + const title = (decision as { title?: unknown }).title + return typeof title === "string" && title.trim().length > 0 + ? { ok: true, value: { action, title } } + : { ok: false } + } + if (action === "newSession") { + const specId = (decision as { specId?: unknown }).specId + return typeof specId === "string" && specId.length > 0 + ? { ok: true, value: { action, specId } } + : { ok: false } + } + if (action === "continue" || action === "openSession") { + const specId = (decision as { specId?: unknown }).specId + const sessionFile = (decision as { sessionFile?: unknown }).sessionFile + return typeof specId === "string" && + specId.length > 0 && + typeof sessionFile === "string" && + sessionFile.length > 0 + ? { ok: true, value: { action, specId, sessionFile } } + : { ok: false } + } + return { ok: false } +} + async function handleSessionProjection<T>( requestId: JsonRpcId, rawParams: unknown, From 02c52a01500d87965468e40fc3388978224d41c1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:36:07 +0200 Subject: [PATCH 062/164] Retire workspace picker wording --- docs/architecture/pi-ui-extension-patterns.md | 18 +- memory/CARDS.md | 184 ------------------ memory/PLAN.md | 4 +- memory/SPEC.md | 2 +- src/brunch-tui.test.ts | 10 +- src/pi-components/workspace-dialog/model.ts | 12 +- src/pi-extensions/workspace-dialog.ts | 12 +- src/workspace-dialog.test.ts | 8 +- 8 files changed, 33 insertions(+), 217 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index c46271ca..e2a39ff9 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -12,8 +12,8 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | -| Startup workspace dialog | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | -| In-session workspace dialog command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable workspace selection beyond startup | Brunch extension command tests + coordinator store oracle | +| Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | +| In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | | Structured-question response loop | feasible but not Brunch-proven | required before M5 lens/review affordances depend on structured elicitation | Pi `question`/`questionnaire` examples + RPC UI demo; Brunch proof pending | ## Evidence inventory @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the workspace dialog (`workspace-dialog.ts` plus `src/pi-components/workspace-dialog/*`), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; workspace dialog tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/pi-components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. @@ -125,7 +125,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/pi-ext - `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. - `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. - `command-policy.ts` owns branch/session effect blocking for unsupported Pi flows. -- `workspace-dialog.ts` owns `/brunch`, `ctrl+shift+b`, and the in-session workspace-dialog activation adapter. +- `workspace-dialog.ts` owns `/brunch`, `ctrl+shift+b`, and the in-session spec/session picker activation adapter. - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. - `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/pi-components/*`. @@ -175,11 +175,11 @@ Runtime should **not** invoke Chafa on startup. The logo should be deterministic ## Workspace dialog implementation evidence -Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure `workspace-dialog` UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. +Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure spec/session picker UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. -The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains workspace-dialog markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. +The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains spec/session picker markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. -The in-session product command is `/brunch` with `ctrl+shift+b`. It waits for idle, inspects inventory, renders the same typed centered workspace dialog with `ctx.ui.custom(..., { overlay: true })`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins; it is the Brunch-owned workspace adapter over Pi's session-replacement API. +The in-session product command is `/brunch` with `ctrl+shift+b`. It waits for idle, inspects inventory, renders the same typed centered spec/session picker with `ctx.ui.custom(..., { overlay: true })`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins; it is the Brunch-owned workspace adapter over Pi's session-replacement API. ## Pi example evidence not yet Brunch integration proof @@ -187,7 +187,7 @@ Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance desig | Example/source affordance | Evidence status | Brunch interpretation | | --- | --- | --- | -| `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom workspace decisions so far. | +| `question` / `questionnaire` typed UI patterns | Pi example/source evidence | Suitable model for future structured elicitation/review surfaces; Brunch has only proven typed custom spec/session decisions so far. | | `shutdown-command` | Pi example evidence | Confirms commands can drive lifecycle actions; Brunch has not added a product shutdown command beyond allowing Pi quit. | | `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-dialog proof. | | `titlebar-spinner` / working indicator examples | Pi example evidence only | Brunch leaves Pi's working indicator untouched; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | @@ -245,7 +245,7 @@ The seam Brunch must still prove is the composition: assistant tool/custom promp | --- | --- | --- | | Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | | Registered structured-question tool seam | Pi examples prove tool-call / `toolResult.details` capture; Brunch projection does not yet classify terminal structured tool results as response-side entries. | Prefer the thinnest Pi-supported transcript seam for basic questions/questionnaires; make `toolResult.details` self-contained enough for Brunch projection. | -| TUI input replacement | Pi examples prove `ctx.ui.custom()` component replacement; Brunch has proven it only for workspace decisions. | Build a Brunch-owned response helper over single-select, multi-select, questionnaire, and freeform-plus-choice patterns. | +| TUI input replacement | Pi examples prove `ctx.ui.custom()` component replacement; Brunch has proven it only for spec/session decisions. | Build a Brunch-owned response helper over single-select, multi-select, questionnaire, and freeform-plus-choice patterns. | | JSON-editor RPC fallback | Pi RPC supports `editor`; Brunch has not yet wrapped schema-tagged JSON editor requests as product pending-elicitation state. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index a2083ca2..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,184 +0,0 @@ -# Scope cards — FE-744 spec/session picker correction - -Status key: `next` / `in progress` / `done` / `dropped`. - -## Orientation - -- **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), specifically the Brunch-owned startup/in-session selection seam over Pi TUI extension affordances. -- **Canonical model:** SPEC D11-L / D36-L: `workspace(cwd) → spec → session`; workspace is cwd scope, not a user-created object; spec/session selection is Brunch-owned before agent loop entry. -- **Volatile state:** The current implementation still lives under `workspace-dialog` file/module names and renders a flat list with labels like “Start new session in X” / “Open X” / “Create workspace”. Those names are implementation lag, not product vocabulary. -- **Main open risk:** The TUI redesign must improve hierarchy without coupling UI components to session creation/opening; the RPC/headless path must expose equivalent activation decisions without invoking TUI picker code. -- **Cross-cutting obligations:** Preserve linear transcript policy (D24-L/I19-L), coordinator-owned activation and session binding (D21-L/I8-L/I22-L), no implicit transcript resume before explicit TUI activation (D22-L/I22-L), and RPC/headless non-TUI startup selection (D36-L/I22-L). - ---- - -## Card 1 — Pure spec/session selection model - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The selection model turns workspace inventory into hierarchical spec/session stages whose top-level actions are `continue last session`, `create new spec`, `resume existing spec`, and `cancel` without listing individual specs as top-level actions. - -### Boundary Crossings - -```text -→ WorkspaceLaunchInventory -→ src/pi-components/workspace-dialog/model.ts selection-state/model helpers -→ WorkspaceSwitchDecision values consumed by coordinator/TUI adapters -``` - -### Risks and Assumptions - -- RISK: Trying to rename every `workspace-*` implementation symbol in the same slice creates noisy churn. → MITIGATION: Fix product-facing labels and model shape first; leave file/module renames to a later cleanup unless they block clarity. -- RISK: The existing flat `WorkspaceDialogOption[]` shape may not express nested screens cleanly. → MITIGATION: Replace or wrap it with explicit stage/view data (`home`, `newSpecTitle`, `specList`, `specAction`, `sessionList`) while keeping `WorkspaceSwitchDecision` as the activation boundary. -- ASSUMPTION: Existing coordinator decision variants are sufficient for the new hierarchy. → VALIDATE: Model tests prove new-spec, new-session, open-session, continue, and cancel all still produce existing `WorkspaceSwitchDecision` variants. - -### Acceptance Criteria - -✓ `src/workspace-dialog.test.ts` — inventory with a valid selected session produces a home stage containing a continue-last option, create-new-spec, resume-existing-spec, and cancel; it does not include `resume spec X` / `open X` / per-spec labels at top level. -✓ `src/workspace-dialog.test.ts` — selecting `resume existing spec` yields a spec-list stage populated by existing specs; selecting a spec yields a stage with `create new session` and `resume existing session`. -✓ `src/workspace-dialog.test.ts` — selecting `resume existing session` yields a session-list stage for the chosen spec and returns `openSession` only after a session is chosen. -✓ `src/workspace-dialog.test.ts` — selecting `create new spec` enters title-entry state and returns `newSpec` with the entered title; no session-selection step is required for this path. - -### Verification Approach - -- Inner: Unit tests over the pure selection model — prove hierarchy, labels, and decision mapping independent of terminal rendering. -- Middle: Architectural boundary assertion in tests — model emits decisions only; it does not call coordinator/session APIs or mutate `.brunch/state.json`. - -### Cross-cutting obligations - -- Keep `WorkspaceSessionCoordinator` as the only owner of activation, session creation/opening, `.brunch/state.json`, and `brunch.session_binding` writes. -- Keep `WorkspaceSwitchDecision` product-shaped and transport-neutral so TUI and RPC/headless activation can share it. -- Retire stale user-facing “workspace” wording in model labels/descriptions touched by this slice. - ---- - -## Card 2 — Hierarchical TUI spec/session picker - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The startup and in-session TUI picker renders the hierarchical spec/session flow with a continue-last fast path and navigates through each stage using keyboard input. - -### Boundary Crossings - -```text -→ createWorkspaceDialogComponent(options) -→ selection model from Card 1 -→ @earendil-works/pi-tui Component render/handleInput -→ runWorkspaceDialogPreflight / ctx.ui.custom overlay adapters -→ WorkspaceSwitchDecision callback -``` - -### Risks and Assumptions - -- RISK: Multi-screen state can become a local UI state machine that diverges from the pure model. → MITIGATION: Keep screen/view derivation in the model module where possible; component stores only current stage, selected index, and text input. -- RISK: Scrollable spec/session lists may be more work than needed for first pass. → MITIGATION: Implement bounded visible-window scrolling only if list length exceeds available content height; otherwise keep list rendering simple but ensure selected index can move through all entries. -- RISK: Current tests assume flat-list arrow counts. → MITIGATION: Replace those tests with stage-by-stage input tests matching the new hierarchy. - -### Acceptance Criteria - -✓ `src/workspace-dialog.test.ts` — rendered copy says “Choose a specification” / “Create new specification” / “Resume existing specification” and does not say “Brunch workspace”, “Create workspace”, or “Open workspace” in user-facing text. -✓ `src/workspace-dialog.test.ts` — pressing Enter on continue-last returns the existing `continue` decision when valid prior state exists. -✓ `src/workspace-dialog.test.ts` — keyboard path `resume existing specification → choose spec → create new session` returns `newSession` for that spec. -✓ `src/workspace-dialog.test.ts` — keyboard path `resume existing specification → choose spec → resume existing session → choose session` returns `openSession` for that session. -✓ `src/workspace-dialog.test.ts` — escape backs out one picker stage where possible and cancels from the home stage. -✓ `src/brunch-tui.test.ts` — startup preflight and in-session overlay still pass the same overlay width/lifecycle expectations and clear after decision. - -### Verification Approach - -- Inner: Component render/input tests — prove keyboard navigation, visible labels, and decision callbacks. -- Middle: Existing startup preflight lifecycle test — proves no stale overlay remains after activation. -- Outer: Manual/pty smoke after build — launch `brunch-next` in a scratch cwd with multiple specs/sessions and capture that no prior transcript renders before explicit continue/open. - -### Cross-cutting obligations - -- Preserve the startup invariant: no prior transcript or agent loop before explicit activation. -- Preserve shared startup/in-session component reuse; adapters may differ only in terminal lifecycle and Pi session replacement mechanics. -- Keep copy aligned to SPEC lexicon: workspace = cwd label only; spec/session are the user choices. - ---- - -## Card 3 — RPC/headless initial selection contract - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -RPC mode exposes initial spec/session selection as structured JSON-RPC state and activation methods without constructing or invoking the TUI picker. - -### Boundary Crossings - -```text -→ brunch --mode rpc / createRpcHandlers -→ WorkspaceSwitchCoordinator.inspectWorkspace / activateWorkspace -→ JSON-RPC method family -→ product-shaped selection/inventory and activation responses -``` - -### Risks and Assumptions - -- RISK: Reusing `workspace.snapshot` for activation would blur read vs mutation behavior. → MITIGATION: Add explicit method names, e.g. `workspace.selectionState` for inventory/requirements and `workspace.activate` for submitting a `WorkspaceSwitchDecision`. -- RISK: JSON-RPC params may accidentally accept impossible decision shapes. → MITIGATION: Add narrow runtime parsing for `continue`, `openSession`, `newSession`, `newSpec`, and `cancel` decisions; invalid params return `-32602`. -- RISK: Activation can return a ready state containing non-serializable `SessionManager`. → MITIGATION: Return a serializable snapshot/activation DTO derived from `WorkspaceActivationState`, not the raw state object. - -### Acceptance Criteria - -✓ `src/rpc.test.ts` — `workspace.selectionState` returns cwd, current spec/session acceleration, specs/sessions inventory, unavailable sessions, and a `requiresSelection`/status field when no ready default exists. -✓ `src/rpc.test.ts` — `workspace.activate` accepts `newSpec`, `newSession`, `openSession`, `continue`, and `cancel` decision params and delegates to `coordinator.activateWorkspace` without importing or constructing the TUI picker/component. -✓ `src/rpc.test.ts` — successful activation returns a serializable product snapshot including selected spec/session ids and status; needs-human/cancelled activation returns structured reason/status without switching sessions. -✓ `src/rpc.test.ts` — invalid activation params return JSON-RPC `-32602` and unknown methods still return `-32601`. - -### Verification Approach - -- Inner: JSON-RPC handler contract tests — prove method names, param validation, coordinator delegation, and serializable responses. -- Middle: Architectural import/boundary test or source assertion — RPC module does not import `pi-components/workspace-dialog` or TUI picker code. - -### Cross-cutting obligations - -- RPC/headless must not invoke TUI picker code; it exposes the same product selection requirement and activation decisions through JSON-RPC. -- Keep transport modes distinct from product state: RPC connections are client attachments, not sessions. -- Keep coordinator as the only activation/session-binding writer. - ---- - -## Card 4 — Terminology cleanup and compatibility retirement - -**Status:** next -**Weight:** light scope card - -### Objective - -Remove stale user-facing “workspace dialog/switcher” terminology from tests, descriptions, commands, and documentation-adjacent strings touched by the picker work while preserving stable internal APIs unless renaming is cheap. - -### Acceptance Criteria - -✓ User-facing command/shortcut descriptions say “Open the Brunch spec/session picker” or equivalent, not “workspace dialog”. -✓ Tests assert the new lexicon for visible UI text and no longer expect “Create workspace” / “Brunch workspace”. -✓ Any implementation names left as `workspace-dialog` are either private/file-path compatibility or explicitly deferred; no product copy depends on them. - -### Verification Approach - -- Inner: `rg` checks plus existing unit tests. -- Middle: Manual screenshot/smoke review for startup and Ctrl-Shift-B copy. - -### Cross-cutting obligations - -- Do not rename public/product decision variants purely for aesthetics if doing so would create avoidable churn for coordinator/RPC clients. -- Delete obsolete copy/tests rather than preserving aliases for old “workspace” wording. - -### Promotion checklist - -- [ ] Does this change a requirement? No — SPEC already changed; this card implements terminology cleanup. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No, if kept to user-facing strings/tests. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/memory/PLAN.md b/memory/PLAN.md index b5237906..ff40ca05 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -237,7 +237,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome, workspace-dialog startup flow, in-session workspace command, pty startup oracle, centered branded overlay reuse, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) +- **Status:** in-progress (command-containment, dynamic chrome, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -245,7 +245,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Scope the spec/session picker correction before the structured-question spike: update terminology and interaction shape, preserve the startup/in-session shared component, and add RPC/headless non-TUI initial-selection coverage. Then scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes `workspace.selectionState` / `workspace.activate` without importing TUI picker code, and the startup no-resume pty oracle passes with the new spec/session copy. Next scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 11b4ad51..b5bb7087 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -239,7 +239,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | -| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered for current startup-switcher behavior (FE-744 coordinator tests plus `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation); planned for hierarchical picker and RPC/headless non-TUI startup coverage | D11-L, D21-L, D22-L, D36-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index e69df8c3..a7040219 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -356,7 +356,7 @@ describe("Brunch TUI boot", () => { expect(titles).toEqual(["brunch — Spec One"]) }) - it("registers the Brunch workspace command and shortcut", async () => { + it("registers the Brunch spec/session picker command and shortcut", async () => { const commands = new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() const shortcuts = @@ -395,17 +395,17 @@ describe("Brunch TUI boot", () => { "present_alternatives", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( - "Open the Brunch workspace dialog", + "Open the Brunch spec/session picker", ) const retiredWorkspaceCommand = ["brunch", "workspace"].join("-") expect(commands.has(retiredWorkspaceCommand)).toBe(false) expect(shortcuts.get(BRUNCH_WORKSPACE_SHORTCUT)?.description).toBe( - "Open the Brunch workspace dialog", + "Open the Brunch spec/session picker", ) expect(shortcuts.has("ctrl+b")).toBe(false) }) - it("opens the workspace dialog from the Brunch command", async () => { + it("opens the spec/session picker from the Brunch command", async () => { const events: string[] = [] const target = readyWorkspace("/tmp/project", "session-target") const ctx = fakeCommandContext({ @@ -497,7 +497,7 @@ describe("Brunch TUI boot", () => { ]) }) - it("opens the workspace dialog from shortcut contexts without waitForIdle", async () => { + it("opens the spec/session picker from shortcut contexts without waitForIdle", async () => { const events: string[] = [] const target = readyWorkspace("/tmp/project", "session-target") const ctx = fakeCommandContext({ diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index 429d147e..20ebb3d3 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -215,7 +215,7 @@ export function buildWorkspaceDialogOptions( for (const { spec, sessions } of inventory.specs) { options.push({ id: `new-session:${spec.id}`, - label: `Start new session in ${spec.title}`, + label: `Create new session for ${spec.title}`, description: "Create a binding-only session before Pi starts", kind: "newSession", decision: { action: "newSession", specId: spec.id }, @@ -227,8 +227,8 @@ export function buildWorkspaceDialogOptions( } options.push({ id: `open:${session.file}`, - label: `Open ${spec.title}`, - description: sessionDescription(session, "Open existing session"), + label: `Resume ${spec.title}`, + description: sessionDescription(session, "Resume existing session"), kind: "openSession", decision: { action: "openSession", @@ -241,14 +241,14 @@ export function buildWorkspaceDialogOptions( options.push({ id: "new-spec", - label: "Create workspace", - description: "Name a new specification workspace", + label: "Create new specification", + description: "Name a new spec and create its first session", kind: "newSpec", }) options.push({ id: "cancel", label: "Cancel", - description: "Exit without opening a Brunch workspace", + description: "Exit without activating a spec/session", kind: "cancel", decision: { action: "cancel" }, }) diff --git a/src/pi-extensions/workspace-dialog.ts b/src/pi-extensions/workspace-dialog.ts index 5978b890..c8970910 100644 --- a/src/pi-extensions/workspace-dialog.ts +++ b/src/pi-extensions/workspace-dialog.ts @@ -26,13 +26,13 @@ export function registerBrunchWorkspaceDialog( { coordinator }: BrunchWorkspaceDialogOptions, ): void { pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { - description: "Open the Brunch workspace dialog", + description: "Open the Brunch spec/session picker", handler: async (_args, ctx) => { await runBrunchWorkspaceCommand(ctx, coordinator) }, }) pi.registerShortcut?.(BRUNCH_WORKSPACE_SHORTCUT, { - description: "Open the Brunch workspace dialog", + description: "Open the Brunch spec/session picker", handler: async (ctx) => { await runBrunchWorkspaceCommand( ctx as ExtensionCommandContext, @@ -74,7 +74,7 @@ export async function runBrunchWorkspaceAction( const activated = await coordinator.activateWorkspace(decision) if (activated.status === "cancelled") { - ctx.ui.notify("Workspace switch cancelled.", "info") + ctx.ui.notify("Spec/session switch cancelled.", "info") return } if (activated.status === "needs_human") { @@ -98,7 +98,7 @@ async function switchToActivatedWorkspace( const targetFile = activated.session.file if (ctx.sessionManager.getSessionFile() === targetFile) { renderBrunchChrome(ctx.ui, chromeStateForWorkspace(activated)) - ctx.ui.notify("Already using the selected Brunch workspace.", "info") + ctx.ui.notify("Already using the selected Brunch spec/session.", "info") return } @@ -110,13 +110,13 @@ async function switchToActivatedWorkspace( withSession: async (replacementCtx) => { renderBrunchChrome(replacementCtx.ui, targetChrome) replacementCtx.ui.notify( - `Switched Brunch workspace to ${targetSpecTitle} (${targetSessionId}).`, + `Switched Brunch spec/session to ${targetSpecTitle} (${targetSessionId}).`, "info", ) }, }) if (result.cancelled) { - ctx.ui.notify("Workspace switch was cancelled by Pi.", "warning") + ctx.ui.notify("Spec/session switch was cancelled by Pi.", "warning") } } diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 29ef30fd..149d82e7 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -13,7 +13,7 @@ import { } from "./pi-components/workspace-dialog/index.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" -describe("workspace dialog", () => { +describe("spec/session picker", () => { it("builds a hierarchical spec/session selection home without per-spec top-level actions", () => { const view = buildWorkspaceSelectionView(inventory()) @@ -119,7 +119,7 @@ describe("workspace dialog", () => { }, }) expect(options.at(-2)).toMatchObject({ - label: "Create workspace", + label: "Create new specification", }) expect(options.at(-2)).not.toHaveProperty("decision") expect(options.at(-1)).toMatchObject({ @@ -265,7 +265,7 @@ describe("workspace dialog", () => { expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true) }) - it("keeps logo assets colocated with the workspace dialog component", async () => { + it("keeps logo assets colocated with the private picker component", async () => { const source = await readFile( new URL( "./pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", @@ -285,7 +285,7 @@ describe("workspace dialog", () => { expect(manifest.dependencies).toHaveProperty("@earendil-works/pi-tui") }) - it("clears the startup preflight frame after a workspace decision", async () => { + it("clears the startup preflight frame after a spec/session decision", async () => { const terminal = new FakeTerminal() const decision = runWorkspaceDialogPreflight(inventory(), { terminal }) From 007526d179c222fc2a55214fdd167bfe957da63c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:46:36 +0200 Subject: [PATCH 063/164] Adjust spec session picker UX --- src/brunch-tui.test.ts | 11 ++++++ .../workspace-dialog/component.ts | 8 +++- src/pi-components/workspace-dialog/model.ts | 37 ++++++++++++++----- src/pi-extensions/workspace-dialog.ts | 20 ++++++++-- src/workspace-dialog.test.ts | 28 +++++++++++--- 5 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index a7040219..6f120115 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -403,6 +403,17 @@ describe("Brunch TUI boot", () => { "Open the Brunch spec/session picker", ) expect(shortcuts.has("ctrl+b")).toBe(false) + + const shortcutEvents: string[] = [] + const shortcut = shortcuts.get(BRUNCH_WORKSPACE_SHORTCUT) + expect(shortcut).toBeDefined() + const shortcutHandler = shortcut!.handler as ( + ctx: unknown, + ) => Promise<void> | void + await shortcutHandler({ + ui: fakeUi((method, type) => shortcutEvents.push(`${method}:${type}`)), + }) + expect(shortcutEvents).toEqual(["notify:warning"]) }) it("opens the spec/session picker from the Brunch command", async () => { diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index f6f0d560..489d0732 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -40,6 +40,7 @@ export interface WorkspaceDialogComponentOptions { inventory: WorkspaceLaunchInventory onDecision: (decision: WorkspaceSwitchDecision) => void theme?: WorkspaceDialogTheme + includeContinue?: boolean } export function createWorkspaceDialogComponent( @@ -52,6 +53,7 @@ class WorkspaceDialogComponent implements Component { #inventory: WorkspaceLaunchInventory #onDecision: (decision: WorkspaceSwitchDecision) => void #theme: WorkspaceDialogTheme | undefined + #includeContinue: boolean #selectedIndex = 0 #stage: WorkspaceSelectionStage = { stage: "home" } #history: WorkspaceSelectionStage[] = [] @@ -61,6 +63,7 @@ class WorkspaceDialogComponent implements Component { this.#inventory = options.inventory this.#onDecision = options.onDecision this.#theme = options.theme + this.#includeContinue = options.includeContinue ?? true } handleInput(data: string): void { @@ -153,6 +156,7 @@ class WorkspaceDialogComponent implements Component { this.#view(), this.#selectedIndex, this.#inventory, + { includeContinue: this.#includeContinue }, ) if ("decision" in result) { this.#onDecision(result.decision) @@ -186,7 +190,9 @@ class WorkspaceDialogComponent implements Component { } #view(): WorkspaceSelectionView { - return buildWorkspaceSelectionView(this.#inventory, this.#stage) + return buildWorkspaceSelectionView(this.#inventory, this.#stage, { + includeContinue: this.#includeContinue, + }) } #backOrCancel(): void { diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index 20ebb3d3..a96d3321 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -39,6 +39,10 @@ export interface WorkspaceSelectionView { specId?: string } +export interface WorkspaceSelectionViewOptions { + includeContinue?: boolean +} + export type WorkspaceSelectionResult = { decision: WorkspaceSwitchDecision } | { view: WorkspaceSelectionView } @@ -46,6 +50,7 @@ export type WorkspaceSelectionResult = { decision: WorkspaceSwitchDecision } | { export function buildWorkspaceSelectionView( inventory: WorkspaceLaunchInventory, stage: WorkspaceSelectionStage = { stage: "home" }, + options: WorkspaceSelectionViewOptions = {}, ): WorkspaceSelectionView { if (stage.stage === "newSpecTitle") { return { @@ -116,13 +121,14 @@ export function buildWorkspaceSelectionView( } } - return buildHomeSelectionView(inventory) + return buildHomeSelectionView(inventory, options) } export function selectWorkspaceSelectionOption( view: WorkspaceSelectionView, index: number, inventory?: WorkspaceLaunchInventory, + options: WorkspaceSelectionViewOptions = {}, ): WorkspaceSelectionResult { const option = view.options[index] if (!option) return { decision: { action: "cancel" } } @@ -130,7 +136,9 @@ export function selectWorkspaceSelectionOption( if (!inventory) { return { view: stageOnlyView(option.nextStage ?? { stage: "home" }) } } - return { view: buildWorkspaceSelectionView(inventory, option.nextStage) } + return { + view: buildWorkspaceSelectionView(inventory, option.nextStage, options), + } } function stageOnlyView(stage: WorkspaceSelectionStage): WorkspaceSelectionView { @@ -144,14 +152,19 @@ function stageOnlyView(stage: WorkspaceSelectionStage): WorkspaceSelectionView { function buildHomeSelectionView( inventory: WorkspaceLaunchInventory, + viewOptions: WorkspaceSelectionViewOptions, ): WorkspaceSelectionView { - const options: WorkspaceSelectionOption[] = [] + const selectionOptions: WorkspaceSelectionOption[] = [] const currentSession = findCurrentSession(inventory) - if (currentSession && inventory.currentSpec) { - options.push({ + if ( + viewOptions.includeContinue !== false && + currentSession && + inventory.currentSpec + ) { + selectionOptions.push({ id: `continue:${currentSession.file}`, - label: "Continue last session", + label: "Continue your latest spec and session", description: `${inventory.currentSpec.title} · ${currentSession.id}`, kind: "continue", decision: { @@ -162,17 +175,17 @@ function buildHomeSelectionView( }) } - options.push( + selectionOptions.push( { id: "new-spec", - label: "Create new specification", + label: "Start a new specification", description: "Name a new spec and create its first session", kind: "newSpec", nextStage: { stage: "newSpecTitle", title: "" }, }, { id: "resume-spec", - label: "Resume existing specification", + label: "Continue an existing specification", description: "Choose a spec, then create or resume a session", kind: "resumeSpec", nextStage: { stage: "specList" }, @@ -186,7 +199,11 @@ function buildHomeSelectionView( }, ) - return { stage: "home", title: "Choose a specification", options } + return { + stage: "home", + title: "Choose a specification", + options: selectionOptions, + } } export function buildWorkspaceDialogOptions( diff --git a/src/pi-extensions/workspace-dialog.ts b/src/pi-extensions/workspace-dialog.ts index c8970910..d360ace2 100644 --- a/src/pi-extensions/workspace-dialog.ts +++ b/src/pi-extensions/workspace-dialog.ts @@ -34,9 +34,9 @@ export function registerBrunchWorkspaceDialog( pi.registerShortcut?.(BRUNCH_WORKSPACE_SHORTCUT, { description: "Open the Brunch spec/session picker", handler: async (ctx) => { - await runBrunchWorkspaceCommand( - ctx as ExtensionCommandContext, - coordinator, + ctx.ui.notify( + "Use /brunch to switch specs or sessions; Pi shortcut contexts cannot switch sessions yet.", + "warning", ) }, }) @@ -60,7 +60,12 @@ export async function runBrunchWorkspaceAction( const inventory = await coordinator.inspectWorkspace() const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( (_tui, theme, _keybindings, done) => - createWorkspaceDialogComponent({ inventory, theme, onDecision: done }), + createWorkspaceDialogComponent({ + inventory, + theme, + onDecision: done, + includeContinue: false, + }), { overlay: true, overlayOptions: { @@ -95,6 +100,13 @@ async function switchToActivatedWorkspace( ctx: ExtensionCommandContext, activated: WorkspaceSessionReadyState, ): Promise<void> { + if (typeof ctx.switchSession !== "function") { + ctx.ui.notify( + "Use /brunch to switch specs or sessions; this Pi context cannot switch sessions.", + "warning", + ) + return + } const targetFile = activated.session.file if (ctx.sessionManager.getSessionFile() === targetFile) { renderBrunchChrome(ctx.ui, chromeStateForWorkspace(activated)) diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 149d82e7..2ade509a 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -25,9 +25,9 @@ describe("spec/session picker", () => { "cancel", ]) expect(view.options.map((option) => option.label)).toEqual([ - "Continue last session", - "Create new specification", - "Resume existing specification", + "Continue your latest spec and session", + "Start a new specification", + "Continue an existing specification", "Cancel", ]) expect(view.options.map((option) => option.label).join("\n")).not.toMatch( @@ -137,13 +137,27 @@ describe("spec/session picker", () => { const text = component.render(80).join("\n") expect(text).toContain("Choose a specification") - expect(text).toContain("Create new specification") - expect(text).toContain("Resume existing specification") + expect(text).toContain("Start a new specification") + expect(text).toContain("Continue an existing specification") expect(text).not.toContain("Brunch workspace") expect(text).not.toContain("Create workspace") expect(text).not.toContain("Open workspace") }) + it("omits continue-latest from in-session picker contexts", () => { + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + includeContinue: false, + onDecision: () => {}, + }) + + const text = component.render(80).join("\n") + + expect(text).not.toContain("Continue your latest spec and session") + expect(text).toContain("Start a new specification") + expect(text).toContain("Continue an existing specification") + }) + it("selects current continue as a typed decision", () => { const decisions: unknown[] = [] const component = createWorkspaceDialogComponent({ @@ -240,7 +254,9 @@ describe("spec/session picker", () => { component.handleInput!("\r") expect(component.render(80).join("\n")).toContain("Choose a specification") component.handleInput!("\x1B") - expect(component.render(80).join("\n")).toContain("Continue last session") + expect(component.render(80).join("\n")).toContain( + "Continue your latest spec and session", + ) component.handleInput!("\x1B") expect(decisions).toEqual([{ action: "cancel" }]) From b0ca853860dc68fc7ea22ceaa9be9cd05bdd91a5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:56:48 +0200 Subject: [PATCH 064/164] Refine picker layout and in-session order --- .../workspace-dialog/component.ts | 14 ++--- src/pi-components/workspace-dialog/model.ts | 53 +++++++++++-------- src/workspace-dialog.test.ts | 5 +- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index 489d0732..9b586006 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -112,15 +112,18 @@ class WorkspaceDialogComponent implements Component { ) const logo = readLogo() const version = brunchVersion() - const versionLines = [ - style(this.#theme, "accent", `brunch ${version.version}`), - ...(version.dev ? [style(this.#theme, "success", version.dev)] : []), - ] + const versionLine = style( + this.#theme, + "accent", + `brunch ${version.version}${version.dev ? ` ${version.dev}` : ""}`, + ) const piLine = style(this.#theme, "dim", `built on Pi v${PI_VERSION}`) const lines = [ + ...logo, + ...(logo.length > 0 ? [""] : []), ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), "", - ...versionLines, + versionLine, piLine, "", title, @@ -147,7 +150,6 @@ class WorkspaceDialogComponent implements Component { "", style(this.#theme, "dim", "↑↓ navigate • enter select • esc cancel"), ) - lines.push("", ...logo) return lines } diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index a96d3321..3e74b49c 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -175,29 +175,36 @@ function buildHomeSelectionView( }) } - selectionOptions.push( - { - id: "new-spec", - label: "Start a new specification", - description: "Name a new spec and create its first session", - kind: "newSpec", - nextStage: { stage: "newSpecTitle", title: "" }, - }, - { - id: "resume-spec", - label: "Continue an existing specification", - description: "Choose a spec, then create or resume a session", - kind: "resumeSpec", - nextStage: { stage: "specList" }, - }, - { - id: "cancel", - label: "Cancel", - description: "Exit without activating a spec/session", - kind: "cancel", - decision: { action: "cancel" }, - }, - ) + const newSpecOption: WorkspaceSelectionOption = { + id: "new-spec", + label: "Start a new specification", + description: "Name a new spec and create its first session", + kind: "newSpec", + nextStage: { stage: "newSpecTitle", title: "" }, + } + const resumeSpecOption: WorkspaceSelectionOption = { + id: "resume-spec", + label: + viewOptions.includeContinue === false + ? "Switch to another specification" + : "Continue an existing specification", + description: "Choose a spec, then create or resume a session", + kind: "resumeSpec", + nextStage: { stage: "specList" }, + } + const cancelOption: WorkspaceSelectionOption = { + id: "cancel", + label: "Cancel", + description: "Exit without activating a spec/session", + kind: "cancel", + decision: { action: "cancel" }, + } + + if (viewOptions.includeContinue === false) { + selectionOptions.push(resumeSpecOption, newSpecOption, cancelOption) + } else { + selectionOptions.push(newSpecOption, resumeSpecOption, cancelOption) + } return { stage: "home", diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 2ade509a..168d08c7 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -154,8 +154,11 @@ describe("spec/session picker", () => { const text = component.render(80).join("\n") expect(text).not.toContain("Continue your latest spec and session") + expect(text).toContain("Switch to another specification") expect(text).toContain("Start a new specification") - expect(text).toContain("Continue an existing specification") + expect(text.indexOf("Switch to another specification")).toBeLessThan( + text.indexOf("Start a new specification"), + ) }) it("selects current continue as a typed decision", () => { From 75158184d3488c99fbce31e226cb3a8292c5b3bf Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:59:21 +0200 Subject: [PATCH 065/164] Hide illogical spec session picker options --- src/pi-components/workspace-dialog/model.ts | 64 ++++++++++++--------- src/workspace-dialog.test.ts | 56 +++++++++++++++--- 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index 3e74b49c..31e49bce 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -76,26 +76,29 @@ export function buildWorkspaceSelectionView( if (stage.stage === "specAction") { const spec = findSpec(inventory, stage.specId) + const options: WorkspaceSelectionOption[] = [ + { + id: `new-session:${stage.specId}`, + label: "Create new session", + description: "Start a binding-only session for this specification", + kind: "newSession", + decision: { action: "newSession", specId: stage.specId }, + }, + ] + if ((spec?.sessions.length ?? 0) > 0) { + options.push({ + id: `resume-session:${stage.specId}`, + label: "Resume existing session", + description: "Choose a prior session transcript explicitly", + kind: "resumeSession", + nextStage: { stage: "sessionList", specId: stage.specId }, + }) + } return { stage: "specAction", specId: stage.specId, title: spec ? `Continue ${spec.spec.title}` : "Continue specification", - options: [ - { - id: `new-session:${stage.specId}`, - label: "Create new session", - description: "Start a binding-only session for this specification", - kind: "newSession", - decision: { action: "newSession", specId: stage.specId }, - }, - { - id: `resume-session:${stage.specId}`, - label: "Resume existing session", - description: "Choose a prior session transcript explicitly", - kind: "resumeSession", - nextStage: { stage: "sessionList", specId: stage.specId }, - }, - ], + options, } } @@ -182,16 +185,19 @@ function buildHomeSelectionView( kind: "newSpec", nextStage: { stage: "newSpecTitle", title: "" }, } - const resumeSpecOption: WorkspaceSelectionOption = { - id: "resume-spec", - label: - viewOptions.includeContinue === false - ? "Switch to another specification" - : "Continue an existing specification", - description: "Choose a spec, then create or resume a session", - kind: "resumeSpec", - nextStage: { stage: "specList" }, - } + const resumeSpecOption: WorkspaceSelectionOption | null = + inventory.specs.length > 0 + ? { + id: "resume-spec", + label: + viewOptions.includeContinue === false + ? "Switch to another specification" + : "Continue another existing specification", + description: "Choose a spec, then create or resume a session", + kind: "resumeSpec", + nextStage: { stage: "specList" }, + } + : null const cancelOption: WorkspaceSelectionOption = { id: "cancel", label: "Cancel", @@ -201,9 +207,11 @@ function buildHomeSelectionView( } if (viewOptions.includeContinue === false) { - selectionOptions.push(resumeSpecOption, newSpecOption, cancelOption) + if (resumeSpecOption) selectionOptions.push(resumeSpecOption) + selectionOptions.push(newSpecOption, cancelOption) } else { - selectionOptions.push(newSpecOption, resumeSpecOption, cancelOption) + if (resumeSpecOption) selectionOptions.push(resumeSpecOption) + selectionOptions.push(newSpecOption, cancelOption) } return { diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 168d08c7..8595d08d 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -20,14 +20,14 @@ describe("spec/session picker", () => { expect(view.stage).toBe("home") expect(view.options.map((option) => option.kind)).toEqual([ "continue", - "newSpec", "resumeSpec", + "newSpec", "cancel", ]) expect(view.options.map((option) => option.label)).toEqual([ "Continue your latest spec and session", + "Continue another existing specification", "Start a new specification", - "Continue an existing specification", "Cancel", ]) expect(view.options.map((option) => option.label).join("\n")).not.toMatch( @@ -45,7 +45,7 @@ describe("spec/session picker", () => { it("navigates resume-existing-spec to spec actions without emitting activation early", () => { const currentInventory = inventory() const home = buildWorkspaceSelectionView(currentInventory) - const specList = selectWorkspaceSelectionOption(home, 2, currentInventory) + const specList = selectWorkspaceSelectionOption(home, 1, currentInventory) expect(specList).toMatchObject({ view: { stage: "specList" } }) if (!("view" in specList)) throw new Error("expected spec list") @@ -93,11 +93,31 @@ describe("spec/session picker", () => { it("enters new-spec title state before emitting a new-spec decision", () => { const home = buildWorkspaceSelectionView(inventory()) - expect(selectWorkspaceSelectionOption(home, 1)).toMatchObject({ + expect(selectWorkspaceSelectionOption(home, 2)).toMatchObject({ view: { stage: "newSpecTitle", title: "", options: [] }, }) }) + it("only shows logical home options in an empty workspace", () => { + const view = buildWorkspaceSelectionView(emptyInventory()) + + expect(view.options.map((option) => option.label)).toEqual([ + "Start a new specification", + "Cancel", + ]) + }) + + it("only shows resume-existing-session when the chosen spec has sessions", () => { + const view = buildWorkspaceSelectionView(emptySessionInventory(), { + stage: "specAction", + specId: "spec-empty", + }) + + expect(view.options.map((option) => option.label)).toEqual([ + "Create new session", + ]) + }) + it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { const options = buildWorkspaceDialogOptions(inventory()) @@ -138,7 +158,7 @@ describe("spec/session picker", () => { expect(text).toContain("Choose a specification") expect(text).toContain("Start a new specification") - expect(text).toContain("Continue an existing specification") + expect(text).toContain("Continue another existing specification") expect(text).not.toContain("Brunch workspace") expect(text).not.toContain("Create workspace") expect(text).not.toContain("Open workspace") @@ -186,7 +206,6 @@ describe("spec/session picker", () => { onDecision: (decision) => decisions.push(decision), }) - component.handleInput!("\x1B[B") component.handleInput!("\x1B[B") component.handleInput!("\r") component.handleInput!("\r") @@ -202,7 +221,6 @@ describe("spec/session picker", () => { onDecision: (decision) => decisions.push(decision), }) - component.handleInput!("\x1B[B") component.handleInput!("\x1B[B") component.handleInput!("\r") component.handleInput!("\r") @@ -227,6 +245,7 @@ describe("spec/session picker", () => { onDecision: (decision) => decisions.push(decision), }) + component.handleInput!("\x1B[B") component.handleInput!("\x1B[B") component.handleInput!("\r") for (const char of "Gamma") { @@ -252,7 +271,6 @@ describe("spec/session picker", () => { onDecision: (decision) => decisions.push(decision), }) - component.handleInput!("\x1B[B") component.handleInput!("\x1B[B") component.handleInput!("\r") expect(component.render(80).join("\n")).toContain("Choose a specification") @@ -368,6 +386,28 @@ class FakeTerminal implements Terminal { } } +function emptyInventory(): WorkspaceLaunchInventory { + return { + cwd: "/project", + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], + } +} + +function emptySessionInventory(): WorkspaceLaunchInventory { + return { + cwd: "/project", + currentSpec: { id: "spec-empty", title: "Empty" }, + currentSessionFile: null, + needsNewSpec: false, + specs: [{ spec: { id: "spec-empty", title: "Empty" }, sessions: [] }], + unavailableSessions: [], + } +} + function inventory(): WorkspaceLaunchInventory { return { cwd: "/project", From b502e3612c7a761e3f903a229bae2d8ca50c2b89 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:01:15 +0200 Subject: [PATCH 066/164] Support ctrl-c in spec session picker --- src/pi-components/workspace-dialog/component.ts | 6 ++++++ src/workspace-dialog.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index 9b586006..bcf943a2 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -25,6 +25,7 @@ import { export const WORKSPACE_DIALOG_WIDTH = 80 const ESC = String.fromCharCode(27) +const CTRL_C = "\x03" const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") const ASSET_DIR = new URL("./assets/", import.meta.url) @@ -67,6 +68,11 @@ class WorkspaceDialogComponent implements Component { } handleInput(data: string): void { + if (data === CTRL_C) { + this.#onDecision({ action: "cancel" }) + return + } + if (this.#stage.stage === "newSpecTitle") { this.#handleTitleInput(data) return diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 8595d08d..fab5937a 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -283,6 +283,17 @@ describe("spec/session picker", () => { expect(decisions).toEqual([{ action: "cancel" }]) }) + it("cancels from startup preflight on ctrl-c", async () => { + const terminal = new FakeTerminal() + const decision = runWorkspaceDialogPreflight(inventory(), { terminal }) + + terminal.emit("\x03") + + await expect(decision).resolves.toEqual({ action: "cancel" }) + expect(terminal.events.at(-2)).toBe("stop") + expect(terminal.events.at(-1)).toBe("clearScreen") + }) + it("renders a branded centered-dialog frame with version metadata", () => { const component = createWorkspaceDialogComponent({ inventory: inventory(), From bdd68de7a7c45e58ecc7662daec1500192069534 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:13:48 +0200 Subject: [PATCH 067/164] plan refinements for critical UI proof slices --- memory/CARDS.md | 285 ++++++++++++++++++++++++++++++++++++++++++++++++ memory/PLAN.md | 8 +- memory/SPEC.md | 15 ++- 3 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..ae4fe47a --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,285 @@ +# Scope cards — FE-744 judo fixes and next UI-seam slices + +Status key: `next` / `in progress` / `done` / `dropped`. + +## Orientation + +- **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. +- **Current state:** The hierarchical spec/session picker landed and verified, but review found stale flat-picker exports, outdated `WorkspaceSwitchDecision` naming, an ad-hoc RPC activation parser, a partial-coordinator capability smell, and a visible regression to minimal chrome. SPEC/PLAN reconciliation is present but this checkout still shows `memory/SPEC.md` and `memory/PLAN.md` as modified. +- **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. +- **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). + +--- + +## Card 1 — Delete legacy flat picker API, rename activation decision, and restore version styling + +**Status:** next +**Weight:** light scope card + +### Objective + +Retire the obsolete flat workspace-dialog option API, rename the activation decision boundary away from “workspace switch” language, and restore the separate styled dev build tag in the spec/session picker header. + +### Acceptance Criteria + +✓ `rg "buildWorkspaceDialogOptions|WorkspaceDialogOption" src` finds no exported production API and no tests depending on the old flat-list picker. +✓ `src/pi-components/workspace-dialog/model.ts` contains only the hierarchical selection model for picker option generation. +✓ `WorkspaceSwitchDecision` is replaced in production code with a spec/session activation name such as `SpecSessionActivationDecision`; if `WorkspaceSwitchCoordinator` remains, it is either renamed too or justified by a narrower follow-up. +✓ `src/workspace-dialog.test.ts` asserts hierarchical model/component behavior without testing the old flat option list. +✓ The picker header renders `brunch v...` and the dev metadata as separately styled segments/lines so the dev tag uses `success` styling rather than being folded into the accent version string. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: `npm run fix`; targeted `npx vitest --run src/workspace-dialog.test.ts src/brunch-tui.test.ts`; then `npm run verify`. +- Middle: `rg` deletion check for the retired flat-picker symbols. + +### Cross-cutting obligations + +- Delete stale concepts instead of preserving compatibility scaffolding; this is pre-release and `buildWorkspaceDialogOptions` is now the wrong model. +- Keep the renamed spec/session activation decision as the transport-neutral activation boundary; do not rename individual action variants just for copy cleanup unless doing so deletes more ambiguity than it creates. +- Preserve current TUI startup and in-session picker behavior while removing old API surface. + +### Promotion checklist + +- [ ] Does this change a requirement? No. +- [ ] Does this create, retire, or invalidate an assumption? No. +- [ ] Does this make or reverse a non-trivial design decision? No — D36-L already chose the hierarchical model. +- [ ] Does this establish a new seam-level invariant? No. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. +- [ ] Does it cross more than two major seams? No. +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. + +--- + +## Card 2 — Schema-backed RPC spec/session activation boundary + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +`workspace.activate` validates activation params through an explicit TypeBox-backed spec/session activation decision schema and is only registered with a coordinator that supports workspace inspection and spec/session activation. + +### Boundary Crossings + +```text +→ JSON-RPC request params +→ TypeBox workspace activation decision schema/parser +→ SpecSessionActivationDecision +→ spec/session activation coordinator method +→ serializable activation response DTO +``` + +### Risks and Assumptions + +- RISK: Continuing with `Partial<WorkspaceSwitchCoordinator>` (or its renamed equivalent) keeps an impossible registered-method state: the method exists but can only return an internal error. → MITIGATION: Make `createRpcHandlers` require the coordinator capabilities it registers, or split selection/activation handler registration into a separate explicit factory if a read-only coordinator is truly needed. +- RISK: Hand-rolled casts around `unknown` will be copied into the upcoming structured-question RPC work. → MITIGATION: Establish the TypeBox parse pattern here before adding more RPC boundaries. +- ASSUMPTION: All current call sites can pass a full `WorkspaceSessionCoordinator` plus the renamed spec/session activation coordinator capability. → VALIDATE: Typecheck all call sites (`brunch.ts`, `web-host.ts`, fixture capture, tests) after tightening the type. + +### Acceptance Criteria + +✓ `src/rpc.ts` has no manual `(decision as { ... })` parser for `workspace.activate`; params are parsed/checked via a TypeBox schema or a small schema-backed helper returning the renamed spec/session activation decision type. +✓ `createRpcHandlers` no longer accepts a partial activation coordinator for methods it always registers; required capabilities are explicit at the factory boundary. +✓ `workspace.activate` invalid params still return `-32602`; valid `cancel`, `newSpec`, `newSession`, `continue`, and `openSession` decisions still delegate exactly once to `activateWorkspace`. +✓ Activation responses remain serializable and do not expose `SessionManager`. +✓ The source assertion that RPC does not import TUI picker code remains meaningful and passes. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: RPC contract tests — valid/invalid decision parsing, coordinator delegation, serializable activation snapshots, and typecheck of all handler call sites. +- Middle: Architectural boundary/source assertion — `src/rpc.ts` does not import TUI picker code and does not use non-TypeBox runtime schema libraries. + +### Cross-cutting obligations + +- Honor D41-L/I26-L: TypeBox is the runtime schema vocabulary at Brunch boundaries. +- RPC/headless startup must expose structured selection/activation, not TUI picker code. +- Keep transport connections as client attachments; activation still flows through coordinator, not through connection-local session identity. + +--- + +## Card 3 — Restore rich Brunch chrome projection + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The persistent Brunch TUI chrome renders a richer product-owned header/footer/status/widget projection, including the selected cwd/spec/session and available runtime/context metadata, without fabricating unavailable facts. + +### Boundary Crossings + +```text +→ WorkspaceSessionReadyState / Brunch runtime snapshot producers +→ BrunchChromeState +→ renderBrunchChrome wrapper +→ Pi ui.setHeader / setFooter / setStatus / setWidget / setTitle +→ TUI visual surface and RPC-compatible status/widget events +``` + +### Risks and Assumptions + +- RISK: The earlier rich chrome may have depended on metadata producers that are not currently wired into `BrunchChromeState` (context usage, model/thinking, runtime bundle, git/build data). → MITIGATION: First inventory what data is available from Pi extension contexts and Brunch runtime state; render optional fields only when the producer exists, and record missing producers as follow-up rather than fabricating values. +- RISK: A sophisticated footer can become a pile of formatting branches. → MITIGATION: Split pure formatting helpers by region (`header`, `footer`, `widget/status`) and keep `renderBrunchChrome()` as the only imperative shell. +- RISK: Header/footer are TUI-only in Pi RPC. → MITIGATION: Mirror the important compact facts into `setStatus` / `setWidget` so RPC tests and fixture drivers still have deterministic observability. +- ASSUMPTION: `setFooter` remains the right home for the richer metadata/status bar. → VALIDATE: Unit tests prove `setFooter` receives the rich projection; manual TUI smoke validates visual hierarchy. + +### Acceptance Criteria + +✓ `src/pi-extensions/chrome.ts` exposes a deeper `BrunchChromeState` or projection input that can carry optional runtime metadata such as model/thinking/runtime bundle/build info/context usage without making those fields mandatory. +✓ `formatBrunchChromeFooterLines` renders a richer footer than the current two plain lines, including a compact context-usage progress bar when usage data is present and a clear omission when it is not. +✓ `renderBrunchChrome` still calls `setHeader`, `setFooter`, `setStatus`, `setWidget`, and `setTitle` through one wrapper; downstream code does not scatter raw `ctx.ui.*` calls. +✓ `src/brunch-tui.test.ts` covers the rich footer/header/status/widget projection and RPC-compatible degradation expectations. +✓ Manual TUI smoke or pty capture confirms the Brunch chrome no longer resembles the minimal cwd/spec/session dump shown in the regression screenshot. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: Pure formatter unit tests plus wrapper-call tests in `src/brunch-tui.test.ts`. +- Middle: Manual/pty TUI smoke comparing the live Brunch chrome against the rich footer/header expectations; RPC-compatible tests assert status/widget only for facts Pi RPC actually emits. + +### Cross-cutting obligations + +- `renderBrunchChrome` remains the canonical wrapper; no feature code should call raw Pi chrome primitives directly. +- Do not fabricate unavailable metadata; optional chrome fields are presentation metadata, not product truth. +- Preserve RPC degradation rules: header/footer are TUI-only, status/widget/title are deterministic for headless/RPC observers. + +--- + +## Card 4 — Structured-question result model and transcript payload + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +A Brunch structured-question tool can return a self-contained `toolResult.details` payload for text, single-select, multi-select, questionnaire, and optional-freeform answers. + +### Boundary Crossings + +```text +→ Pi extension tool registration +→ TypeBox structured-question parameter/result schemas +→ TUI/RPC-neutral structured answer model +→ toolResult.content + toolResult.details +→ Pi JSONL transcript projection inputs +``` + +### Risks and Assumptions + +- RISK: Building UI first may leave the durable transcript shape under-specified. → MITIGATION: Start with pure schemas/builders and tests for `details` and model-readable `content`; add UI adapters later. +- RISK: The tool parameter schema and result schema can drift. → MITIGATION: Keep both in one module and derive TS types from TypeBox `Static<typeof Schema>`. +- ASSUMPTION: A single details envelope can cover all current answer modes without a separate custom entry. → VALIDATE: Tests cover `answered`, `skipped`, `cancelled`, and at least one answer shape per mode; if linked custom entries are needed, stop and rescope before building UI. + +### Acceptance Criteria + +✓ A new structured-question module defines TypeBox schemas for question/tool params and terminal result details. +✓ Tests prove the returned `toolResult.details` includes schema/version, status, mode, prompts/questions, options where relevant, answers, and transport metadata without requiring rehydration from assistant tool-call args. +✓ Tests prove `toolResult.content` is generated from the same details payload and remains model-readable. +✓ The module supports text, single-select, multi-select, questionnaire, and optional-freeform shapes at the data/model layer. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: Schema/builder unit tests for each mode and terminal status; typecheck against `Static<typeof Schema>` types. +- Middle: Transcript-shape contract test using a synthetic tool result entry to prove the payload is self-contained enough for later projection. + +### Cross-cutting obligations + +- Pi JSONL remains transcript truth; the details payload is not an ephemeral UI return value. +- Use TypeBox, not Zod/ad-hoc casts, for the new runtime boundary. +- Do not introduce graph mutations, command-layer bypasses, or a parallel chat/turn store. + +--- + +## Card 5 — TUI custom UI adapter for structured questions + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +In TUI mode, the structured-question tool can replace the default input surface with a Brunch custom UI and persist the selected answer through the Card 4 result builder. + +### Boundary Crossings + +```text +→ registered structured-question Pi tool +→ ctx.ui.custom TUI adapter +→ pi-tui component for answer selection/input +→ structured result builder +→ toolResult.details persisted in Pi JSONL +``` + +### Risks and Assumptions + +- RISK: One component for every question shape may become a mini-framework. → MITIGATION: Implement the thinnest shared selector/input component that covers the supported modes; do not generalize beyond Card 4 schemas. +- RISK: UI-local return values may diverge from transcript details. → MITIGATION: The UI returns only inputs needed by the Card 4 builder; content/details are built in one place. +- ASSUMPTION: `ctx.ui.custom()` is available in the Brunch TUI extension path for this tool. → VALIDATE: Unit/fake-context test plus manual TUI smoke; if unavailable in a context, return `unavailable` details rather than blocking. + +### Acceptance Criteria + +✓ TUI fake-context tests prove single-select, multi-select, questionnaire, text/freeform, skip/cancel paths call the structured result builder and return terminal details. +✓ The component is input-replacing for TUI and does not append a separate custom message as the canonical answer store. +✓ Empty/invalid required answers remain in the UI until answered, skipped, or cancelled. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: Component/tool unit tests with fake `ctx.ui.custom`. +- Middle: Manual TUI smoke or pty capture demonstrating an input-replacing question and JSONL inspection showing one terminal tool result with details. + +### Cross-cutting obligations + +- Preserve transcript-native structured elicitation (D37-L/I23-L). +- Keep UI adapters thin over the shared data/result model. +- Do not widen Pi command/keybinding behavior while adding this tool. + +--- + +## Card 6 — RPC JSON-editor fallback for structured questions + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +When rich TUI custom UI is unavailable over raw Pi RPC, the structured-question tool can round-trip the same semantic interaction through schema-tagged JSON in `ctx.ui.editor` and produce the same result details. + +### Boundary Crossings + +```text +→ structured-question Pi tool +→ ctx.ui.editor JSON prefill +→ raw Pi RPC extension_ui_request/response +→ JSON parse/validation +→ structured result builder from Card 4 +→ Brunch product-facing relay/probe expectations +``` + +### Risks and Assumptions + +- RISK: Exposing raw editor JSON as product UX would violate D38-L. → MITIGATION: Treat JSON-editor as compatibility adapter only; Brunch public RPC clients should see product-shaped pending interaction semantics in a later relay slice. +- RISK: Invalid edited JSON can produce ambiguous failure behavior. → MITIGATION: Validate with TypeBox; invalid/malformed responses become terminal `unavailable` or a clear validation error according to the tool contract decided in Card 4. +- ASSUMPTION: Pi RPC's documented editor request/response path is sufficient for this fallback. → VALIDATE: Raw Pi RPC probe based on `examples/rpc-extension-ui.ts` or equivalent local fixture. + +### Acceptance Criteria + +✓ Tests prove editor prefill JSON includes schema tag/version, mode, prompt/questions, options, and response instructions. +✓ Tests prove valid edited JSON produces the same `toolResult.details` shape as the TUI adapter. +✓ Tests prove malformed or schema-invalid edited JSON fails deterministically without producing a misleading `answered` result. +✓ A raw Pi RPC probe/runbook demonstrates `ctx.ui.editor` fallback round-trips through documented extension UI protocol. +✓ `npm run verify` passes. + +### Verification Approach + +- Inner: JSON prefill/parse/validation tests over the Card 4 schema and builder. +- Middle: Raw Pi RPC probe/runbook — proves the fallback works against Pi's actual extension UI messages. + +### Cross-cutting obligations + +- JSON-editor fallback is private adapter mechanics, not a second public Brunch API. +- Preserve one public Brunch RPC surface; raw Pi RPC remains behind adapters/probes. +- Keep structured result details self-contained and transcript-backed. diff --git a/memory/PLAN.md b/memory/PLAN.md index ff40ca05..09e45990 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -123,7 +123,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) -- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Immediate UI correction before continuing profile audit: rename/reframe the current workspace dialog around SPEC D11-L/D36-L terminology (`workspace(cwd) → spec → session`) and reshape it into the hierarchical spec/session selection model: optional continue-last fast path; create spec → name it → implicit first session; resume existing spec → choose spec from a scrollable selector → create new session or resume existing session → choose session. Preserve RPC/headless startup as structured initial-selection state/results, not a TUI picker. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. +- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Immediate UI correction before continuing profile audit: rename/reframe the current workspace dialog around SPEC D11-L/D36-L terminology (`workspace(cwd) → spec → session`) and reshape it into the hierarchical spec/session selection model: optional continue-last fast path; create spec → name it → implicit first session; resume existing spec → choose spec from a scrollable selector → create new session or resume existing session → choose session. Preserve RPC/headless startup as structured initial-selection state/results, not a TUI picker. Follow-up in the same frontier: add best-effort lifecycle-generated session display names over Pi `session_info`, likely triggered from `session_shutdown` and modeled after the local `summarize.ts` extension's cheap-model summarization pattern, so picker lists can distinguish sessions by meaning rather than UUID alone. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. ### graph-data-plane @@ -136,10 +136,10 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Pins I1-L, I6-L. Unlocks all agent ↔ graph work (M5+) and lets oracle / design / plan planes be added later without re-foundation. - **Acceptance:** Graph CRUD + change-log replay tests pass through the `CommandExecutor` public mutation boundary; command results already include success, `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` shapes even if pre-M6 policy classification is minimal; reconciliation-need substrate accepts inserts/updates/resolutions with LSN invariants enforced; oracle-plane stub tables exist (Check, Validation Method, Evidence, Obligation) even if unused; the persistence layer proves the one-transaction protocol that couples authority/result classification, version checks, structural validation, LSN allocation, change-log append, and any coherence updates. - **Verification:** Inner gate plus command/result schema/type tests. Middle — property/model-based tests on LSN monotonicity, graph replay, reconciliation invariants, framing matrix, and `CommandExecutor` transaction/result behavior; architectural no-bypass tests. Outer — fixture property invariants on reconciliation-substrate begin running. -- **Cross-cutting obligations:** Establish the Drizzle + `better-sqlite3` persistence shape, `CommandExecutor` result contract, and no-bypass transaction rule as shared infrastructure for later direct-agent, observer-job, side-task, migration, and UI-attributed writes. -- **Traceability:** R7, R9, R13 / D3-L, D4-L, D6-L, D8-L, D9-L, D16-L, D20-L / I1-L, I6-L, I7-L, I11-L / A3-L, A4-L +- **Cross-cutting obligations:** Establish the Drizzle + `better-sqlite3` persistence shape, `CommandExecutor` result contract, and no-bypass transaction rule as shared infrastructure for later direct-agent, observer-job, side-task, migration, and UI-attributed writes. Derive row/insert/update runtime schemas from Drizzle table definitions via TypeBox (`drizzle-orm/typebox` if A20-L resolves to the Drizzle 1.0 beta line; standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` otherwise) — do not hand-author parallel row schemas. Land the I26-L grep-based architectural test alongside the first Drizzle import so the single-schema-vocabulary boundary stays enforced. +- **Traceability:** R7, R9, R13 / D3-L, D4-L, D6-L, D8-L, D9-L, D16-L, D20-L, D41-L / I1-L, I6-L, I7-L, I11-L, I26-L / A3-L, A4-L, A20-L - **Design docs:** [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [pi-seam-extensions.md §Graph clock, §Reconciliation-need substrate, §Oracle plane](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) -- **Current execution pointer:** start by scoping the narrow `CommandExecutor` result contract and one-transaction LSN/change-log skeleton before widening CRUD or coherence homes. +- **Current execution pointer:** start by scoping the narrow `CommandExecutor` result contract and one-transaction LSN/change-log skeleton before widening CRUD or coherence homes. Pair the first slice with an A20-L spike (Drizzle 1.0 beta + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` round-trip) so the version pin and schema-derivation path are settled before later slices import them broadly. ### agent-graph-integration diff --git a/memory/SPEC.md b/memory/SPEC.md index b5bb7087..058d20d9 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -114,6 +114,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | | A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L, D35-L | `pi-ui-extension-patterns` product-shell review after command-containment and dynamic Brunch chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | | A19-L | Pi's current settings/resource lifecycle can be made product-safe through a sealed Brunch Pi Profile without forking Pi: ambient discovery remains disabled, Brunch-owned extension factories may inject explicit resources, and remaining settings/keybinding leakage can be eliminated through programmatic policy or a narrow upstream seam. | medium | open | D39-L | FE-744/profile audit: source-backed resource-loader/settings audit, tests proving no ambient `.pi/` skills/prompts/themes/extensions/context files affect Brunch, and product-owned resources still load when intentionally injected. | +| A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | ### Active Decisions @@ -124,7 +125,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. #### Data model & vocabulary @@ -191,12 +192,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. - **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Background sub-agents are tracked by a Brunch-owned `SideTaskRegistry`; results are never injected mid-turn and instead arrive at the next-turn boundary through the existing custom-message plus `prepareNextTurn` path. Side-task writes remain subject to the same command-layer authority as primary-agent writes. Depends on: A11-L, D4-L. Supersedes: —. -- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Depends on: A3-L, A4-L. Supersedes: —. +- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions via TypeBox per D41-L; the Drizzle version pin is open per A20-L. Depends on: A3-L, A4-L. Refined by: D41-L. Supersedes: —. - **D18-L — Observer extraction is exchange-keyed durable work, not a chat/turn store.** After a user response closes an elicitation exchange, Brunch may enqueue an observer job keyed by session id plus exchange entry ids; jobs survive process restart and graph writes still route through the command layer. Routine observer jobs are operational queue state, not reconciliation needs by default; low-confidence or conflicting findings may create reconciliation needs. Depends on: A13-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model. - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. - **D29-L — Reviewer is an `observer`-shaped agent role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. +#### Schema & validation + +- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/pi-extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, observer/reviewer-job result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. + #### Interaction & UI shape - **D11-L — Workspace state hierarchy `workspace(cwd) → spec → session`, with spec and session selection gated before any agent loop.** A Brunch workspace is the single cwd where the CLI is invoked; it is not a user-created container and there is only one per launch context. The cwd's human-readable label may be derived by `src/project-identity.ts` from shallow project manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) or directory basename, but that label is presentation metadata, not a second selectable container. The first durable choice is the spec: create a new spec, or resume an existing spec. Within an existing spec, the second durable choice is the session: create a new session or resume an existing session. Creating a new spec implicitly creates its first session. Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: treating “workspace” as the user-created product object in the boot dialog. @@ -213,6 +218,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. - **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. +- **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. ### Critical Invariants @@ -243,6 +249,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | +| I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | +| I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their table definitions. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | ## Future Direction Register @@ -314,6 +322,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Workspace** | The current working directory where the Brunch CLI was invoked. It scopes `.brunch/` state for the launch context. It is not user-created, not selectable within the dialog, and there is only one active workspace per Brunch process. The UI may display a project identity/name derived from cwd-local manifests or directory basename, but that name labels the cwd; it does not create a separate workspace object. | | **Spec / specification** | The user-created specification container within a workspace, identified by its intent-graph root. Multiple specs may coexist under one workspace. A spec contains sessions and the graph data gathered through those sessions (intent nodes, design nodes, oracle/plan data as they land). Future plan-execution mode operates on a selected spec. | | **Session** | An elicitation transcript belonging to one spec. Backed by a linear pi JSONL session under `.brunch/sessions/`. A spec may have many sessions over time; a session never changes specs. Pi branch/tree mechanics are unsupported Brunch product behavior in the POC. | +| **Session display name** | Optional human-readable label for a session, stored as Pi `session_info` metadata and used by pickers/chrome to distinguish sessions. It may be user-set or Brunch-generated from transcript content; it is not canonical spec/session identity. | | **Session binding** | The first Brunch custom entry in a session that binds the Pi session id to exactly one spec id and schema version. Makes JSONL self-describing; registry/index state is an acceleration, not the canonical binding. | | **Client attachment** | An ephemeral TUI instance, browser tab, stdio stream, or WebSocket connection attached to one or more Brunch product resources for viewing or driving. Client attachment state may guide subscriptions and UI routing, but it is not durable spec/session truth. | | **Workspace session coordinator** | The Brunch boot seam that returns `ready | select_spec | needs_human` workspace-session state for a cwd/mode, owns spec selection, selected-session reopening, and `/new`, creates/opens Pi sessions through `SessionManager`, writes `brunch.session_binding`, persists current spec/session acceleration in `.brunch/state.json`, and derives chrome state for callers. “Workspace” in this name refers to cwd scope, not a selectable product object. | @@ -349,7 +358,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | -| **Side task** | A scoped sub-agent invocation whose result returns through the shared command layer. | +| **Side task** | A scoped sub-agent or auxiliary LLM invocation whose result returns through the shared command layer or a bounded metadata seam such as Pi `session_info` when it is explicitly presentation metadata. | | **World update** | `worldUpdate` custom message synthesised in `prepareNextTurn` summarising relevant graph changes since the session's `lastSeenLsn`. | | **Mention ledger** | Per-session `(entity_id, snapshotted_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | | **Authority** | Source of a node's claim: `stakeholder | technical | external | derived`. | From 6ef7063478bc9622b8e0ad4b88623c95e6a30b3c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:16:32 +0200 Subject: [PATCH 068/164] Retire flat spec session picker API --- memory/CARDS.md | 4 +- src/brunch-tui.ts | 14 ++-- src/pi-components/workspace-dialog.ts | 2 - .../workspace-dialog/component.ts | 12 ++- src/pi-components/workspace-dialog/index.ts | 2 - src/pi-components/workspace-dialog/model.ts | 83 ++----------------- .../workspace-dialog/preflight.ts | 8 +- src/pi-extensions.ts | 6 +- src/pi-extensions/workspace-dialog.ts | 16 ++-- src/rpc.test.ts | 10 +-- src/rpc.ts | 8 +- src/workspace-dialog.test.ts | 49 +++-------- src/workspace-session-coordinator.ts | 10 +-- 13 files changed, 63 insertions(+), 161 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index ae4fe47a..bb45abb4 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -5,7 +5,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Orientation - **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified, but review found stale flat-picker exports, outdated `WorkspaceSwitchDecision` naming, an ad-hoc RPC activation parser, a partial-coordinator capability smell, and a visible regression to minimal chrome. SPEC/PLAN reconciliation is present but this checkout still shows `memory/SPEC.md` and `memory/PLAN.md` as modified. +- **Current state:** The hierarchical spec/session picker landed and verified. Card 1 retired stale flat-picker exports, renamed the activation decision/coordinator types, and restored separate dev-tag styling; remaining review findings are the ad-hoc RPC activation parser, partial-coordinator capability smell, and visible regression to minimal chrome. - **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. - **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). @@ -13,7 +13,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Card 1 — Delete legacy flat picker API, rename activation decision, and restore version styling -**Status:** next +**Status:** done **Weight:** light scope card ### Objective diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index e564487c..39de25a1 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -16,8 +16,8 @@ import { type WorkspaceLaunchInventory, type WorkspaceSessionBoundaryCoordinator, type WorkspaceSessionReadyState, - type WorkspaceSwitchCoordinator, - type WorkspaceSwitchDecision, + type SpecSessionActivationCoordinator, + type SpecSessionActivationDecision, } from "./workspace-session-coordinator.js" import { chromeStateForWorkspace, @@ -38,7 +38,7 @@ export { } from "./pi-extensions.js" export { runWorkspaceDialogPreflight } from "./pi-components/workspace-dialog.js" -export type BrunchTuiCoordinator = WorkspaceSwitchCoordinator & WorkspaceSessionBoundaryCoordinator +export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState @@ -51,7 +51,7 @@ export interface BrunchTuiOptions { selectSpecTitle?: () => Promise<string | undefined> runWorkspaceDialogPreflight?: ( inventory: WorkspaceLaunchInventory, - ) => Promise<WorkspaceSwitchDecision> + ) => Promise<SpecSessionActivationDecision> launchInteractive?: (context: BrunchTuiLaunchContext) => Promise<void> } @@ -63,7 +63,7 @@ export async function runBrunchTui( options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }) const inventory = await coordinator.inspectWorkspace() - const decision = await chooseWorkspaceSwitchDecision(inventory, options) + const decision = await chooseSpecSessionActivationDecision(inventory, options) const workspaceState = await coordinator.activateWorkspace(decision) if (workspaceState.status === "cancelled") { @@ -79,10 +79,10 @@ export async function runBrunchTui( }) } -async function chooseWorkspaceSwitchDecision( +async function chooseSpecSessionActivationDecision( inventory: WorkspaceLaunchInventory, options: BrunchTuiOptions, -): Promise<WorkspaceSwitchDecision> { +): Promise<SpecSessionActivationDecision> { if (options.runWorkspaceDialogPreflight) { return options.runWorkspaceDialogPreflight(inventory) } diff --git a/src/pi-components/workspace-dialog.ts b/src/pi-components/workspace-dialog.ts index 6d29c2a1..003080da 100644 --- a/src/pi-components/workspace-dialog.ts +++ b/src/pi-components/workspace-dialog.ts @@ -1,7 +1,5 @@ export { - buildWorkspaceDialogOptions, createWorkspaceDialogComponent, runWorkspaceDialogPreflight, type WorkspaceDialogComponentOptions, - type WorkspaceDialogOption, } from "./workspace-dialog/index.js" diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index bcf943a2..4ca2064f 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -14,7 +14,7 @@ import { import type { WorkspaceLaunchInventory, - WorkspaceSwitchDecision, + SpecSessionActivationDecision, } from "../../workspace-session-coordinator.js" import { buildWorkspaceSelectionView, @@ -39,7 +39,7 @@ export type WorkspaceDialogTheme = Pick<Theme, "fg"> export interface WorkspaceDialogComponentOptions { inventory: WorkspaceLaunchInventory - onDecision: (decision: WorkspaceSwitchDecision) => void + onDecision: (decision: SpecSessionActivationDecision) => void theme?: WorkspaceDialogTheme includeContinue?: boolean } @@ -52,7 +52,7 @@ export function createWorkspaceDialogComponent( class WorkspaceDialogComponent implements Component { #inventory: WorkspaceLaunchInventory - #onDecision: (decision: WorkspaceSwitchDecision) => void + #onDecision: (decision: SpecSessionActivationDecision) => void #theme: WorkspaceDialogTheme | undefined #includeContinue: boolean #selectedIndex = 0 @@ -121,8 +121,11 @@ class WorkspaceDialogComponent implements Component { const versionLine = style( this.#theme, "accent", - `brunch ${version.version}${version.dev ? ` ${version.dev}` : ""}`, + `brunch ${version.version}`, ) + const devLine = version.dev + ? style(this.#theme, "success", version.dev) + : null const piLine = style(this.#theme, "dim", `built on Pi v${PI_VERSION}`) const lines = [ ...logo, @@ -130,6 +133,7 @@ class WorkspaceDialogComponent implements Component { ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), "", versionLine, + ...(devLine ? [devLine] : []), piLine, "", title, diff --git a/src/pi-components/workspace-dialog/index.ts b/src/pi-components/workspace-dialog/index.ts index 0e7c41ef..98f9d4e8 100644 --- a/src/pi-components/workspace-dialog/index.ts +++ b/src/pi-components/workspace-dialog/index.ts @@ -4,10 +4,8 @@ export { type WorkspaceDialogComponentOptions, } from "./component.js" export { - buildWorkspaceDialogOptions, buildWorkspaceSelectionView, selectWorkspaceSelectionOption, - type WorkspaceDialogOption, type WorkspaceSelectionOption, type WorkspaceSelectionResult, type WorkspaceSelectionStage, diff --git a/src/pi-components/workspace-dialog/model.ts b/src/pi-components/workspace-dialog/model.ts index 31e49bce..7c022c39 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/pi-components/workspace-dialog/model.ts @@ -1,17 +1,9 @@ import type { WorkspaceLaunchInventory, WorkspaceLaunchSession, - WorkspaceSwitchDecision, + SpecSessionActivationDecision, } from "../../workspace-session-coordinator.js" -export interface WorkspaceDialogOption { - id: string - label: string - description: string - kind: "continue" | "openSession" | "newSession" | "newSpec" | "cancel" - decision?: WorkspaceSwitchDecision -} - export type WorkspaceSelectionStage = { stage: "home" } | { stage: "newSpecTitle" title: string @@ -28,7 +20,7 @@ export interface WorkspaceSelectionOption { label: string description: string kind: "continue" | "newSpec" | "resumeSpec" | "cancel" | "spec" | "newSession" | "resumeSession" | "session" - decision?: WorkspaceSwitchDecision + decision?: SpecSessionActivationDecision nextStage?: WorkspaceSelectionStage } @@ -43,7 +35,9 @@ export interface WorkspaceSelectionViewOptions { includeContinue?: boolean } -export type WorkspaceSelectionResult = { decision: WorkspaceSwitchDecision } | { +export type WorkspaceSelectionResult = { + decision: SpecSessionActivationDecision +} | { view: WorkspaceSelectionView } @@ -221,73 +215,6 @@ function buildHomeSelectionView( } } -export function buildWorkspaceDialogOptions( - inventory: WorkspaceLaunchInventory, -): WorkspaceDialogOption[] { - const options: WorkspaceDialogOption[] = [] - const currentSession = findCurrentSession(inventory) - - if (currentSession && inventory.currentSpec) { - options.push({ - id: `continue:${currentSession.file}`, - label: `Continue ${inventory.currentSpec.title}`, - description: sessionDescription( - currentSession, - "Resume selected session", - ), - kind: "continue", - decision: { - action: "continue", - specId: inventory.currentSpec.id, - sessionFile: currentSession.file, - }, - }) - } - - for (const { spec, sessions } of inventory.specs) { - options.push({ - id: `new-session:${spec.id}`, - label: `Create new session for ${spec.title}`, - description: "Create a binding-only session before Pi starts", - kind: "newSession", - decision: { action: "newSession", specId: spec.id }, - }) - - for (const session of sessions) { - if (session.file === currentSession?.file) { - continue - } - options.push({ - id: `open:${session.file}`, - label: `Resume ${spec.title}`, - description: sessionDescription(session, "Resume existing session"), - kind: "openSession", - decision: { - action: "openSession", - specId: spec.id, - sessionFile: session.file, - }, - }) - } - } - - options.push({ - id: "new-spec", - label: "Create new specification", - description: "Name a new spec and create its first session", - kind: "newSpec", - }) - options.push({ - id: "cancel", - label: "Cancel", - description: "Exit without activating a spec/session", - kind: "cancel", - decision: { action: "cancel" }, - }) - - return options -} - function findCurrentSession( inventory: WorkspaceLaunchInventory, ): WorkspaceLaunchSession | undefined { diff --git a/src/pi-components/workspace-dialog/preflight.ts b/src/pi-components/workspace-dialog/preflight.ts index a9f1173b..01a7cae6 100644 --- a/src/pi-components/workspace-dialog/preflight.ts +++ b/src/pi-components/workspace-dialog/preflight.ts @@ -3,7 +3,7 @@ import { ProcessTerminal, TUI, type Terminal } from "@earendil-works/pi-tui" import type { WorkspaceLaunchInventory, - WorkspaceSwitchDecision, + SpecSessionActivationDecision, } from "../../workspace-session-coordinator.js" import { WORKSPACE_DIALOG_WIDTH, @@ -19,13 +19,13 @@ interface WorkspaceDialogPreflightOptions { export async function runWorkspaceDialogPreflight( inventory: WorkspaceLaunchInventory, options: WorkspaceDialogPreflightOptions = {}, -): Promise<WorkspaceSwitchDecision> { +): Promise<SpecSessionActivationDecision> { const terminal = options.terminal ?? new ProcessTerminal() const tui = new TUI(terminal) const dialogTheme = options.theme ?? resolveStartupDialogTheme() - return await new Promise<WorkspaceSwitchDecision>((resolve) => { - const finish = (decision: WorkspaceSwitchDecision) => { + return await new Promise<SpecSessionActivationDecision>((resolve) => { + const finish = (decision: SpecSessionActivationDecision) => { overlay.hide() tui.stop() terminal.clearScreen() diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 6462c631..e3873fe1 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -21,7 +21,7 @@ import { } from "./pi-extensions/session-lifecycle.js" import { registerBrunchWorkspaceDialog, - type BrunchWorkspaceDialogOptions, + type BrunchSpecSessionPickerOptions, } from "./pi-extensions/workspace-dialog.js" export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" @@ -74,11 +74,11 @@ export { registerBrunchWorkspaceDialog, runBrunchWorkspaceAction, runBrunchWorkspaceCommand, - type BrunchWorkspaceDialogOptions, + type BrunchSpecSessionPickerOptions, } from "./pi-extensions/workspace-dialog.js" export interface BrunchPiExtensionShellOptions - extends BrunchWorkspaceDialogOptions { + extends BrunchSpecSessionPickerOptions { graphMentionSource?: GraphMentionSource } diff --git a/src/pi-extensions/workspace-dialog.ts b/src/pi-extensions/workspace-dialog.ts index d360ace2..15e277d4 100644 --- a/src/pi-extensions/workspace-dialog.ts +++ b/src/pi-extensions/workspace-dialog.ts @@ -5,8 +5,8 @@ import type { import { type WorkspaceSessionReadyState, - type WorkspaceSwitchCoordinator, - type WorkspaceSwitchDecision, + type SpecSessionActivationCoordinator, + type SpecSessionActivationDecision, } from "../workspace-session-coordinator.js" import { WORKSPACE_DIALOG_WIDTH, @@ -17,13 +17,13 @@ import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch" export const BRUNCH_WORKSPACE_SHORTCUT = "ctrl+shift+b" -export interface BrunchWorkspaceDialogOptions { - coordinator: WorkspaceSwitchCoordinator +export interface BrunchSpecSessionPickerOptions { + coordinator: SpecSessionActivationCoordinator } export function registerBrunchWorkspaceDialog( pi: ExtensionAPI, - { coordinator }: BrunchWorkspaceDialogOptions, + { coordinator }: BrunchSpecSessionPickerOptions, ): void { pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { description: "Open the Brunch spec/session picker", @@ -44,21 +44,21 @@ export function registerBrunchWorkspaceDialog( export async function runBrunchWorkspaceCommand( ctx: ExtensionCommandContext, - coordinator: WorkspaceSwitchCoordinator, + coordinator: SpecSessionActivationCoordinator, ): Promise<void> { await runBrunchWorkspaceAction(ctx, coordinator) } export async function runBrunchWorkspaceAction( ctx: ExtensionCommandContext, - coordinator: WorkspaceSwitchCoordinator, + coordinator: SpecSessionActivationCoordinator, options: { waitForIdle?: boolean } = {}, ): Promise<void> { if (options.waitForIdle !== false && canWaitForIdle(ctx)) { await ctx.waitForIdle() } const inventory = await coordinator.inspectWorkspace() - const decision = await ctx.ui.custom<WorkspaceSwitchDecision>( + const decision = await ctx.ui.custom<SpecSessionActivationDecision>( (_tui, theme, _keybindings, done) => createWorkspaceDialogComponent({ inventory, diff --git a/src/rpc.test.ts b/src/rpc.test.ts index f2c670c0..33a7d4d4 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -16,15 +16,15 @@ import type { WorkspaceLaunchInventory, WorkspaceSessionReadyState, WorkspaceSessionState, - WorkspaceSwitchCoordinator, - WorkspaceSwitchDecision, + SpecSessionActivationCoordinator, + SpecSessionActivationDecision, } from "./workspace-session-coordinator.js" function coordinator( state: WorkspaceSessionState = readyState( "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", ), -): DefaultWorkspaceCoordinator & WorkspaceSwitchCoordinator { +): DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator { const inventory = launchInventory() return { async openDefaultWorkspace() { @@ -34,7 +34,7 @@ function coordinator( return inventory }, async activateWorkspace( - decision: WorkspaceSwitchDecision, + decision: SpecSessionActivationDecision, ): Promise<WorkspaceActivationState> { if (decision.action === "cancel") return cancelledState() return readyState("/tmp/brunch-project/.brunch/sessions/session-1.jsonl") @@ -205,7 +205,7 @@ describe("JSON-RPC handlers", () => { }) it("activates valid workspace decisions and returns a serializable product snapshot", async () => { - const decisions: WorkspaceSwitchDecision[] = [] + const decisions: SpecSessionActivationDecision[] = [] const handlers = createRpcHandlers({ cwd: "/tmp/brunch-project", coordinator: { diff --git a/src/rpc.ts b/src/rpc.ts index 172acee1..5b4a2761 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -30,8 +30,8 @@ import type { WorkspaceActivationState, WorkspaceLaunchInventory, WorkspaceSessionState, - WorkspaceSwitchCoordinator, - WorkspaceSwitchDecision, + SpecSessionActivationCoordinator, + SpecSessionActivationDecision, } from "./workspace-session-coordinator.js" export interface RpcHandlers { @@ -39,7 +39,7 @@ export interface RpcHandlers { } export function createRpcHandlers(options: { - coordinator: DefaultWorkspaceCoordinator & Partial<WorkspaceSwitchCoordinator> + coordinator: DefaultWorkspaceCoordinator & Partial<SpecSessionActivationCoordinator> cwd: string }): RpcHandlers { return { @@ -159,7 +159,7 @@ function workspaceActivationSnapshotFromState( type WorkspaceActivationParamsParseResult = { ok: true - value: WorkspaceSwitchDecision + value: SpecSessionActivationDecision } | { ok: false } function parseWorkspaceActivationParams( diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index fab5937a..e7238b05 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -1,11 +1,10 @@ import { readFile } from "node:fs/promises" -import { visibleWidth, type Terminal } from "@earendil-works/pi-tui" +import { type Terminal } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" import { - buildWorkspaceDialogOptions, buildWorkspaceSelectionView, createWorkspaceDialogComponent, selectWorkspaceSelectionOption, @@ -118,36 +117,6 @@ describe("spec/session picker", () => { ]) }) - it("builds explicit resume, new-session, open-session, create-spec, and cancel options", () => { - const options = buildWorkspaceDialogOptions(inventory()) - - expect(options.map((option) => option.kind)).toEqual([ - "continue", - "newSession", - "openSession", - "newSession", - "openSession", - "newSpec", - "cancel", - ]) - expect(options[0]).toMatchObject({ - label: "Continue Alpha", - decision: { - action: "continue", - specId: "spec-alpha", - sessionFile: "/sessions/alpha-current.jsonl", - }, - }) - expect(options.at(-2)).toMatchObject({ - label: "Create new specification", - }) - expect(options.at(-2)).not.toHaveProperty("decision") - expect(options.at(-1)).toMatchObject({ - label: "Cancel", - decision: { action: "cancel" }, - }) - }) - it("renders specification copy without user-created workspace wording", () => { const component = createWorkspaceDialogComponent({ inventory: inventory(), @@ -294,23 +263,29 @@ describe("spec/session picker", () => { expect(terminal.events.at(-1)).toBe("clearScreen") }) - it("renders a branded centered-dialog frame with version metadata", () => { + it("renders a branded centered-dialog frame with separately styled version metadata", () => { const component = createWorkspaceDialogComponent({ inventory: inventory(), onDecision: () => {}, + theme: { + fg: (color, text) => `[${color}]${text}[/${color}]`, + }, }) const lines = component.render(80) expect(lines[0]).toContain("╭") - expect(lines[1]).toMatch(/^│\s+│$/) + expect(lines[1]).toMatch( + /^\[borderMuted\]│\[\/borderMuted\]\s+\[borderMuted\]│\[\/borderMuted\]$/, + ) expect(lines.some((line) => line.includes("Choose a specification"))).toBe( true, ) - expect(lines.some((line) => line.includes("brunch v0.0.0"))).toBe(true) - expect(lines.some((line) => line.includes("(dev"))).toBe(true) + expect( + lines.some((line) => line.includes("[accent]brunch v0.0.0[/accent]")), + ).toBe(true) + expect(lines.some((line) => line.includes("[success](dev"))).toBe(true) expect(lines.some((line) => line.includes("built on Pi v"))).toBe(true) - expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true) }) it("keeps logo assets colocated with the private picker component", async () => { diff --git a/src/workspace-session-coordinator.ts b/src/workspace-session-coordinator.ts index ed848068..9dd0ea8b 100644 --- a/src/workspace-session-coordinator.ts +++ b/src/workspace-session-coordinator.ts @@ -96,7 +96,7 @@ export interface WorkspaceCancelDecision { action: "cancel" } -export type WorkspaceSwitchDecision = WorkspaceContinueDecision | WorkspaceOpenSessionDecision | WorkspaceNewSessionDecision | WorkspaceNewSpecDecision | WorkspaceCancelDecision +export type SpecSessionActivationDecision = WorkspaceContinueDecision | WorkspaceOpenSessionDecision | WorkspaceNewSessionDecision | WorkspaceNewSpecDecision | WorkspaceCancelDecision export type WorkspaceActivationState = WorkspaceSessionReadyState | WorkspaceSessionNeedsHumanState | WorkspaceSessionCancelledState @@ -131,10 +131,10 @@ export interface WorkspaceLaunchInventory { unavailableSessions: WorkspaceUnavailableSession[] } -export interface WorkspaceSwitchCoordinator { +export interface SpecSessionActivationCoordinator { inspectWorkspace(): Promise<WorkspaceLaunchInventory> activateWorkspace( - decision: WorkspaceSwitchDecision, + decision: SpecSessionActivationDecision, ): Promise<WorkspaceActivationState> } @@ -161,7 +161,7 @@ export interface WorkspaceDefaultChromeCoordinator { } export interface WorkspaceSessionCoordinator - extends WorkspaceSwitchCoordinator, + extends SpecSessionActivationCoordinator, DefaultWorkspaceCoordinator, WorkspaceSetupCoordinator, WorkspaceSessionBoundaryCoordinator, @@ -186,7 +186,7 @@ class FileWorkspaceSessionCoordinator implements WorkspaceSessionCoordinator { } async activateWorkspace( - decision: WorkspaceSwitchDecision, + decision: SpecSessionActivationDecision, ): Promise<WorkspaceActivationState> { if (decision.action === "cancel") { const state = await readWorkspaceState(this.#cwd) From 5bd098fd2f173a0c7a2090d1e0c5c11bc0066125 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:19:26 +0200 Subject: [PATCH 069/164] Schema-validate spec session RPC activation --- memory/CARDS.md | 4 +- src/brunch.ts | 3 +- src/fixture-capture.test.ts | 9 ++-- src/fixture-capture.ts | 4 +- src/rpc.test.ts | 62 +++++++++++++++------- src/rpc.ts | 101 +++++++++++++++++++++--------------- src/web-host.test.ts | 5 +- src/web-host.ts | 4 +- 8 files changed, 118 insertions(+), 74 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index bb45abb4..7e9ceb95 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -5,7 +5,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Orientation - **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified. Card 1 retired stale flat-picker exports, renamed the activation decision/coordinator types, and restored separate dev-tag styling; remaining review findings are the ad-hoc RPC activation parser, partial-coordinator capability smell, and visible regression to minimal chrome. +- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–2 retired stale flat-picker exports, renamed the activation decision/coordinator types, restored separate dev-tag styling, and put `workspace.activate` behind a TypeBox-backed activation schema with required coordinator capabilities. The remaining review finding is the visible regression to minimal chrome. - **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. - **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). @@ -55,7 +55,7 @@ Retire the obsolete flat workspace-dialog option API, rename the activation deci ## Card 2 — Schema-backed RPC spec/session activation boundary -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch.ts b/src/brunch.ts index 889c2722..7e69439a 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -11,13 +11,12 @@ import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { startWebHost } from "./web-host.js" import { createWorkspaceSessionCoordinator, - type DefaultWorkspaceCoordinator, type WorkspaceSessionCoordinator, } from "./workspace-session-coordinator.js" export interface WebHostRunnerOptions { cwd: string - coordinator: DefaultWorkspaceCoordinator + coordinator: WorkspaceSessionCoordinator } export interface BrunchCliOptions { diff --git a/src/fixture-capture.test.ts b/src/fixture-capture.test.ts index e76d758c..7b676aff 100644 --- a/src/fixture-capture.test.ts +++ b/src/fixture-capture.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" -import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" -import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" +import { + createWorkspaceSessionCoordinator, + type WorkspaceSessionCoordinator, +} from "./workspace-session-coordinator.js" import { loadLinearElicitationExchangeProjection } from "./elicitation-exchange.js" import { assistantMessage, userMessage } from "./test-helpers.js" import { @@ -95,7 +97,8 @@ describe("fixture capture", () => { workspace.session.manager.appendMessage(assistantMessage("Question")) workspace.session.manager.appendMessage(userMessage("Answer")) - const coordinator: DefaultWorkspaceCoordinator = { + const coordinator: WorkspaceSessionCoordinator = { + ...createWorkspaceSessionCoordinator({ cwd }), async openDefaultWorkspace() { return workspace }, diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index 9e1c8db3..e8e82e92 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -10,8 +10,8 @@ import type { WorkspaceSnapshot } from "./print-snapshot.js" import type { JsonRpcResponse } from "./json-rpc-protocol.js" import { createWorkspaceSessionCoordinator, - type DefaultWorkspaceCoordinator, type WorkspaceSessionBoundaryCoordinator, + type WorkspaceSessionCoordinator, type WorkspaceSetupCoordinator, } from "./workspace-session-coordinator.js" @@ -20,7 +20,7 @@ export interface FixtureCaptureOptions { briefId: string runId: string timestamp?: string - coordinator?: DefaultWorkspaceCoordinator + coordinator?: WorkspaceSessionCoordinator } export interface FixtureCaptureResult { diff --git a/src/rpc.test.ts b/src/rpc.test.ts index 33a7d4d4..cb14c51f 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -204,7 +204,7 @@ describe("JSON-RPC handlers", () => { }) }) - it("activates valid workspace decisions and returns a serializable product snapshot", async () => { + it("activates valid spec/session decisions and returns serializable product snapshots", async () => { const decisions: SpecSessionActivationDecision[] = [] const handlers = createRpcHandlers({ cwd: "/tmp/brunch-project", @@ -212,30 +212,52 @@ describe("JSON-RPC handlers", () => { ...coordinator(), async activateWorkspace(decision): Promise<WorkspaceActivationState> { decisions.push(decision) - return readyState( - "/tmp/brunch-project/.brunch/sessions/session-1.jsonl", - ) + return decision.action === "cancel" + ? cancelledState() + : readyState("/tmp/brunch-project/.brunch/sessions/session-1.jsonl") }, }, }) - await expect( - handlers.handle({ - jsonrpc: "2.0", - id: 21, - method: "workspace.activate", - params: { decision: { action: "newSession", specId: "spec-1" } }, - }), - ).resolves.toMatchObject({ - jsonrpc: "2.0", - id: 21, - result: { - status: "ready", - spec: { id: "spec-1" }, - session: { id: "session-1" }, + const validDecisions: SpecSessionActivationDecision[] = [ + { action: "cancel" }, + { action: "newSpec", title: "New spec" }, + { action: "newSession", specId: "spec-1" }, + { + action: "continue", + specId: "spec-1", + sessionFile: "session-1.jsonl", }, - }) - expect(decisions).toEqual([{ action: "newSession", specId: "spec-1" }]) + { + action: "openSession", + specId: "spec-1", + sessionFile: "session-2.jsonl", + }, + ] + + for (const [index, decision] of validDecisions.entries()) { + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 21 + index, + method: "workspace.activate", + params: { decision }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 21 + index, + result: + decision.action === "cancel" + ? { status: "cancelled", spec: { id: "spec-1" } } + : { + status: "ready", + spec: { id: "spec-1" }, + session: { id: "session-1" }, + }, + }) + expect(decisions).toHaveLength(index + 1) + expect(decisions[index]).toEqual(decision) + } }) it("rejects invalid workspace activation params", async () => { diff --git a/src/rpc.ts b/src/rpc.ts index 5b4a2761..b442c074 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,6 +1,9 @@ import { createInterface } from "node:readline/promises" import type { Readable, Writable } from "node:stream" +import { Type, type Static } from "typebox" +import { Value } from "typebox/value" + import { readBrunchSessionEnvelope, NonLinearTranscriptError, @@ -39,7 +42,7 @@ export interface RpcHandlers { } export function createRpcHandlers(options: { - coordinator: DefaultWorkspaceCoordinator & Partial<SpecSessionActivationCoordinator> + coordinator: DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator cwd: string }): RpcHandlers { return { @@ -65,9 +68,6 @@ export function createRpcHandlers(options: { if (request.params !== undefined) { return createJsonRpcFailure(requestId, -32602, "Invalid params") } - if (!options.coordinator.inspectWorkspace) { - return createJsonRpcFailure(requestId, -32603, "Internal error") - } const [state, inventory] = await Promise.all([ options.coordinator.openDefaultWorkspace(), options.coordinator.inspectWorkspace(), @@ -83,9 +83,6 @@ export function createRpcHandlers(options: { if (!decision.ok) { return createJsonRpcFailure(requestId, -32602, "Invalid params") } - if (!options.coordinator.activateWorkspace) { - return createJsonRpcFailure(requestId, -32603, "Internal error") - } const state = await options.coordinator.activateWorkspace( decision.value, ) @@ -157,6 +154,56 @@ function workspaceActivationSnapshotFromState( return workspaceSnapshotFromState(state) } +const NonBlankStringSchema = Type.String({ minLength: 1, pattern: "\\S" }) + +export const SpecSessionActivationDecisionSchema = Type.Union([ + Type.Object( + { + action: Type.Literal("continue"), + specId: NonBlankStringSchema, + sessionFile: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal("openSession"), + specId: NonBlankStringSchema, + sessionFile: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal("newSession"), + specId: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal("newSpec"), + title: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal("cancel"), + }, + { additionalProperties: false }, + ), +]) + +const WorkspaceActivationParamsSchema = Type.Object( + { + decision: SpecSessionActivationDecisionSchema, + }, + { additionalProperties: false }, +) + +type WorkspaceActivationParams = Static<typeof WorkspaceActivationParamsSchema> + type WorkspaceActivationParamsParseResult = { ok: true value: SpecSessionActivationDecision @@ -165,42 +212,14 @@ type WorkspaceActivationParamsParseResult = { function parseWorkspaceActivationParams( value: unknown, ): WorkspaceActivationParamsParseResult { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return { ok: false } - } - const decision = (value as { decision?: unknown }).decision - if ( - typeof decision !== "object" || - decision === null || - Array.isArray(decision) - ) { + if (!Value.Check(WorkspaceActivationParamsSchema, value)) { return { ok: false } } - const action = (decision as { action?: unknown }).action - if (action === "cancel") return { ok: true, value: { action } } - if (action === "newSpec") { - const title = (decision as { title?: unknown }).title - return typeof title === "string" && title.trim().length > 0 - ? { ok: true, value: { action, title } } - : { ok: false } - } - if (action === "newSession") { - const specId = (decision as { specId?: unknown }).specId - return typeof specId === "string" && specId.length > 0 - ? { ok: true, value: { action, specId } } - : { ok: false } - } - if (action === "continue" || action === "openSession") { - const specId = (decision as { specId?: unknown }).specId - const sessionFile = (decision as { sessionFile?: unknown }).sessionFile - return typeof specId === "string" && - specId.length > 0 && - typeof sessionFile === "string" && - sessionFile.length > 0 - ? { ok: true, value: { action, specId, sessionFile } } - : { ok: false } - } - return { ok: false } + const params: WorkspaceActivationParams = Value.Parse( + WorkspaceActivationParamsSchema, + value, + ) + return { ok: true, value: params.decision } } async function handleSessionProjection<T>( diff --git a/src/web-host.test.ts b/src/web-host.test.ts index 2319dc53..80c25967 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -9,7 +9,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { createWorkspaceSessionCoordinator, - type DefaultWorkspaceCoordinator, + type WorkspaceSessionCoordinator, } from "./workspace-session-coordinator.js" import { startWebHost } from "./web-host.js" import { assistantMessage, userMessage } from "./test-helpers.js" @@ -485,8 +485,9 @@ function openWebSocket(url: string): Promise<WebSocket> { }) } -function throwingCoordinator(): DefaultWorkspaceCoordinator { +function throwingCoordinator(): WorkspaceSessionCoordinator { return { + ...createWorkspaceSessionCoordinator({ cwd: "/tmp/brunch-project" }), async openDefaultWorkspace() { throw new Error("boom") }, diff --git a/src/web-host.ts b/src/web-host.ts index 1f4ea9fd..8442d57f 100644 --- a/src/web-host.ts +++ b/src/web-host.ts @@ -5,13 +5,13 @@ import { fileURLToPath } from "node:url" import { createRpcHandlers } from "./rpc.js" import { attachWebRpcTransport } from "./web-rpc-transport.js" -import type { DefaultWorkspaceCoordinator } from "./workspace-session-coordinator.js" +import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" export interface WebHostOptions { cwd: string port?: number hostname?: string - coordinator?: DefaultWorkspaceCoordinator + coordinator?: WorkspaceSessionCoordinator webAssetRoot?: string } From c43c122488e32af9c05aa141aa44e07c24697865 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:21:20 +0200 Subject: [PATCH 070/164] Restore rich Brunch chrome projection --- memory/CARDS.md | 4 +- src/brunch-tui.test.ts | 50 ++++++++++++++++++++--- src/pi-extensions/chrome.ts | 79 +++++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 11 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 7e9ceb95..86059ce5 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -5,7 +5,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Orientation - **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–2 retired stale flat-picker exports, renamed the activation decision/coordinator types, restored separate dev-tag styling, and put `workspace.activate` behind a TypeBox-backed activation schema with required coordinator capabilities. The remaining review finding is the visible regression to minimal chrome. +- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–3 retired stale picker/RPC APIs, put `workspace.activate` behind a TypeBox-backed activation schema with required coordinator capabilities, and restored richer Brunch chrome formatting with optional runtime/build/context metadata. The remaining queue is the structured-question / RPC-relay proof. - **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. - **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). @@ -102,7 +102,7 @@ Retire the obsolete flat workspace-dialog option API, rename the activation deci ## Card 3 — Restore rich Brunch chrome projection -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 6f120115..d8beb8c8 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -207,24 +207,62 @@ describe("Brunch TUI boot", () => { } expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch specification workspace", + "brunch · Spec One", "cwd: /tmp/project", - "Spec One · Interview #1", + "session: Interview #1 · phase: elicitation", ]) expect(formatBrunchChromeFooterLines(state)).toEqual([ - "phase: elicitation · chat: responding-to-elicitation", + "runtime: not reported · build: not reported", + "context: not reported", + "state: responding-to-elicitation · coherence: unknown · worker: not reported", "spec: Spec One · session: Interview #1", "", ]) - expect(formatBrunchStatus(state)).toBe("Brunch · elicitation · Spec One") + expect(formatBrunchStatus(state)).toBe( + "Brunch · elicitation · Spec One · not reported", + ) expect(formatChromeWidgetLines(state)).toEqual([ "cwd: /tmp/project", "spec: Spec One", "session: Interview #1", + "runtime: not reported", + "context: not reported", "chat mode: responding-to-elicitation", ]) }) + it("formats rich optional runtime and context metadata without fabricating missing fields", () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + chatMode: "responding-to-elicitation" as const, + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + lens: "step-by-step", + }, + build: { version: "v0.0.0", dev: "dev abc123" }, + contextUsage: { usedTokens: 1024, maxTokens: 2048 }, + worker: { stage: "observer-review" as const, status: "queued" as const }, + coherence: "needs_review" as const, + } + + expect(formatBrunchChromeFooterLines(state)).toEqual([ + "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", + "context: [█████░░░░░] 1,024/2,048 tokens (50%)", + "state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued", + "spec: Spec One · session: Interview #1", + "", + ]) + expect(formatChromeWidgetLines(state)).toContain( + "context: [█████░░░░░] 1,024/2,048 tokens (50%)", + ) + }) + it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { const calls: FakeUiCall[] = [] const ui: FakeExtensionUi = { @@ -262,7 +300,7 @@ describe("Brunch TUI boot", () => { ) expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ "brunch.chrome", - "Brunch · elicitation · Spec One", + "Brunch · elicitation · Spec One · not reported", ]) expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ "brunch.chrome", @@ -270,6 +308,8 @@ describe("Brunch TUI boot", () => { "cwd: /tmp/project", "spec: Spec One", "session: session-1", + "runtime: not reported", + "context: not reported", "chat mode: responding-to-elicitation", ], { placement: "aboveEditor" }, diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index e4b0916a..ca9ed7ac 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -9,11 +9,37 @@ export type BrunchChromeStage = "idle" | "streaming" | "observer-review" export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" export type BrunchChromeCoherenceVerdict = "unknown" | "coherent" | "needs_review" | "incoherent" +export interface BrunchChromeContextUsage { + usedTokens: number + maxTokens: number +} + +export interface BrunchChromeRuntimeState { + bundle?: string + role?: string + model?: string + thinking?: string + lens?: string +} + +export interface BrunchChromeBuildState { + version?: string + dev?: string +} + export interface BrunchChromeState extends WorkspaceSessionChromeState { session: { id: string label?: string } + runtime?: BrunchChromeRuntimeState + build?: BrunchChromeBuildState + contextUsage?: BrunchChromeContextUsage + worker?: { + stage?: BrunchChromeStage + status?: BrunchChromeWorkerStatus + } + coherence?: BrunchChromeCoherenceVerdict } export type BrunchChromeUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setStatus" | "setWidget" | "setTitle"> @@ -22,9 +48,9 @@ export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, ): string[] { return [ - "brunch specification workspace", + `brunch · ${formatSpec(chrome)}`, `cwd: ${chrome.cwd}`, - `${formatSpec(chrome)} · ${formatSession(chrome)}`, + `session: ${formatSession(chrome)} · phase: ${chrome.phase}`, ] } @@ -32,14 +58,16 @@ export function formatBrunchChromeFooterLines( chrome: BrunchChromeState, ): string[] { return [ - `phase: ${chrome.phase} · chat: ${chrome.chatMode}`, + `runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}`, + `context: ${formatContextUsage(chrome.contextUsage)}`, + `state: ${chrome.chatMode} · coherence: ${chrome.coherence ?? "unknown"} · worker: ${formatWorker(chrome)}`, `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}`, "", ] } export function formatBrunchStatus(chrome: BrunchChromeState): string { - return `Brunch · ${chrome.phase} · ${formatSpec(chrome)}` + return `Brunch · ${chrome.phase} · ${formatSpec(chrome)} · ${formatRuntime(chrome)}` } export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { @@ -47,6 +75,8 @@ export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { `cwd: ${chrome.cwd}`, `spec: ${formatSpec(chrome)}`, `session: ${formatSession(chrome)}`, + `runtime: ${formatRuntime(chrome)}`, + `context: ${formatContextUsage(chrome.contextUsage)}`, `chat mode: ${chrome.chatMode}`, ] } @@ -89,3 +119,44 @@ function formatSpec(chrome: BrunchChromeState): string { function formatSession(chrome: BrunchChromeState): string { return chrome.session.label ?? chrome.session.id } + +function formatRuntime(chrome: BrunchChromeState): string { + const runtime = chrome.runtime + if (!runtime) return "not reported" + const parts = [ + runtime.bundle, + runtime.role ? `role ${runtime.role}` : undefined, + runtime.model, + runtime.thinking ? `thinking ${runtime.thinking}` : undefined, + runtime.lens ? `lens ${runtime.lens}` : undefined, + ].filter((part): part is string => Boolean(part)) + return parts.length > 0 ? parts.join(" · ") : "not reported" +} + +function formatBuild(chrome: BrunchChromeState): string { + const build = chrome.build + if (!build) return "not reported" + return [build.version, build.dev].filter(Boolean).join(" ") || "not reported" +} + +function formatContextUsage( + usage: BrunchChromeContextUsage | undefined, +): string { + if (!usage) return "not reported" + const max = Math.max(0, usage.maxTokens) + const used = Math.max(0, usage.usedTokens) + if (max === 0) return `${used.toLocaleString()} tokens · no limit reported` + const ratio = Math.min(1, used / max) + const filled = Math.round(ratio * 10) + const bar = `${"█".repeat(filled)}${"░".repeat(10 - filled)}` + const percent = Math.round(ratio * 100) + return `[${bar}] ${used.toLocaleString()}/${max.toLocaleString()} tokens (${percent}%)` +} + +function formatWorker(chrome: BrunchChromeState): string { + const worker = chrome.worker + if (!worker) return "not reported" + return ( + [worker.stage, worker.status].filter(Boolean).join("/") || "not reported" + ) +} From c02496c7c5c44f7b38ef396f1b89f60cc24b523d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:25:03 +0200 Subject: [PATCH 071/164] Define structured question result payloads --- memory/CARDS.md | 4 +- memory/PLAN.md | 2 +- memory/SPEC.md | 2 +- src/structured-question.test.ts | 208 +++++++++++++++++++++++++++ src/structured-question.ts | 247 ++++++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 src/structured-question.test.ts create mode 100644 src/structured-question.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 86059ce5..00c94b3f 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -5,7 +5,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Orientation - **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–3 retired stale picker/RPC APIs, put `workspace.activate` behind a TypeBox-backed activation schema with required coordinator capabilities, and restored richer Brunch chrome formatting with optional runtime/build/context metadata. The remaining queue is the structured-question / RPC-relay proof. +- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–4 retired stale picker/RPC APIs, restored richer Brunch chrome formatting, and added the TypeBox-backed structured-question result schema/builder for self-contained `toolResult.details`. The remaining queue is TUI input replacement and RPC JSON-editor fallback. - **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. - **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). @@ -150,7 +150,7 @@ The persistent Brunch TUI chrome renders a richer product-owned header/footer/st ## Card 4 — Structured-question result model and transcript payload -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/memory/PLAN.md b/memory/PLAN.md index 09e45990..a6759847 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -245,7 +245,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes `workspace.selectionState` / `workspace.activate` without importing TUI picker code, and the startup no-resume pty oracle passes with the new spec/session copy. Next scope the structured-question result + JSON-editor RPC fallback spike. Use Pi's `question.ts`, `questionnaire.ts`, `rpc-demo.ts`, and `examples/rpc-extension-ui.ts` as implementation references; prove self-contained `toolResult.details`, TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder now proves self-contained `toolResult.details` plus model-readable `content` for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 058d20d9..b6f19c19 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -246,7 +246,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | planned (FE-744 structured-question tool tests + JSON-over-editor RPC fallback + projection contract) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI custom UI, JSON-over-editor RPC fallback, and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | diff --git a/src/structured-question.test.ts b/src/structured-question.test.ts new file mode 100644 index 00000000..dce48dae --- /dev/null +++ b/src/structured-question.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest" +import { Value } from "typebox/value" + +import { + StructuredQuestionResultDetailsSchema, + buildStructuredQuestionResult, + parseStructuredQuestionParams, + structuredQuestionSummary, + type StructuredQuestionAnswer, + type StructuredQuestionParams, +} from "./structured-question.js" + +const transport = { surface: "test" as const, requestId: "req-1" } + +describe("structured-question result model", () => { + it("builds self-contained text answer details and content", () => { + const params: StructuredQuestionParams = { + id: "q-domain", + mode: "text", + prompt: "What domain are we in?", + required: true, + } + + const result = buildStructuredQuestionResult({ + params, + status: "answered", + transport, + answers: [ + { questionId: "q-domain", mode: "text", value: "Local-first devtools" }, + ], + }) + + expect( + Value.Check(StructuredQuestionResultDetailsSchema, result.details), + ).toBe(true) + expect(result.details).toMatchObject({ + schema: "brunch.structured_question.result", + schemaVersion: 1, + status: "answered", + mode: "text", + prompt: "What domain are we in?", + questions: [{ id: "q-domain", mode: "text" }], + answers: [{ questionId: "q-domain", value: "Local-first devtools" }], + transport, + }) + expect(result.content).toEqual([ + { + type: "text", + text: "What domain are we in?: Local-first devtools", + }, + ]) + }) + + it("builds single-select details with options and optional freeform", () => { + const params: StructuredQuestionParams = { + id: "q-risk", + mode: "singleSelect", + prompt: "Which risk dominates?", + options: [ + { id: "ux", label: "UX ambiguity", description: "User cannot choose" }, + { id: "rpc", label: "RPC mismatch" }, + ], + allowFreeform: true, + } + + const result = buildStructuredQuestionResult({ + params, + status: "answered", + transport, + answers: [ + { + questionId: "q-risk", + mode: "singleSelect", + selectedOption: { id: "rpc", label: "RPC mismatch" }, + freeform: "Editor fallback must match TUI semantics.", + }, + ], + }) + + expect(result.details.questions[0]).toMatchObject({ + options: [ + { id: "ux", label: "UX ambiguity" }, + { id: "rpc", label: "RPC mismatch" }, + ], + allowFreeform: true, + }) + expect(result.details.answers[0]).toMatchObject({ + selectedOption: { id: "rpc", label: "RPC mismatch" }, + freeform: "Editor fallback must match TUI semantics.", + }) + expect(result.content[0]?.text).toBe( + "Which risk dominates?: RPC mismatch; freeform: Editor fallback must match TUI semantics.", + ) + }) + + it("builds multi-select details with selected option labels", () => { + const params: StructuredQuestionParams = { + id: "q-oracles", + mode: "multiSelect", + prompt: "Which oracles apply?", + options: [ + { id: "unit", label: "Unit" }, + { id: "rpc", label: "RPC contract" }, + { id: "pty", label: "PTY smoke" }, + ], + } + + const result = buildStructuredQuestionResult({ + params, + status: "answered", + transport, + answers: [ + { + questionId: "q-oracles", + mode: "multiSelect", + selectedOptions: [ + { id: "rpc", label: "RPC contract" }, + { id: "pty", label: "PTY smoke" }, + ], + }, + ], + }) + + expect(result.details.answers[0]).toMatchObject({ + mode: "multiSelect", + selectedOptions: [ + { id: "rpc", label: "RPC contract" }, + { id: "pty", label: "PTY smoke" }, + ], + }) + expect(result.content[0]?.text).toBe( + "Which oracles apply?: RPC contract, PTY smoke", + ) + }) + + it("builds questionnaire details with each prompt, option set, and answer", () => { + const params: StructuredQuestionParams = { + id: "q-grounding", + mode: "questionnaire", + prompt: "Grounding bundle", + questions: [ + { + id: "domain", + mode: "text", + prompt: "Domain?", + }, + { + id: "pressure", + mode: "singleSelect", + prompt: "Main pressure?", + options: [ + { id: "speed", label: "Speed" }, + { id: "trust", label: "Trust" }, + ], + }, + ], + } + const answers: StructuredQuestionAnswer[] = [ + { questionId: "domain", mode: "text", value: "Developer tooling" }, + { + questionId: "pressure", + mode: "singleSelect", + selectedOption: { id: "trust", label: "Trust" }, + }, + ] + + const result = buildStructuredQuestionResult({ + params, + status: "answered", + transport, + answers, + }) + + expect(result.details.mode).toBe("questionnaire") + expect(result.details.questions.map((question) => question.prompt)).toEqual( + ["Domain?", "Main pressure?"], + ) + expect(result.details.answers).toEqual(answers) + expect(result.content[0]?.text).toBe( + "Domain?: Developer tooling\nMain pressure?: Trust", + ) + }) + + it("builds terminal skipped, cancelled, and unavailable details without answers", () => { + const params = parseStructuredQuestionParams({ + id: "q-terminal", + mode: "text", + prompt: "Can you answer?", + }) + + for (const status of ["skipped", "cancelled", "unavailable"] as const) { + const result = buildStructuredQuestionResult({ + params, + status, + transport: { surface: "headless" }, + ...(status === "unavailable" ? { message: "UI unavailable" } : {}), + }) + + expect(result.details).toMatchObject({ + status, + answers: [], + questions: [{ id: "q-terminal", prompt: "Can you answer?" }], + transport: { surface: "headless" }, + }) + expect(structuredQuestionSummary(result.details)).toContain(status) + } + }) +}) diff --git a/src/structured-question.ts b/src/structured-question.ts new file mode 100644 index 00000000..2fdecb98 --- /dev/null +++ b/src/structured-question.ts @@ -0,0 +1,247 @@ +import { Type, type Static } from "typebox" +import { Value } from "typebox/value" + +const NonBlankStringSchema = Type.String({ minLength: 1, pattern: "\\S" }) + +export const StructuredQuestionOptionSchema = Type.Object( + { + id: NonBlankStringSchema, + label: NonBlankStringSchema, + description: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +const TextQuestionSchema = Type.Object( + { + id: NonBlankStringSchema, + mode: Type.Literal("text"), + prompt: NonBlankStringSchema, + required: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +) + +const SingleSelectQuestionSchema = Type.Object( + { + id: NonBlankStringSchema, + mode: Type.Literal("singleSelect"), + prompt: NonBlankStringSchema, + options: Type.Array(StructuredQuestionOptionSchema, { minItems: 1 }), + allowFreeform: Type.Optional(Type.Boolean()), + required: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +) + +const MultiSelectQuestionSchema = Type.Object( + { + id: NonBlankStringSchema, + mode: Type.Literal("multiSelect"), + prompt: NonBlankStringSchema, + options: Type.Array(StructuredQuestionOptionSchema, { minItems: 1 }), + allowFreeform: Type.Optional(Type.Boolean()), + required: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +) + +export const StructuredQuestionSchema = Type.Union([ + TextQuestionSchema, + SingleSelectQuestionSchema, + MultiSelectQuestionSchema, +]) + +export const StructuredQuestionParamsSchema = Type.Union([ + TextQuestionSchema, + SingleSelectQuestionSchema, + MultiSelectQuestionSchema, + Type.Object( + { + id: NonBlankStringSchema, + mode: Type.Literal("questionnaire"), + prompt: NonBlankStringSchema, + questions: Type.Array(StructuredQuestionSchema, { minItems: 1 }), + }, + { additionalProperties: false }, + ), +]) + +const SelectedOptionSchema = Type.Object( + { + id: NonBlankStringSchema, + label: NonBlankStringSchema, + }, + { additionalProperties: false }, +) + +const TextAnswerSchema = Type.Object( + { + questionId: NonBlankStringSchema, + mode: Type.Literal("text"), + value: Type.String(), + }, + { additionalProperties: false }, +) + +const SingleSelectAnswerSchema = Type.Object( + { + questionId: NonBlankStringSchema, + mode: Type.Literal("singleSelect"), + selectedOption: Type.Optional(SelectedOptionSchema), + freeform: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +const MultiSelectAnswerSchema = Type.Object( + { + questionId: NonBlankStringSchema, + mode: Type.Literal("multiSelect"), + selectedOptions: Type.Array(SelectedOptionSchema), + freeform: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +export const StructuredQuestionAnswerSchema = Type.Union([ + TextAnswerSchema, + SingleSelectAnswerSchema, + MultiSelectAnswerSchema, +]) + +export const StructuredQuestionTransportSchema = Type.Object( + { + surface: Type.Union([ + Type.Literal("tui-custom"), + Type.Literal("rpc-editor"), + Type.Literal("rpc-dialog"), + Type.Literal("headless"), + Type.Literal("test"), + ]), + requestId: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +export const StructuredQuestionResultDetailsSchema = Type.Object( + { + schema: Type.Literal("brunch.structured_question.result"), + schemaVersion: Type.Literal(1), + status: Type.Union([ + Type.Literal("answered"), + Type.Literal("skipped"), + Type.Literal("cancelled"), + Type.Literal("unavailable"), + ]), + mode: Type.Union([ + Type.Literal("text"), + Type.Literal("singleSelect"), + Type.Literal("multiSelect"), + Type.Literal("questionnaire"), + ]), + prompt: NonBlankStringSchema, + questions: Type.Array(StructuredQuestionSchema, { minItems: 1 }), + answers: Type.Array(StructuredQuestionAnswerSchema), + transport: StructuredQuestionTransportSchema, + message: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +export type StructuredQuestionParams = Static<typeof StructuredQuestionParamsSchema> +export type StructuredQuestion = Static<typeof StructuredQuestionSchema> +export type StructuredQuestionAnswer = Static<typeof StructuredQuestionAnswerSchema> +export type StructuredQuestionTransport = Static<typeof StructuredQuestionTransportSchema> +export type StructuredQuestionResultDetails = Static<typeof StructuredQuestionResultDetailsSchema> +export type StructuredQuestionStatus = StructuredQuestionResultDetails["status"] + +export interface StructuredQuestionContentPart { + type: "text" + text: string +} + +export interface StructuredQuestionToolResult { + content: StructuredQuestionContentPart[] + details: StructuredQuestionResultDetails +} + +export function parseStructuredQuestionParams( + value: unknown, +): StructuredQuestionParams { + return Value.Parse(StructuredQuestionParamsSchema, value) +} + +export function buildStructuredQuestionResult(input: { + params: StructuredQuestionParams + status: StructuredQuestionStatus + answers?: StructuredQuestionAnswer[] + transport: StructuredQuestionTransport + message?: string +}): StructuredQuestionToolResult { + const details = Value.Parse(StructuredQuestionResultDetailsSchema, { + schema: "brunch.structured_question.result", + schemaVersion: 1, + status: input.status, + mode: input.params.mode, + prompt: input.params.prompt, + questions: questionsFromParams(input.params), + answers: input.answers ?? [], + transport: input.transport, + ...(input.message ? { message: input.message } : {}), + }) + return { + content: structuredQuestionContent(details), + details, + } +} + +export function structuredQuestionContent( + details: StructuredQuestionResultDetails, +): StructuredQuestionContentPart[] { + return [{ type: "text", text: structuredQuestionSummary(details) }] +} + +export function structuredQuestionSummary( + details: StructuredQuestionResultDetails, +): string { + if (details.status !== "answered") { + return details.message + ? `Structured question ${details.status}: ${details.message}` + : `Structured question ${details.status}.` + } + + if (details.answers.length === 0) return "Structured question answered." + + const lines = details.answers.map((answer) => { + const question = details.questions.find( + (candidate) => candidate.id === answer.questionId, + ) + const label = question ? question.prompt : answer.questionId + return `${label}: ${formatAnswer(answer)}` + }) + return lines.join("\n") +} + +function questionsFromParams( + params: StructuredQuestionParams, +): StructuredQuestion[] { + if (params.mode === "questionnaire") return params.questions + return [params] +} + +function formatAnswer(answer: StructuredQuestionAnswer): string { + if (answer.mode === "text") return answer.value || "(empty response)" + if (answer.mode === "singleSelect") { + const selected = answer.selectedOption?.label + const freeform = answer.freeform ? `freeform: ${answer.freeform}` : null + return [selected, freeform].filter(Boolean).join("; ") || "(no selection)" + } + const selected = answer.selectedOptions + .map((option) => option.label) + .join(", ") + const freeform = answer.freeform ? `freeform: ${answer.freeform}` : null + return ( + [selected || null, freeform].filter(Boolean).join("; ") || "(no selections)" + ) +} From beecf5837daf8402896c5851814e54488e7653f7 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:28:33 +0200 Subject: [PATCH 072/164] Add structured question TUI adapter --- memory/CARDS.md | 4 +- memory/PLAN.md | 2 +- memory/SPEC.md | 2 +- src/brunch-tui.test.ts | 1 + src/pi-extensions.ts | 9 + src/pi-extensions/structured-question.test.ts | 203 ++++++++++++++++ src/pi-extensions/structured-question.ts | 222 ++++++++++++++++++ 7 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 src/pi-extensions/structured-question.test.ts create mode 100644 src/pi-extensions/structured-question.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 00c94b3f..28c6f829 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -5,7 +5,7 @@ Status key: `next` / `in progress` / `done` / `dropped`. ## Orientation - **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–4 retired stale picker/RPC APIs, restored richer Brunch chrome formatting, and added the TypeBox-backed structured-question result schema/builder for self-contained `toolResult.details`. The remaining queue is TUI input replacement and RPC JSON-editor fallback. +- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–5 retired stale picker/RPC APIs, restored richer Brunch chrome formatting, added the TypeBox-backed structured-question result schema/builder, and registered the TUI custom adapter for input-replacing structured answers. The remaining queue is RPC JSON-editor fallback. - **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. - **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). @@ -196,7 +196,7 @@ A Brunch structured-question tool can return a self-contained `toolResult.detail ## Card 5 — TUI custom UI adapter for structured questions -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior diff --git a/memory/PLAN.md b/memory/PLAN.md index a6759847..f7683d07 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -245,7 +245,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder now proves self-contained `toolResult.details` plus model-readable `content` for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with TUI input replacement, JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI adapter now prove self-contained `toolResult.details`, model-readable `content`, and input-replacing TUI answer collection for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index b6f19c19..fe30bdee 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -246,7 +246,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI custom UI, JSON-over-editor RPC fallback, and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor RPC fallback and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index d8beb8c8..dc865d67 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -433,6 +433,7 @@ describe("Brunch TUI boot", () => { "find", "ls", "present_alternatives", + "brunch_structured_question", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( "Open the Brunch spec/session picker", diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index e3873fe1..5c9edcfa 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -10,6 +10,7 @@ import { type GraphMentionSource, } from "./pi-extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" +import { registerBrunchStructuredQuestion } from "./pi-extensions/structured-question.js" import { renderBrunchChrome, type BrunchChromeState, @@ -68,6 +69,13 @@ export { registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, } from "./pi-extensions/session-lifecycle.js" +export { + STRUCTURED_QUESTION_TOOL, + answerStructuredQuestionWithTui, + createStructuredQuestionTuiComponent, + registerBrunchStructuredQuestion, + type StructuredQuestionTuiResponse, +} from "./pi-extensions/structured-question.js" export { BRUNCH_WORKSPACE_COMMAND, BRUNCH_WORKSPACE_SHORTCUT, @@ -100,6 +108,7 @@ export function createBrunchPiExtensionShell( registerBrunchOperationalModePolicy(pi) registerBrunchMentionAutocomplete(pi, options.graphMentionSource) registerBrunchAlternatives(pi) + registerBrunchStructuredQuestion(pi) registerBrunchWorkspaceDialog(pi, options) } } diff --git a/src/pi-extensions/structured-question.test.ts b/src/pi-extensions/structured-question.test.ts new file mode 100644 index 00000000..cb9589ce --- /dev/null +++ b/src/pi-extensions/structured-question.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest" + +import { + STRUCTURED_QUESTION_TOOL, + answerStructuredQuestionWithTui, + createStructuredQuestionTuiComponent, + registerBrunchStructuredQuestion, + type StructuredQuestionTuiResponse, +} from "./structured-question.js" +import type { StructuredQuestionParams } from "../structured-question.js" + +describe("Brunch structured-question TUI adapter", () => { + it("registers a structured-question tool", () => { + const tools: Array<{ name: string }> = [] + + registerBrunchStructuredQuestion({ + registerTool: (tool: { name: string }) => tools.push({ name: tool.name }), + } as never) + + expect(tools).toEqual([{ name: STRUCTURED_QUESTION_TOOL }]) + }) + + it("returns unavailable details when rich UI is missing", async () => { + const result = await answerStructuredQuestionWithTui(textParams(), { + hasUI: false, + ui: {} as never, + }) + + expect(result.details).toMatchObject({ + status: "unavailable", + transport: { surface: "headless" }, + answers: [], + }) + expect(result.content[0]?.text).toContain("unavailable") + }) + + it("uses ctx.ui.custom and the shared result builder for text answers", async () => { + const result = await answerStructuredQuestionWithTui( + textParams(), + fakeContext({ + status: "answered", + answers: [ + { questionId: "q-text", mode: "text", value: "A typed answer" }, + ], + }), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "text", + answers: [{ value: "A typed answer" }], + transport: { surface: "tui-custom" }, + }) + expect(result.content[0]?.text).toBe("Say something: A typed answer") + }) + + it("uses ctx.ui.custom and the shared result builder for single and multi select answers", async () => { + const single = await answerStructuredQuestionWithTui( + singleParams(), + fakeContext({ + status: "answered", + answers: [ + { + questionId: "q-single", + mode: "singleSelect", + selectedOption: { id: "b", label: "Beta" }, + }, + ], + }), + ) + const multi = await answerStructuredQuestionWithTui( + multiParams(), + fakeContext({ + status: "answered", + answers: [ + { + questionId: "q-multi", + mode: "multiSelect", + selectedOptions: [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ], + }, + ], + }), + ) + + expect(single.details.answers[0]).toMatchObject({ + selectedOption: { id: "b", label: "Beta" }, + }) + expect(multi.details.answers[0]).toMatchObject({ + selectedOptions: [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ], + }) + }) + + it("keeps required empty text answers in the input-replacing component", () => { + const decisions: StructuredQuestionTuiResponse[] = [] + const component = createStructuredQuestionTuiComponent( + textParams(), + (response) => decisions.push(response), + ) + + component.handleInput?.("\r") + expect(decisions).toEqual([]) + + for (const char of "Done") component.handleInput?.(char) + component.handleInput?.("\r") + + expect(decisions).toEqual([ + { + status: "answered", + answers: [{ questionId: "q-text", mode: "text", value: "Done" }], + }, + ]) + }) + + it("supports questionnaire answers through the input-replacing component", () => { + const decisions: StructuredQuestionTuiResponse[] = [] + const component = createStructuredQuestionTuiComponent( + questionnaireParams(), + (response) => decisions.push(response), + ) + + for (const char of "Domain") component.handleInput?.(char) + component.handleInput?.("\r") + component.handleInput?.("\r") + + expect(decisions).toEqual([ + { + status: "answered", + answers: [ + { questionId: "q-domain", mode: "text", value: "Domain" }, + { + questionId: "q-risk", + mode: "singleSelect", + selectedOption: { id: "a", label: "Alpha" }, + }, + ], + }, + ]) + }) +}) + +function fakeContext(response: StructuredQuestionTuiResponse) { + return { + hasUI: true, + ui: { + custom: async () => response, + }, + } as never +} + +function textParams(): StructuredQuestionParams { + return { + id: "q-text", + mode: "text", + prompt: "Say something", + } +} + +function singleParams(): StructuredQuestionParams { + return { + id: "q-single", + mode: "singleSelect", + prompt: "Pick one", + options: [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ], + } +} + +function multiParams(): StructuredQuestionParams { + return { + id: "q-multi", + mode: "multiSelect", + prompt: "Pick many", + options: [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ], + } +} + +function questionnaireParams(): StructuredQuestionParams { + return { + id: "q-all", + mode: "questionnaire", + prompt: "Questionnaire", + questions: [ + { id: "q-domain", mode: "text", prompt: "Domain" }, + { + id: "q-risk", + mode: "singleSelect", + prompt: "Risk", + options: [{ id: "a", label: "Alpha" }], + }, + ], + } +} diff --git a/src/pi-extensions/structured-question.ts b/src/pi-extensions/structured-question.ts new file mode 100644 index 00000000..70cee788 --- /dev/null +++ b/src/pi-extensions/structured-question.ts @@ -0,0 +1,222 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent" +import { Key, matchesKey, type Component } from "@earendil-works/pi-tui" + +import { + StructuredQuestionParamsSchema, + buildStructuredQuestionResult, + type StructuredQuestion, + type StructuredQuestionAnswer, + type StructuredQuestionParams, + type StructuredQuestionStatus, + type StructuredQuestionToolResult, +} from "../structured-question.js" + +export const STRUCTURED_QUESTION_TOOL = "brunch_structured_question" + +export interface StructuredQuestionTuiResponse { + status: Exclude<StructuredQuestionStatus, "unavailable"> + answers?: StructuredQuestionAnswer[] +} + +export function registerBrunchStructuredQuestion(pi: ExtensionAPI): void { + if (typeof (pi as Partial<ExtensionAPI>).registerTool !== "function") { + return + } + pi.registerTool({ + name: STRUCTURED_QUESTION_TOOL, + label: "Structured question", + description: + "Ask the user a Brunch structured question and persist a self-contained structured result.", + parameters: StructuredQuestionParamsSchema, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + return answerStructuredQuestionWithTui(params, ctx) + }, + }) +} + +export async function answerStructuredQuestionWithTui( + params: StructuredQuestionParams, + ctx: Pick<ExtensionContext, "hasUI" | "ui">, +): Promise<StructuredQuestionToolResult> { + if (!ctx.hasUI || typeof ctx.ui.custom !== "function") { + return buildStructuredQuestionResult({ + params, + status: "unavailable", + transport: { surface: "headless" }, + message: "Structured question UI is unavailable.", + }) + } + + const response = await ctx.ui.custom<StructuredQuestionTuiResponse>( + (_tui, _theme, _keybindings, done) => + createStructuredQuestionTuiComponent(params, done), + ) + + return buildStructuredQuestionResult({ + params, + status: response.status, + answers: response.status === "answered" ? (response.answers ?? []) : [], + transport: { surface: "tui-custom" }, + }) +} + +export function createStructuredQuestionTuiComponent( + params: StructuredQuestionParams, + done: (response: StructuredQuestionTuiResponse) => void, +): Component { + return new StructuredQuestionTuiComponent(params, done) +} + +class StructuredQuestionTuiComponent implements Component { + readonly #params: StructuredQuestionParams + readonly #questions: StructuredQuestion[] + readonly #done: (response: StructuredQuestionTuiResponse) => void + #questionIndex = 0 + #optionIndex = 0 + #text = "" + #selectedOptionIds = new Set<string>() + #answers: StructuredQuestionAnswer[] = [] + + constructor( + params: StructuredQuestionParams, + done: (response: StructuredQuestionTuiResponse) => void, + ) { + this.#params = params + this.#questions = + params.mode === "questionnaire" ? params.questions : [params] + this.#done = done + } + + handleInput(data: string): void { + const question = this.#currentQuestion() + if (!question) return + + if (matchesKey(data, Key.escape)) { + this.#done({ status: "cancelled" }) + return + } + + if (question.mode === "text") { + this.#handleTextInput(data, question) + return + } + + if (matchesKey(data, Key.up)) { + this.#optionIndex = Math.max(0, this.#optionIndex - 1) + return + } + if (matchesKey(data, Key.down)) { + this.#optionIndex = Math.min( + question.options.length - 1, + this.#optionIndex + 1, + ) + return + } + + if (question.mode === "multiSelect" && data === " ") { + const option = question.options[this.#optionIndex] + if (!option) return + if (this.#selectedOptionIds.has(option.id)) { + this.#selectedOptionIds.delete(option.id) + } else { + this.#selectedOptionIds.add(option.id) + } + return + } + + if (matchesKey(data, Key.enter)) { + if (question.mode === "singleSelect") { + const option = question.options[this.#optionIndex] + if (!option) return + this.#completeAnswer({ + questionId: question.id, + mode: "singleSelect", + selectedOption: { id: option.id, label: option.label }, + }) + return + } + const selectedOptions = question.options + .filter((option) => this.#selectedOptionIds.has(option.id)) + .map((option) => ({ id: option.id, label: option.label })) + if (selectedOptions.length === 0 && question.required !== false) return + this.#completeAnswer({ + questionId: question.id, + mode: "multiSelect", + selectedOptions, + }) + } + } + + render(_width: number): string[] { + const question = this.#currentQuestion() + if (!question) return ["Structured question"] + const prefix = + this.#params.mode === "questionnaire" + ? `Question ${this.#questionIndex + 1}/${this.#questions.length}: ` + : "" + const lines = [`${prefix}${question.prompt}`] + if (question.mode === "text") { + lines.push(`› ${this.#text}`) + lines.push("Enter submit • Esc cancel") + return lines + } + for (const [index, option] of question.options.entries()) { + const cursor = index === this.#optionIndex ? "›" : " " + const checked = + question.mode === "multiSelect" + ? this.#selectedOptionIds.has(option.id) + ? "[x]" + : "[ ]" + : `${index + 1}.` + lines.push(`${cursor} ${checked} ${option.label}`) + if (option.description) lines.push(` ${option.description}`) + } + lines.push( + question.mode === "multiSelect" + ? "Space toggle • Enter submit • Esc cancel" + : "↑↓ navigate • Enter select • Esc cancel", + ) + return lines + } + + invalidate(): void {} + + #handleTextInput(data: string, question: StructuredQuestion): void { + if (matchesKey(data, Key.backspace)) { + this.#text = this.#text.slice(0, -1) + return + } + if (matchesKey(data, Key.enter)) { + const value = this.#text.trim() + if (!value && question.required !== false) return + this.#completeAnswer({ questionId: question.id, mode: "text", value }) + return + } + if (data.length === 1 && data >= " " && data !== "\u007f") { + this.#text += data + } + } + + #completeAnswer(answer: StructuredQuestionAnswer): void { + if (this.#params.mode !== "questionnaire") { + this.#done({ status: "answered", answers: [answer] }) + return + } + this.#answers.push(answer) + if (this.#questionIndex < this.#questions.length - 1) { + this.#questionIndex += 1 + this.#optionIndex = 0 + this.#text = "" + this.#selectedOptionIds.clear() + return + } + this.#done({ status: "answered", answers: this.#answers }) + } + + #currentQuestion(): StructuredQuestion | undefined { + return this.#questions[this.#questionIndex] + } +} From d4361f0dfd09bd89828978413bfad0c7660513a8 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:32:34 +0200 Subject: [PATCH 073/164] Add structured question RPC editor fallback --- memory/CARDS.md | 285 ------------------ memory/PLAN.md | 2 +- memory/SPEC.md | 2 +- src/pi-extensions.ts | 3 + src/pi-extensions/structured-question.test.ts | 116 +++++++ src/pi-extensions/structured-question.ts | 130 +++++++- 6 files changed, 242 insertions(+), 296 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 28c6f829..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,285 +0,0 @@ -# Scope cards — FE-744 judo fixes and next UI-seam slices - -Status key: `next` / `in progress` / `done` / `dropped`. - -## Orientation - -- **Containing seam / frontier:** `pi-ui-extension-patterns` (FE-744), the Brunch-owned Pi UI affordance seam: startup/in-session spec/session selection, RPC/headless selection contract, and the next structured-question/RPC-relay proof. -- **Current state:** The hierarchical spec/session picker landed and verified. Cards 1–5 retired stale picker/RPC APIs, restored richer Brunch chrome formatting, added the TypeBox-backed structured-question result schema/builder, and registered the TUI custom adapter for input-replacing structured answers. The remaining queue is RPC JSON-editor fallback. -- **Main open risk:** The next structured-question work will add another UI/RPC boundary; if the existing picker/RPC seam keeps stale APIs and cast-heavy parsing, the structured-question slice will copy that complexity. -- **Frontier obligations:** Preserve `workspace(cwd) → spec → session` (D11-L/D36-L/I22-L), coordinator-owned activation and binding (D21-L/I8-L), no implicit TUI resume before explicit activation (D22-L/I22-L), RPC/headless non-TUI selection, Pi transcript truth for structured interactions (D37-L/I23-L), and TypeBox as Brunch's runtime schema vocabulary (D41-L/I26-L). - ---- - -## Card 1 — Delete legacy flat picker API, rename activation decision, and restore version styling - -**Status:** done -**Weight:** light scope card - -### Objective - -Retire the obsolete flat workspace-dialog option API, rename the activation decision boundary away from “workspace switch” language, and restore the separate styled dev build tag in the spec/session picker header. - -### Acceptance Criteria - -✓ `rg "buildWorkspaceDialogOptions|WorkspaceDialogOption" src` finds no exported production API and no tests depending on the old flat-list picker. -✓ `src/pi-components/workspace-dialog/model.ts` contains only the hierarchical selection model for picker option generation. -✓ `WorkspaceSwitchDecision` is replaced in production code with a spec/session activation name such as `SpecSessionActivationDecision`; if `WorkspaceSwitchCoordinator` remains, it is either renamed too or justified by a narrower follow-up. -✓ `src/workspace-dialog.test.ts` asserts hierarchical model/component behavior without testing the old flat option list. -✓ The picker header renders `brunch v...` and the dev metadata as separately styled segments/lines so the dev tag uses `success` styling rather than being folded into the accent version string. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: `npm run fix`; targeted `npx vitest --run src/workspace-dialog.test.ts src/brunch-tui.test.ts`; then `npm run verify`. -- Middle: `rg` deletion check for the retired flat-picker symbols. - -### Cross-cutting obligations - -- Delete stale concepts instead of preserving compatibility scaffolding; this is pre-release and `buildWorkspaceDialogOptions` is now the wrong model. -- Keep the renamed spec/session activation decision as the transport-neutral activation boundary; do not rename individual action variants just for copy cleanup unless doing so deletes more ambiguity than it creates. -- Preserve current TUI startup and in-session picker behavior while removing old API surface. - -### Promotion checklist - -- [ ] Does this change a requirement? No. -- [ ] Does this create, retire, or invalidate an assumption? No. -- [ ] Does this make or reverse a non-trivial design decision? No — D36-L already chose the hierarchical model. -- [ ] Does this establish a new seam-level invariant? No. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [ ] Does it cross more than two major seams? No. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. - ---- - -## Card 2 — Schema-backed RPC spec/session activation boundary - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`workspace.activate` validates activation params through an explicit TypeBox-backed spec/session activation decision schema and is only registered with a coordinator that supports workspace inspection and spec/session activation. - -### Boundary Crossings - -```text -→ JSON-RPC request params -→ TypeBox workspace activation decision schema/parser -→ SpecSessionActivationDecision -→ spec/session activation coordinator method -→ serializable activation response DTO -``` - -### Risks and Assumptions - -- RISK: Continuing with `Partial<WorkspaceSwitchCoordinator>` (or its renamed equivalent) keeps an impossible registered-method state: the method exists but can only return an internal error. → MITIGATION: Make `createRpcHandlers` require the coordinator capabilities it registers, or split selection/activation handler registration into a separate explicit factory if a read-only coordinator is truly needed. -- RISK: Hand-rolled casts around `unknown` will be copied into the upcoming structured-question RPC work. → MITIGATION: Establish the TypeBox parse pattern here before adding more RPC boundaries. -- ASSUMPTION: All current call sites can pass a full `WorkspaceSessionCoordinator` plus the renamed spec/session activation coordinator capability. → VALIDATE: Typecheck all call sites (`brunch.ts`, `web-host.ts`, fixture capture, tests) after tightening the type. - -### Acceptance Criteria - -✓ `src/rpc.ts` has no manual `(decision as { ... })` parser for `workspace.activate`; params are parsed/checked via a TypeBox schema or a small schema-backed helper returning the renamed spec/session activation decision type. -✓ `createRpcHandlers` no longer accepts a partial activation coordinator for methods it always registers; required capabilities are explicit at the factory boundary. -✓ `workspace.activate` invalid params still return `-32602`; valid `cancel`, `newSpec`, `newSession`, `continue`, and `openSession` decisions still delegate exactly once to `activateWorkspace`. -✓ Activation responses remain serializable and do not expose `SessionManager`. -✓ The source assertion that RPC does not import TUI picker code remains meaningful and passes. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: RPC contract tests — valid/invalid decision parsing, coordinator delegation, serializable activation snapshots, and typecheck of all handler call sites. -- Middle: Architectural boundary/source assertion — `src/rpc.ts` does not import TUI picker code and does not use non-TypeBox runtime schema libraries. - -### Cross-cutting obligations - -- Honor D41-L/I26-L: TypeBox is the runtime schema vocabulary at Brunch boundaries. -- RPC/headless startup must expose structured selection/activation, not TUI picker code. -- Keep transport connections as client attachments; activation still flows through coordinator, not through connection-local session identity. - ---- - -## Card 3 — Restore rich Brunch chrome projection - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The persistent Brunch TUI chrome renders a richer product-owned header/footer/status/widget projection, including the selected cwd/spec/session and available runtime/context metadata, without fabricating unavailable facts. - -### Boundary Crossings - -```text -→ WorkspaceSessionReadyState / Brunch runtime snapshot producers -→ BrunchChromeState -→ renderBrunchChrome wrapper -→ Pi ui.setHeader / setFooter / setStatus / setWidget / setTitle -→ TUI visual surface and RPC-compatible status/widget events -``` - -### Risks and Assumptions - -- RISK: The earlier rich chrome may have depended on metadata producers that are not currently wired into `BrunchChromeState` (context usage, model/thinking, runtime bundle, git/build data). → MITIGATION: First inventory what data is available from Pi extension contexts and Brunch runtime state; render optional fields only when the producer exists, and record missing producers as follow-up rather than fabricating values. -- RISK: A sophisticated footer can become a pile of formatting branches. → MITIGATION: Split pure formatting helpers by region (`header`, `footer`, `widget/status`) and keep `renderBrunchChrome()` as the only imperative shell. -- RISK: Header/footer are TUI-only in Pi RPC. → MITIGATION: Mirror the important compact facts into `setStatus` / `setWidget` so RPC tests and fixture drivers still have deterministic observability. -- ASSUMPTION: `setFooter` remains the right home for the richer metadata/status bar. → VALIDATE: Unit tests prove `setFooter` receives the rich projection; manual TUI smoke validates visual hierarchy. - -### Acceptance Criteria - -✓ `src/pi-extensions/chrome.ts` exposes a deeper `BrunchChromeState` or projection input that can carry optional runtime metadata such as model/thinking/runtime bundle/build info/context usage without making those fields mandatory. -✓ `formatBrunchChromeFooterLines` renders a richer footer than the current two plain lines, including a compact context-usage progress bar when usage data is present and a clear omission when it is not. -✓ `renderBrunchChrome` still calls `setHeader`, `setFooter`, `setStatus`, `setWidget`, and `setTitle` through one wrapper; downstream code does not scatter raw `ctx.ui.*` calls. -✓ `src/brunch-tui.test.ts` covers the rich footer/header/status/widget projection and RPC-compatible degradation expectations. -✓ Manual TUI smoke or pty capture confirms the Brunch chrome no longer resembles the minimal cwd/spec/session dump shown in the regression screenshot. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: Pure formatter unit tests plus wrapper-call tests in `src/brunch-tui.test.ts`. -- Middle: Manual/pty TUI smoke comparing the live Brunch chrome against the rich footer/header expectations; RPC-compatible tests assert status/widget only for facts Pi RPC actually emits. - -### Cross-cutting obligations - -- `renderBrunchChrome` remains the canonical wrapper; no feature code should call raw Pi chrome primitives directly. -- Do not fabricate unavailable metadata; optional chrome fields are presentation metadata, not product truth. -- Preserve RPC degradation rules: header/footer are TUI-only, status/widget/title are deterministic for headless/RPC observers. - ---- - -## Card 4 — Structured-question result model and transcript payload - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -A Brunch structured-question tool can return a self-contained `toolResult.details` payload for text, single-select, multi-select, questionnaire, and optional-freeform answers. - -### Boundary Crossings - -```text -→ Pi extension tool registration -→ TypeBox structured-question parameter/result schemas -→ TUI/RPC-neutral structured answer model -→ toolResult.content + toolResult.details -→ Pi JSONL transcript projection inputs -``` - -### Risks and Assumptions - -- RISK: Building UI first may leave the durable transcript shape under-specified. → MITIGATION: Start with pure schemas/builders and tests for `details` and model-readable `content`; add UI adapters later. -- RISK: The tool parameter schema and result schema can drift. → MITIGATION: Keep both in one module and derive TS types from TypeBox `Static<typeof Schema>`. -- ASSUMPTION: A single details envelope can cover all current answer modes without a separate custom entry. → VALIDATE: Tests cover `answered`, `skipped`, `cancelled`, and at least one answer shape per mode; if linked custom entries are needed, stop and rescope before building UI. - -### Acceptance Criteria - -✓ A new structured-question module defines TypeBox schemas for question/tool params and terminal result details. -✓ Tests prove the returned `toolResult.details` includes schema/version, status, mode, prompts/questions, options where relevant, answers, and transport metadata without requiring rehydration from assistant tool-call args. -✓ Tests prove `toolResult.content` is generated from the same details payload and remains model-readable. -✓ The module supports text, single-select, multi-select, questionnaire, and optional-freeform shapes at the data/model layer. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: Schema/builder unit tests for each mode and terminal status; typecheck against `Static<typeof Schema>` types. -- Middle: Transcript-shape contract test using a synthetic tool result entry to prove the payload is self-contained enough for later projection. - -### Cross-cutting obligations - -- Pi JSONL remains transcript truth; the details payload is not an ephemeral UI return value. -- Use TypeBox, not Zod/ad-hoc casts, for the new runtime boundary. -- Do not introduce graph mutations, command-layer bypasses, or a parallel chat/turn store. - ---- - -## Card 5 — TUI custom UI adapter for structured questions - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -In TUI mode, the structured-question tool can replace the default input surface with a Brunch custom UI and persist the selected answer through the Card 4 result builder. - -### Boundary Crossings - -```text -→ registered structured-question Pi tool -→ ctx.ui.custom TUI adapter -→ pi-tui component for answer selection/input -→ structured result builder -→ toolResult.details persisted in Pi JSONL -``` - -### Risks and Assumptions - -- RISK: One component for every question shape may become a mini-framework. → MITIGATION: Implement the thinnest shared selector/input component that covers the supported modes; do not generalize beyond Card 4 schemas. -- RISK: UI-local return values may diverge from transcript details. → MITIGATION: The UI returns only inputs needed by the Card 4 builder; content/details are built in one place. -- ASSUMPTION: `ctx.ui.custom()` is available in the Brunch TUI extension path for this tool. → VALIDATE: Unit/fake-context test plus manual TUI smoke; if unavailable in a context, return `unavailable` details rather than blocking. - -### Acceptance Criteria - -✓ TUI fake-context tests prove single-select, multi-select, questionnaire, text/freeform, skip/cancel paths call the structured result builder and return terminal details. -✓ The component is input-replacing for TUI and does not append a separate custom message as the canonical answer store. -✓ Empty/invalid required answers remain in the UI until answered, skipped, or cancelled. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: Component/tool unit tests with fake `ctx.ui.custom`. -- Middle: Manual TUI smoke or pty capture demonstrating an input-replacing question and JSONL inspection showing one terminal tool result with details. - -### Cross-cutting obligations - -- Preserve transcript-native structured elicitation (D37-L/I23-L). -- Keep UI adapters thin over the shared data/result model. -- Do not widen Pi command/keybinding behavior while adding this tool. - ---- - -## Card 6 — RPC JSON-editor fallback for structured questions - -**Status:** next -**Weight:** full scope card - -### Target Behavior - -When rich TUI custom UI is unavailable over raw Pi RPC, the structured-question tool can round-trip the same semantic interaction through schema-tagged JSON in `ctx.ui.editor` and produce the same result details. - -### Boundary Crossings - -```text -→ structured-question Pi tool -→ ctx.ui.editor JSON prefill -→ raw Pi RPC extension_ui_request/response -→ JSON parse/validation -→ structured result builder from Card 4 -→ Brunch product-facing relay/probe expectations -``` - -### Risks and Assumptions - -- RISK: Exposing raw editor JSON as product UX would violate D38-L. → MITIGATION: Treat JSON-editor as compatibility adapter only; Brunch public RPC clients should see product-shaped pending interaction semantics in a later relay slice. -- RISK: Invalid edited JSON can produce ambiguous failure behavior. → MITIGATION: Validate with TypeBox; invalid/malformed responses become terminal `unavailable` or a clear validation error according to the tool contract decided in Card 4. -- ASSUMPTION: Pi RPC's documented editor request/response path is sufficient for this fallback. → VALIDATE: Raw Pi RPC probe based on `examples/rpc-extension-ui.ts` or equivalent local fixture. - -### Acceptance Criteria - -✓ Tests prove editor prefill JSON includes schema tag/version, mode, prompt/questions, options, and response instructions. -✓ Tests prove valid edited JSON produces the same `toolResult.details` shape as the TUI adapter. -✓ Tests prove malformed or schema-invalid edited JSON fails deterministically without producing a misleading `answered` result. -✓ A raw Pi RPC probe/runbook demonstrates `ctx.ui.editor` fallback round-trips through documented extension UI protocol. -✓ `npm run verify` passes. - -### Verification Approach - -- Inner: JSON prefill/parse/validation tests over the Card 4 schema and builder. -- Middle: Raw Pi RPC probe/runbook — proves the fallback works against Pi's actual extension UI messages. - -### Cross-cutting obligations - -- JSON-editor fallback is private adapter mechanics, not a second public Brunch API. -- Preserve one public Brunch RPC surface; raw Pi RPC remains behind adapters/probes. -- Keep structured result details self-contained and transcript-backed. diff --git a/memory/PLAN.md b/memory/PLAN.md index f7683d07..6b2b4b9d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -245,7 +245,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI adapter now prove self-contained `toolResult.details`, model-readable `content`, and input-replacing TUI answer collection for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with JSON-over-`ctx.ui.editor` round-trip in raw Pi RPC, Brunch product-surface relay semantics, and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, and schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with Brunch product-surface relay semantics and elicitation-exchange projection before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index fe30bdee..8500574b 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -246,7 +246,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor RPC fallback and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor fallback tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; Brunch product relay and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 5c9edcfa..7d1084c7 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -72,8 +72,11 @@ export { export { STRUCTURED_QUESTION_TOOL, answerStructuredQuestionWithTui, + buildStructuredQuestionEditorPrefill, createStructuredQuestionTuiComponent, + parseStructuredQuestionEditorResponse, registerBrunchStructuredQuestion, + structuredQuestionResultFromEditor, type StructuredQuestionTuiResponse, } from "./pi-extensions/structured-question.js" export { diff --git a/src/pi-extensions/structured-question.test.ts b/src/pi-extensions/structured-question.test.ts index cb9589ce..7d32501b 100644 --- a/src/pi-extensions/structured-question.test.ts +++ b/src/pi-extensions/structured-question.test.ts @@ -3,12 +3,30 @@ import { describe, expect, it } from "vitest" import { STRUCTURED_QUESTION_TOOL, answerStructuredQuestionWithTui, + buildStructuredQuestionEditorPrefill, createStructuredQuestionTuiComponent, + parseStructuredQuestionEditorResponse, registerBrunchStructuredQuestion, + structuredQuestionResultFromEditor, type StructuredQuestionTuiResponse, } from "./structured-question.js" import type { StructuredQuestionParams } from "../structured-question.js" +interface EditorOptionForTest { + id: string + label: string +} + +interface EditorPayloadForTest { + schema: string + schemaVersion: number + mode: string + prompt: string + instructions: string[] + params: { options: EditorOptionForTest[] } + response: { status: string } +} + describe("Brunch structured-question TUI adapter", () => { it("registers a structured-question tool", () => { const tools: Array<{ name: string }> = [] @@ -96,6 +114,104 @@ describe("Brunch structured-question TUI adapter", () => { }) }) + it("builds schema-tagged JSON editor prefill for raw RPC fallback", () => { + const prefill = JSON.parse( + buildStructuredQuestionEditorPrefill(singleParams()), + ) as EditorPayloadForTest + + expect(prefill).toMatchObject({ + schema: "brunch.structured_question.editor", + schemaVersion: 1, + mode: "singleSelect", + prompt: "Pick one", + response: { status: "skipped" }, + }) + expect(prefill.instructions.join("\n")).toContain("response.answers") + expect(prefill.params.options).toEqual([ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ]) + }) + + it("parses valid edited JSON into the same result-details shape as TUI", async () => { + const edited = JSON.parse( + buildStructuredQuestionEditorPrefill(singleParams()), + ) as Record<string, unknown> + edited.response = { + status: "answered", + answers: [ + { + questionId: "q-single", + mode: "singleSelect", + selectedOption: { id: "b", label: "Beta" }, + }, + ], + } + + const result = structuredQuestionResultFromEditor( + singleParams(), + JSON.stringify(edited), + ) + + expect( + parseStructuredQuestionEditorResponse(JSON.stringify(edited)), + ).toEqual(edited.response) + expect(result.details).toMatchObject({ + status: "answered", + mode: "singleSelect", + answers: [ + { + questionId: "q-single", + selectedOption: { id: "b", label: "Beta" }, + }, + ], + transport: { surface: "rpc-editor" }, + }) + }) + + it("fails malformed or schema-invalid edited JSON deterministically", () => { + expect(parseStructuredQuestionEditorResponse("not json")).toBeNull() + + const invalid = JSON.parse( + buildStructuredQuestionEditorPrefill(textParams()), + ) as Record<string, unknown> + invalid.response = { status: "answered", answers: [{ mode: "text" }] } + + const result = structuredQuestionResultFromEditor( + textParams(), + JSON.stringify(invalid), + ) + + expect(result.details).toMatchObject({ + status: "unavailable", + transport: { surface: "rpc-editor" }, + }) + expect(result.content[0]?.text).toContain("invalid JSON") + }) + + it("uses ctx.ui.editor when custom UI is unavailable", async () => { + const edited = JSON.parse( + buildStructuredQuestionEditorPrefill(textParams()), + ) as Record<string, unknown> + edited.response = { + status: "answered", + answers: [{ questionId: "q-text", mode: "text", value: "RPC answer" }], + } + + const result = await answerStructuredQuestionWithTui(textParams(), { + hasUI: true, + ui: { + editor: async () => JSON.stringify(edited), + } as never, + }) + + expect(result.details).toMatchObject({ + status: "answered", + transport: { surface: "rpc-editor" }, + answers: [{ value: "RPC answer" }], + }) + }) + it("keeps required empty text answers in the input-replacing component", () => { const decisions: StructuredQuestionTuiResponse[] = [] const component = createStructuredQuestionTuiComponent( diff --git a/src/pi-extensions/structured-question.ts b/src/pi-extensions/structured-question.ts index 70cee788..4648dc03 100644 --- a/src/pi-extensions/structured-question.ts +++ b/src/pi-extensions/structured-question.ts @@ -3,8 +3,11 @@ import type { ExtensionContext, } from "@earendil-works/pi-coding-agent" import { Key, matchesKey, type Component } from "@earendil-works/pi-tui" +import { Type } from "typebox" +import { Value } from "typebox/value" import { + StructuredQuestionAnswerSchema, StructuredQuestionParamsSchema, buildStructuredQuestionResult, type StructuredQuestion, @@ -21,6 +24,38 @@ export interface StructuredQuestionTuiResponse { answers?: StructuredQuestionAnswer[] } +const StructuredQuestionModeSchema = Type.Union([ + Type.Literal("text"), + Type.Literal("singleSelect"), + Type.Literal("multiSelect"), + Type.Literal("questionnaire"), +]) + +const StructuredQuestionEditorResponseSchema = Type.Object( + { + status: Type.Union([ + Type.Literal("answered"), + Type.Literal("skipped"), + Type.Literal("cancelled"), + ]), + answers: Type.Optional(Type.Array(StructuredQuestionAnswerSchema)), + }, + { additionalProperties: false }, +) + +const StructuredQuestionEditorPayloadSchema = Type.Object( + { + schema: Type.Literal("brunch.structured_question.editor"), + schemaVersion: Type.Literal(1), + mode: StructuredQuestionModeSchema, + prompt: Type.String(), + instructions: Type.Array(Type.String()), + params: StructuredQuestionParamsSchema, + response: StructuredQuestionEditorResponseSchema, + }, + { additionalProperties: false }, +) + export function registerBrunchStructuredQuestion(pi: ExtensionAPI): void { if (typeof (pi as Partial<ExtensionAPI>).registerTool !== "function") { return @@ -41,25 +76,102 @@ export async function answerStructuredQuestionWithTui( params: StructuredQuestionParams, ctx: Pick<ExtensionContext, "hasUI" | "ui">, ): Promise<StructuredQuestionToolResult> { - if (!ctx.hasUI || typeof ctx.ui.custom !== "function") { + if (!ctx.hasUI) { + return unavailableStructuredQuestionResult(params) + } + + if (typeof ctx.ui.custom === "function") { + const response = await ctx.ui.custom<StructuredQuestionTuiResponse>( + (_tui, _theme, _keybindings, done) => + createStructuredQuestionTuiComponent(params, done), + ) + return buildStructuredQuestionResult({ params, - status: "unavailable", - transport: { surface: "headless" }, - message: "Structured question UI is unavailable.", + status: response.status, + answers: response.status === "answered" ? (response.answers ?? []) : [], + transport: { surface: "tui-custom" }, }) } - const response = await ctx.ui.custom<StructuredQuestionTuiResponse>( - (_tui, _theme, _keybindings, done) => - createStructuredQuestionTuiComponent(params, done), - ) + if (typeof ctx.ui.editor === "function") { + const edited = await ctx.ui.editor( + "Answer structured question as JSON", + buildStructuredQuestionEditorPrefill(params), + ) + return structuredQuestionResultFromEditor(params, edited) + } + + return unavailableStructuredQuestionResult(params) +} + +export function buildStructuredQuestionEditorPrefill( + params: StructuredQuestionParams, +): string { + return `${JSON.stringify( + Value.Parse(StructuredQuestionEditorPayloadSchema, { + schema: "brunch.structured_question.editor", + schemaVersion: 1, + mode: params.mode, + prompt: params.prompt, + instructions: [ + "Edit response.status to answered, skipped, or cancelled.", + "For answered responses, fill response.answers using the question ids and answer shapes shown by params.", + "Do not change schema, schemaVersion, params, prompt, or mode.", + ], + params, + response: { status: "skipped" }, + }), + null, + 2, + )}\n` +} + +export function parseStructuredQuestionEditorResponse( + edited: string | undefined, +): StructuredQuestionTuiResponse | null { + if (edited === undefined) return { status: "cancelled" } + try { + const payload = Value.Parse( + StructuredQuestionEditorPayloadSchema, + JSON.parse(edited), + ) + return payload.response + } catch { + return null + } +} +export function structuredQuestionResultFromEditor( + params: StructuredQuestionParams, + edited: string | undefined, +): StructuredQuestionToolResult { + const response = parseStructuredQuestionEditorResponse(edited) + if (!response) { + return buildStructuredQuestionResult({ + params, + status: "unavailable", + transport: { surface: "rpc-editor" }, + message: + "Structured question editor response was invalid JSON or failed schema validation.", + }) + } return buildStructuredQuestionResult({ params, status: response.status, answers: response.status === "answered" ? (response.answers ?? []) : [], - transport: { surface: "tui-custom" }, + transport: { surface: "rpc-editor" }, + }) +} + +function unavailableStructuredQuestionResult( + params: StructuredQuestionParams, +): StructuredQuestionToolResult { + return buildStructuredQuestionResult({ + params, + status: "unavailable", + transport: { surface: "headless" }, + message: "Structured question UI is unavailable.", }) } From ac20c6b414637b85c3bae9afea345527229fa0dd Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:42:44 +0200 Subject: [PATCH 074/164] spec and plan updates re compaction control --- memory/PLAN.md | 10 ++-- memory/SPEC.md | 10 +++- .../auto-compaction-anchors.json | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/pi-extensions/auto-compaction-anchors.json diff --git a/memory/PLAN.md b/memory/PLAN.md index 6b2b4b9d..9e58f95c 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -198,11 +198,11 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** unassigned - **Kind:** structural - **Status:** not-started -- **Objective:** Compaction preserves graph and coherence anchors; interest sets can widen beyond direct reads when needed; conflict signaling remains intelligible at long horizons. -- **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; active spec and any in-flight side-task, observer-job, reviewer-job, or lens bookkeeping remain intelligible after compaction; the latest `brunch.establishment_offer` entry remains reconstructable across compaction so ambient-affordance chrome continues to render the current offer. -- **Verification:** Inner gate plus continuity-metadata unit tests. Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/observer/reviewer bookkeeping, and latest-establishment-offer reconstruction. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, and establishment-offer state when present. -- **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/observer/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. -- **Traceability:** R15 / D6-L, D15-L / I12-L +- **Objective:** Compaction preserves graph, coherence, and continuity anchors per D43-L; interest sets can widen beyond direct reads when needed; conflict signaling remains intelligible at long horizons. +- **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; the auto-compaction extension renders the configured preserved-anchor set byte-stable so active spec, in-flight side-task / observer-job / reviewer-job bookkeeping, latest `brunch.agent_runtime_state`, latest `brunch.establishment_offer`, latest `brunch.lens_switch`, unresolved staleness hints, and active review-set leaves remain intelligible after compaction; ambient-affordance chrome continues to render the current offer; auto-compaction failure falls through to Pi default compaction rather than dropping anchors silently. +- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/observer/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. +- **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/observer/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. The auto-compaction extension is the canonical owner of `session_before_compact`; product code paths that touch compaction must compose with it rather than register a parallel hook. +- **Traceability:** R15 / D6-L, D15-L, D43-L / I12-L, I28-L - **Design docs:** [prd.md §Continuity, Divergence, and Coherence](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) ### brief-library-curation diff --git a/memory/SPEC.md b/memory/SPEC.md index 8500574b..08f5c6ff 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -67,7 +67,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 13. Brunch must detect relevant cross-session graph changes between turns and surface them via a `worldUpdate` custom-message role. 14. Brunch must surface coherence as shared product state to both user and agent. -15. Brunch must preserve graph and coherence anchors across compaction. +15. Brunch must preserve graph, coherence, and continuity anchors across compaction; the continuity-anchor list is the externalized auto-compaction anchor contract. #### Elicitation product shape @@ -197,6 +197,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. - **D29-L — Reviewer is an `observer`-shaped agent role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. +- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a TypeBox schema per D41-L when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. #### Schema & validation @@ -250,7 +251,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | -| I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their table definitions. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | +| I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | +| I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | ## Future Direction Register @@ -359,6 +361,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | A scoped sub-agent or auxiliary LLM invocation whose result returns through the shared command layer or a bounded metadata seam such as Pi `session_info` when it is explicitly presentation metadata. | +| **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/pi-extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active runtime bundle; falls through to Pi default compaction on auth/empty-output/unexpected errors. | +| **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | +| **Anchor contract** | The data inside the preserved-anchor JSON config — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | | **World update** | `worldUpdate` custom message synthesised in `prepareNextTurn` summarising relevant graph changes since the session's `lastSeenLsn`. | | **Mention ledger** | Per-session `(entity_id, snapshotted_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | | **Authority** | Source of a node's claim: `stakeholder | technical | external | derived`. | @@ -488,6 +493,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | +| I28-L | Inner — TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | ### Design Notes diff --git a/src/pi-extensions/auto-compaction-anchors.json b/src/pi-extensions/auto-compaction-anchors.json new file mode 100644 index 00000000..82017563 --- /dev/null +++ b/src/pi-extensions/auto-compaction-anchors.json @@ -0,0 +1,46 @@ +{ + "$comment": "Canonical anchor preservation contract for the auto-compaction extension (D43-L, I28-L). Reviewable and editable without SPEC churn. Validated through a TypeBox schema when src/pi-extensions/auto-compaction.ts lands; until then, treat additions as SPEC-aware data changes. Selection rules: 'first' = first matching entry in branch order (singletons like session_binding); 'latest' = most recent matching entry (singleton-by-recency); 'active-leaves' = matching entries that are leaves of their supersedes chain and not yet terminal; 'all-unresolved' = matching entries whose effect has not yet been consumed by the agent or settled by user action.", + "version": 1, + "anchors": [ + { + "kind": "brunch.session_binding", + "select": "first", + "rationale": "I8-L — exactly one binding per session; must survive compaction byte-stable to keep the JSONL self-describing." + }, + { + "kind": "brunch.agent_runtime_state", + "select": "latest", + "rationale": "D40-L — turn preparation reconstructs operational mode / role preset / strategy / lens from the latest valid runtime-state snapshot; losing it after compaction breaks I25-L." + }, + { + "kind": "brunch.establishment_offer", + "select": "latest", + "rationale": "PLAN compaction-and-conflict-widening — ambient-affordance chrome reads the latest establishment offer to render the current orientation surface." + }, + { + "kind": "brunch.lens_switch", + "select": "latest", + "rationale": "D25-L — observer/reviewer routing and prompt composition depend on the active lens; the latest switch is the authoritative lens marker post-compaction." + }, + { + "kind": "brunch.review_set_proposal", + "select": "active-leaves", + "rationale": "D27-L, D28-L — proposals not yet accepted/rejected and not superseded must remain reviewable after compaction; superseded ancestors do not." + }, + { + "kind": "brunch.side_task_result", + "select": "all-unresolved", + "rationale": "D15-L, I12-L — succeeded side-task results awaiting next-turn-boundary delivery must remain deliverable after compaction; mid-turn delivery remains forbidden." + }, + { + "kind": "brunch.mention_staleness_hint", + "select": "all-unresolved", + "rationale": "D14-L, I9-L — staleness hints the agent has not yet acted upon must survive so the re-read affordance is not silently dropped." + }, + { + "kind": "worldUpdate", + "select": "latest", + "rationale": "R13, I4-L — the latest cross-session graph delta must remain available so the agent does not re-derive world state from an outdated snapshot." + } + ] +} From ddd300f2476cdc5fe7ded9ca240bec1d2f4ccf7a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:50:26 +0200 Subject: [PATCH 075/164] Characterize structured question terminal details --- memory/REFACTOR.md | 58 +++++++++++++++++++++++++++++++++ src/structured-question.test.ts | 50 ++++++++++++++++++++++++++++ src/structured-question.ts | 13 ++++++++ 3 files changed, 121 insertions(+) create mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md new file mode 100644 index 00000000..78f72c8d --- /dev/null +++ b/memory/REFACTOR.md @@ -0,0 +1,58 @@ +## Problem Statement + +The structured-question implementation has good inner-loop coverage for schemas, result builders, fake TUI custom UI, and fake editor fallback, but it does not yet prove the two architectural claims that make the seam safe to depend on. + +First, RPC sufficiency is not witnessed against a live Pi RPC process: current tests exercise Brunch helpers with fake `ctx.ui.editor`, not the documented `extension_ui_request` / `extension_ui_response` round trip. That leaves uncertainty about whether a real CLI JSON-RPC client can drive the fallback end to end. + +Second, elicitation-exchange projection still treats all Pi tool results as prompt-side transcript entries. Brunch now has typed structured-question result details, but projection does not yet classify terminal structured-question tool results as response-side entries. Until that lands, `toolResult.details` is self-contained but not yet part of the observer extraction unit. + +The current UX refinements for structured questions are intentionally not part of this refactor. They should become a separate plan/scope item after the proof seam is trustworthy. + +## Solution + +Make the existing seam easier to trust before changing the structured-question UX. Add a live RPC proof harness that runs a minimal Brunch/Pi structured-question path through actual RPC extension UI messages, then use that evidence to tighten the projection behavior so typed terminal structured-question tool results become response-side entries while ordinary tool results remain prompt-side. + +The target state is: + +- a repeatable proof command or test fixture can witness `editor` request emission and response handling over Pi RPC; +- the proof verifies the final result payload, not just that an editor request appeared; +- projection has a small typed predicate for structured-question terminal results; +- tests distinguish ordinary tool results from structured-question answers; +- SPEC/PLAN evidence language can honestly say RPC fallback is live-proven for the adapter layer and projection is covered for terminal structured-question results. + +## Commits + +1. [x] Add characterization coverage for the existing structured-question transcript boundary: ordinary tool results stay prompt-side, and typed structured-question result details are recognized by a pure predicate without changing exchange projection yet. +2. [ ] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. +3. Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. +4. Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. +5. Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. +6. Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. +7. Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. + +## Decisions + +- The proof targets the Pi RPC extension UI protocol directly, not a mocked Brunch helper and not a future public Brunch relay. +- The proof result must include the same self-contained structured-question details shape used by the TUI path. +- Projection classifies by typed structured-question result details, not by tool name alone; this prevents accidental response-side classification of unrelated tool results. +- The refactor preserves the current structured-question payload schema unless the live proof reveals a protocol mismatch. +- The public Brunch product relay for pending elicitation remains a follow-up seam, not part of this proof refactor. +- Structured-question UX refinements are intentionally deferred to a separate planning item so proof work does not become interaction-design churn. + +## Testing Decisions + +- Good tests here prove behavior at the same boundaries future callers rely on: RPC protocol messages, Pi JSONL/tool-result payloads, and elicitation-exchange projection output. +- Pure unit tests remain useful for schema and projection classification, but they are insufficient for RPC sufficiency. +- The live RPC proof should be small and deterministic: one structured question, one editor fallback answer, one terminal result assertion. +- The live proof should not depend on model behavior if avoidable; prefer a command/tool-driven harness or deterministic probe over asking an LLM to decide when to call the tool. +- Projection tests must include contrastive ordinary tool results so the new response-side rule cannot accidentally reclassify every `toolResult`. +- Existing targeted suites for structured-question helpers, extension adapter helpers, elicitation-exchange projection, and JSON-RPC handlers are the prior art to preserve. + +## Out of Scope + +- Redesigning the structured-question UX, including richer freeform-plus-choice flows, review-set action surfaces, or establishment-offer orientation views. +- Building the public Brunch pending-elicitation relay for web/CLI clients; this refactor proves the private Pi RPC adapter layer and leaves product-surface relay semantics as the next slice. +- Adding graph writes, observer jobs, review-set acceptance, or command-layer mutation behavior. +- Changing the structured-question schema for aesthetic reasons unless the live RPC proof exposes a real protocol mismatch. +- Making the live RPC proof a mandatory CI gate if host-sensitive process/PTY behavior makes it flaky; it may remain a runbook/probe with deterministic assertions. +- Touching unrelated dirty planning files or the auto-compaction anchor artifact except for deliberate reconciliation after proof results are known. diff --git a/src/structured-question.test.ts b/src/structured-question.test.ts index dce48dae..3c50157f 100644 --- a/src/structured-question.test.ts +++ b/src/structured-question.test.ts @@ -4,6 +4,7 @@ import { Value } from "typebox/value" import { StructuredQuestionResultDetailsSchema, buildStructuredQuestionResult, + isTerminalStructuredQuestionResultDetails, parseStructuredQuestionParams, structuredQuestionSummary, type StructuredQuestionAnswer, @@ -205,4 +206,53 @@ describe("structured-question result model", () => { expect(structuredQuestionSummary(result.details)).toContain(status) } }) + + it("recognizes terminal structured-question result details without matching unrelated tool output", () => { + const params = parseStructuredQuestionParams({ + id: "q-terminal", + mode: "text", + prompt: "Can you answer?", + }) + const answered = buildStructuredQuestionResult({ + params, + status: "answered", + answers: [{ questionId: "q-terminal", mode: "text", value: "Yes" }], + transport, + }) + const skipped = buildStructuredQuestionResult({ + params, + status: "skipped", + transport, + }) + const cancelled = buildStructuredQuestionResult({ + params, + status: "cancelled", + transport, + }) + const unavailable = buildStructuredQuestionResult({ + params, + status: "unavailable", + transport: { surface: "headless" }, + message: "No UI surface is available.", + }) + + expect(isTerminalStructuredQuestionResultDetails(answered.details)).toBe( + true, + ) + expect(isTerminalStructuredQuestionResultDetails(skipped.details)).toBe( + true, + ) + expect(isTerminalStructuredQuestionResultDetails(cancelled.details)).toBe( + true, + ) + expect(isTerminalStructuredQuestionResultDetails(unavailable.details)).toBe( + false, + ) + expect( + isTerminalStructuredQuestionResultDetails({ + status: "answered", + content: [{ type: "text", text: "ordinary tool output" }], + }), + ).toBe(false) + }) }) diff --git a/src/structured-question.ts b/src/structured-question.ts index 2fdecb98..7a05cfb3 100644 --- a/src/structured-question.ts +++ b/src/structured-question.ts @@ -172,6 +172,19 @@ export function parseStructuredQuestionParams( return Value.Parse(StructuredQuestionParamsSchema, value) } +export function isTerminalStructuredQuestionResultDetails( + value: unknown, +): value is StructuredQuestionResultDetails { + if (!Value.Check(StructuredQuestionResultDetailsSchema, value)) { + return false + } + return ( + value.status === "answered" || + value.status === "skipped" || + value.status === "cancelled" + ) +} + export function buildStructuredQuestionResult(input: { params: StructuredQuestionParams status: StructuredQuestionStatus From 56e3dc3991f56807307697614690e2d21f6cb781 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:54:11 +0200 Subject: [PATCH 076/164] Prove structured question RPC editor fallback --- memory/REFACTOR.md | 4 +- src/structured-question-rpc-proof.test.ts | 48 ++++ src/structured-question-rpc-proof.ts | 325 ++++++++++++++++++++++ 3 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/structured-question-rpc-proof.test.ts create mode 100644 src/structured-question-rpc-proof.ts diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 78f72c8d..6e4a53d3 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -23,8 +23,8 @@ The target state is: ## Commits 1. [x] Add characterization coverage for the existing structured-question transcript boundary: ordinary tool results stay prompt-side, and typed structured-question result details are recognized by a pure predicate without changing exchange projection yet. -2. [ ] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. -3. Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. +2. [x] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. +3. [ ] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. 4. Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. 5. Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. 6. Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. diff --git a/src/structured-question-rpc-proof.test.ts b/src/structured-question-rpc-proof.test.ts new file mode 100644 index 00000000..8e3e9be7 --- /dev/null +++ b/src/structured-question-rpc-proof.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" + +import { runStructuredQuestionRpcProof } from "./structured-question-rpc-proof.js" + +describe("structured-question RPC proof", () => { + it("round-trips an editor fallback through Pi RPC extension UI", async () => { + const proof = await runStructuredQuestionRpcProof() + + expect(proof.editorRequest).toMatchObject({ + type: "extension_ui_request", + method: "editor", + title: "Answer structured question as JSON", + }) + expect(JSON.parse(proof.editorRequest.prefill ?? "{}")).toMatchObject({ + schema: "brunch.structured_question.editor", + schemaVersion: 1, + response: { status: "skipped" }, + params: { + id: "q-rpc-proof", + mode: "text", + prompt: "What did the RPC proof answer?", + }, + }) + expect(proof.details).toMatchObject({ + schema: "brunch.structured_question.result", + schemaVersion: 1, + status: "answered", + mode: "text", + prompt: "What did the RPC proof answer?", + questions: [ + { + id: "q-rpc-proof", + mode: "text", + prompt: "What did the RPC proof answer?", + }, + ], + answers: [ + { + questionId: "q-rpc-proof", + mode: "text", + value: "RPC editor fallback works", + }, + ], + transport: { surface: "rpc-editor" }, + }) + expect(proof.sessionFile).toContain(".brunch/sessions") + }, 20_000) +}) diff --git a/src/structured-question-rpc-proof.ts b/src/structured-question-rpc-proof.ts new file mode 100644 index 00000000..edf403d2 --- /dev/null +++ b/src/structured-question-rpc-proof.ts @@ -0,0 +1,325 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" + +import { Value } from "typebox/value" + +import { + StructuredQuestionResultDetailsSchema, + type StructuredQuestionResultDetails, +} from "./structured-question.js" + +export interface StructuredQuestionRpcProofResult { + editorRequest: { + type: "extension_ui_request" + id: string + method: "editor" + title?: string + prefill?: string + } + details: StructuredQuestionResultDetails + sessionFile: string + stdout: unknown[] +} + +interface StructuredQuestionRpcProofOptions { + cwd?: string + timeoutMs?: number +} + +const PROOF_CUSTOM_TYPE = "brunch.structured_question_rpc_proof_result" + +export async function runStructuredQuestionRpcProof( + options: StructuredQuestionRpcProofOptions = {}, +): Promise<StructuredQuestionRpcProofResult> { + const cwd = + options.cwd ?? (await mkdtemp(join(tmpdir(), "brunch-rpc-proof-"))) + const timeoutMs = options.timeoutMs ?? 10_000 + const extensionPath = await writeProofExtension(cwd) + const sessionDir = join(cwd, ".brunch", "sessions") + await mkdir(sessionDir, { recursive: true }) + + const child = spawn( + process.execPath, + [ + piCliPath(), + "--mode", + "rpc", + "--no-extensions", + "--extension", + extensionPath, + "--session-dir", + sessionDir, + ], + { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }, + ) + + const client = new RpcProbeClient(child, timeoutMs) + try { + const promptAccepted = client.waitFor( + (event): event is RpcResponse => + isRpcResponse(event) && event.command === "prompt", + ) + child.stdin.write( + `${JSON.stringify({ id: "proof", type: "prompt", message: "/brunch-structured-question-rpc-proof" })}\n`, + ) + + const editorRequest = await client.waitFor( + (event): event is StructuredQuestionRpcProofResult["editorRequest"] => + isEditorRequest(event), + ) + child.stdin.write( + `${JSON.stringify({ + type: "extension_ui_response", + id: editorRequest.id, + value: answeredEditorPayload(editorRequest.prefill), + })}\n`, + ) + + const promptResponse = await promptAccepted + if (!promptResponse.success) { + throw new Error( + `Proof command failed: ${promptResponse.error ?? "unknown error"}`, + ) + } + + const stateResponse = client.waitFor( + (event): event is RpcResponse<{ sessionFile?: string }> => + isRpcResponse(event) && event.id === "state", + ) + child.stdin.write(`${JSON.stringify({ id: "state", type: "get_state" })}\n`) + const state = await stateResponse + const sessionFile = state.data?.sessionFile + if (!state.success || typeof sessionFile !== "string") { + throw new Error("RPC proof did not expose a persisted session file") + } + + const details = await readProofDetails(sessionFile) + return { + editorRequest, + details, + sessionFile, + stdout: client.events, + } + } finally { + client.dispose() + } +} + +async function writeProofExtension(cwd: string): Promise<string> { + const extensionPath = join(cwd, "structured-question-rpc-proof-extension.ts") + const adapterPath = resolve("src/pi-extensions/structured-question.ts") + const content = ` + import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + import { + buildStructuredQuestionEditorPrefill, + structuredQuestionResultFromEditor, + } from ${JSON.stringify(adapterPath)} + + const params = { + id: "q-rpc-proof", + mode: "text", + prompt: "What did the RPC proof answer?", + required: true, + } as const + + export default function(pi: ExtensionAPI): void { + pi.registerCommand("brunch-structured-question-rpc-proof", { + description: "Exercise Brunch structured-question RPC editor fallback.", + handler: async (_args, ctx) => { + const edited = await ctx.ui.editor( + "Answer structured question as JSON", + buildStructuredQuestionEditorPrefill(params), + ) + const result = structuredQuestionResultFromEditor(params, edited) + ctx.sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "Structured-question RPC proof completed." }], + api: "openai-completions", + provider: "openai", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }) + pi.appendEntry(${JSON.stringify(PROOF_CUSTOM_TYPE)}, result.details) + ctx.ui.notify(result.content[0]?.text ?? "Structured question completed.", "info") + }, + }) + } + ` + await writeFile(extensionPath, content, "utf8") + return extensionPath +} + +function answeredEditorPayload(prefill: string | undefined): string { + if (!prefill) throw new Error("RPC editor request did not include a prefill") + const payload = JSON.parse(prefill) as { + response?: unknown + } + payload.response = { + status: "answered", + answers: [ + { + questionId: "q-rpc-proof", + mode: "text", + value: "RPC editor fallback works", + }, + ], + } + return `${JSON.stringify(payload, null, 2)}\n` +} + +async function readProofDetails( + sessionFile: string, +): Promise<StructuredQuestionResultDetails> { + const entries = (await readFile(sessionFile, "utf8")) + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as unknown) + const proofEntry = entries.find( + (entry): entry is ProofResultEntry => + typeof entry === "object" && + entry !== null && + (entry as { customType?: unknown }).customType === PROOF_CUSTOM_TYPE && + "data" in entry, + ) + if (!proofEntry) { + throw new Error("RPC proof result entry was not written to the session") + } + return Value.Parse(StructuredQuestionResultDetailsSchema, proofEntry.data) +} + +function piCliPath(): string { + return fileURLToPath( + new URL( + "../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + import.meta.url, + ), + ) +} + +interface RpcResponse<T = unknown> { + type: "response" + id?: string + command: string + success: boolean + data?: T + error?: string +} + +interface ProofResultEntry { + customType: string + data: unknown +} + +function isRpcResponse(value: unknown): value is RpcResponse { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "response" && + typeof (value as { command?: unknown }).command === "string" && + typeof (value as { success?: unknown }).success === "boolean" + ) +} + +function isEditorRequest( + value: unknown, +): value is StructuredQuestionRpcProofResult["editorRequest"] { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "extension_ui_request" && + typeof (value as { id?: unknown }).id === "string" && + (value as { method?: unknown }).method === "editor" + ) +} + +class RpcProbeClient { + readonly events: unknown[] = [] + readonly #child: ChildProcessWithoutNullStreams + readonly #timeoutMs: number + #stdout = "" + #stderr = "" + #waiters: Array<{ + predicate: (event: unknown) => boolean + resolve: (event: unknown) => void + }> = [] + + constructor(child: ChildProcessWithoutNullStreams, timeoutMs: number) { + this.#child = child + this.#timeoutMs = timeoutMs + child.stdout.on("data", (chunk) => this.#ingestStdout(String(chunk))) + child.stderr.on("data", (chunk) => { + this.#stderr += String(chunk) + }) + } + + waitFor<T,>(predicate: (event: unknown) => event is T): Promise<T> { + const existing = this.events.find(predicate) + if (existing) return Promise.resolve(existing) + + return new Promise<T>((resolve, reject) => { + const timeout = setTimeout( + () => { + reject( + new Error( + `Timed out waiting for RPC proof event. Stderr:\n${this.#stderr}`, + ), + ) + }, + this.#timeoutMs, + ) + this.#waiters.push({ + predicate, + resolve: (event) => { + clearTimeout(timeout) + resolve(event as T) + }, + }) + }) + } + + dispose(): void { + this.#child.kill("SIGTERM") + } + + #ingestStdout(chunk: string): void { + this.#stdout += chunk + while (true) { + const newline = this.#stdout.indexOf("\n") + if (newline === -1) return + const line = this.#stdout.slice(0, newline).replace(/\r$/, "") + this.#stdout = this.#stdout.slice(newline + 1) + if (line.trim().length === 0) continue + let event: unknown + try { + event = JSON.parse(line) + } catch { + continue + } + this.events.push(event) + const waiters = this.#waiters.slice() + for (const waiter of waiters) { + if (!waiter.predicate(event)) continue + this.#waiters = this.#waiters.filter( + (candidate) => candidate !== waiter, + ) + waiter.resolve(event) + } + } + } +} From c025fd463ce9877aa83d818ac049d8dc00bcfa9a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:54:52 +0200 Subject: [PATCH 077/164] Expose structured question RPC proof test --- memory/REFACTOR.md | 4 ++-- package.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 6e4a53d3..265a47d3 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -24,8 +24,8 @@ The target state is: 1. [x] Add characterization coverage for the existing structured-question transcript boundary: ordinary tool results stay prompt-side, and typed structured-question result details are recognized by a pure predicate without changing exchange projection yet. 2. [x] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. -3. [ ] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. -4. Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. +3. [x] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. +4. [ ] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. 5. Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. 6. Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. 7. Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. diff --git a/package.json b/package.json index 169e57b9..82f440e4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build:pi-assets": "mkdir -p dist/pi-components/workspace-dialog && cp -R src/pi-components/workspace-dialog/assets dist/pi-components/workspace-dialog/", "build:web": "vite build", "test": "vitest --run", + "test:structured-question-rpc-proof": "vitest --run src/structured-question-rpc-proof.test.ts", "test:watch": "vitest", "lint": "oxlint src .pi/extensions", "lint:fix": "oxlint --fix src .pi/extensions", From 732658ff98b81ad4c4ce643b0df893c0106c7bc8 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:56:11 +0200 Subject: [PATCH 078/164] Project terminal structured question responses --- memory/REFACTOR.md | 4 +- src/elicitation-exchange.test.ts | 73 ++++++++++++++++++++++++++++++++ src/elicitation-exchange.ts | 17 ++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 265a47d3..7d25c567 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -25,8 +25,8 @@ The target state is: 1. [x] Add characterization coverage for the existing structured-question transcript boundary: ordinary tool results stay prompt-side, and typed structured-question result details are recognized by a pure predicate without changing exchange projection yet. 2. [x] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. 3. [x] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. -4. [ ] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. -5. Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. +4. [x] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. +5. [ ] Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. 6. Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. 7. Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index 948e6eac..8b29085a 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest" import { SessionManager } from "@earendil-works/pi-coding-agent" import { createSessionBindingData } from "./session-binding.js" +import { buildStructuredQuestionResult } from "./structured-question.js" import { assistantMessage, userMessage } from "./test-helpers.js" import { loadJsonlTranscriptEntries, @@ -38,6 +39,50 @@ const toolResult = { isError: false, }, } +const structuredQuestionToolResult = { + id: "sq1", + type: "message", + message: { + role: "toolResult", + toolCallId: "call-sq-1", + toolName: "brunch_structured_question", + content: [{ type: "text", text: "Domain?: Developer tooling" }], + details: buildStructuredQuestionResult({ + params: { + id: "domain", + mode: "text", + prompt: "Domain?", + }, + status: "answered", + answers: [ + { questionId: "domain", mode: "text", value: "Developer tooling" }, + ], + transport: { surface: "rpc-editor" }, + }).details, + isError: false, + }, +} +const unavailableStructuredQuestionToolResult = { + id: "sq-unavailable", + type: "message", + message: { + role: "toolResult", + toolCallId: "call-sq-2", + toolName: "brunch_structured_question", + content: [{ type: "text", text: "Structured question unavailable." }], + details: buildStructuredQuestionResult({ + params: { + id: "domain", + mode: "text", + prompt: "Domain?", + }, + status: "unavailable", + transport: { surface: "headless" }, + message: "Structured question UI is unavailable.", + }).details, + isError: false, + }, +} const user = { id: "u1", type: "message", @@ -168,6 +213,34 @@ describe("elicitation exchange projection", () => { }) }) + it("classifies terminal structured-question tool results as response-side entries", () => { + const projection = projectElicitationExchanges([ + assistant, + structuredQuestionToolResult, + ]) + + expect(projection.exchanges[0]?.promptEntryIds).toEqual(["a1"]) + expect(projection.exchanges[0]?.responseEntryIds).toEqual(["sq1"]) + expect(projection.exchanges[0]?.responseRange).toEqual({ + start: "sq1", + end: "sq1", + }) + expect(projection.openPrompt).toBeNull() + }) + + it("keeps non-terminal structured-question tool results on the prompt side", () => { + const projection = projectElicitationExchanges([ + assistant, + unavailableStructuredQuestionToolResult, + ]) + + expect(projection.exchanges).toEqual([]) + expect(projection.openPrompt?.promptEntryIds).toEqual([ + "a1", + "sq-unavailable", + ]) + }) + it("returns an explicit empty/open shape for incomplete transcripts", () => { expect(projectElicitationExchanges([])).toEqual({ status: "empty", diff --git a/src/elicitation-exchange.ts b/src/elicitation-exchange.ts index 297927c9..09340a64 100644 --- a/src/elicitation-exchange.ts +++ b/src/elicitation-exchange.ts @@ -12,6 +12,7 @@ import { readBrunchSessionEnvelope, type BrunchSessionEnvelope, } from "./brunch-session-envelope.js" +import { isTerminalStructuredQuestionResultDetails } from "./structured-question.js" const PROMPT_SIDE_CUSTOM_TYPES = new Set([ "brunch.elicitation_prompt", @@ -226,6 +227,9 @@ function isPromptSideEntry(entry: SessionEntry): boolean { } const role = roleOf(entry) + if (role === "toolResult" && isTerminalStructuredQuestionToolResult(entry)) { + return false + } return role === "assistant" || role === "toolResult" } @@ -233,12 +237,25 @@ function isResponseSideEntry(entry: SessionEntry): boolean { if (roleOf(entry) === "user") { return true } + if (isTerminalStructuredQuestionToolResult(entry)) { + return true + } return ( isCustomTranscriptEntry(entry) && STRUCTURED_RESPONSE_TYPES.has(entry.customType) ) } +function isTerminalStructuredQuestionToolResult(entry: SessionEntry): boolean { + return ( + isMessageEntry(entry) && + entry.message.role === "toolResult" && + isTerminalStructuredQuestionResultDetails( + (entry.message as { details?: unknown }).details, + ) + ) +} + function isCustomTranscriptEntry( entry: SessionEntry, ): entry is CustomEntry | CustomMessageEntry { From babdbfadf0e7988cdbb62ab6cefad91ba5ef31d1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:57:00 +0200 Subject: [PATCH 079/164] Cover structured question JSONL projection --- memory/REFACTOR.md | 4 ++-- src/elicitation-exchange.test.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index 7d25c567..bf92c9d7 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -26,8 +26,8 @@ The target state is: 2. [x] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. 3. [x] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. 4. [x] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. -5. [ ] Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. -6. Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. +5. [x] Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. +6. [ ] Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. 7. Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. ## Decisions diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index 8b29085a..d5b4bc08 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -299,6 +299,44 @@ describe("elicitation exchange projection", () => { ) }) + it("loads and projects terminal structured-question tool results as JSONL responses", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-structured-question-")) + const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) + appendBinding(manager) + manager.appendMessage( + assistantMessage("Please answer the structured question."), + ) + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-sq-jsonl", + toolName: "brunch_structured_question", + content: [{ type: "text", text: "Domain?: Developer tooling" }], + details: buildStructuredQuestionResult({ + params: { + id: "domain", + mode: "text", + prompt: "Domain?", + }, + status: "answered", + answers: [ + { questionId: "domain", mode: "text", value: "Developer tooling" }, + ], + transport: { surface: "rpc-editor" }, + }).details, + isError: false, + timestamp: 0, + }) + + const projection = await loadLinearElicitationExchangeProjection( + manager.getSessionFile()!, + ) + + expect(projection.status).toBe("ready") + expect(projection.exchanges).toHaveLength(1) + expect(projection.exchanges[0]?.promptEntryIds).toHaveLength(1) + expect(projection.exchanges[0]?.responseEntryIds).toHaveLength(1) + }) + it("loads displayable assistant and user transcript rows", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-display-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) From bd5bebf391fe699e7d7491db1d5bd526eb1be2f2 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:58:21 +0200 Subject: [PATCH 080/164] Reconcile structured question proof evidence --- docs/architecture/pi-ui-extension-patterns.md | 13 +++++++------ memory/PLAN.md | 2 +- memory/REFACTOR.md | 4 ++-- memory/SPEC.md | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index e2a39ff9..b907285b 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -14,7 +14,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | | Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | | In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | -| Structured-question response loop | feasible but not Brunch-proven | required before M5 lens/review affordances depend on structured elicitation | Pi `question`/`questionnaire` examples + RPC UI demo; Brunch proof pending | +| Structured-question response loop | partially proven; product relay pending | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests | ## Evidence inventory @@ -24,6 +24,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/pi-components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. +- **Live structured-question RPC oracle:** `npm run test:structured-question-rpc-proof` launches a real Pi RPC subprocess with a minimal Brunch structured-question proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_question.result` payload as the TUI/helper path. ## Command inventory and containment matrix @@ -228,7 +229,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Structured-question / RPC-relay gap -The remaining live FE-744 gap is not generic UI polish. Brunch still needs a structured elicitation loop: a system/assistant-originated question or questionnaire should be transcript truth, replace the default TUI input surface when rich UI is available, degrade over Pi RPC through supported extension UI dialogs (notably schema-tagged JSON over `ctx.ui.editor` for complex shapes), and persist a self-contained terminal structured result before the next agent turn consumes it. +The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop: the structured-question helper produces self-contained terminal result details, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, and elicitation-exchange projection classifies terminal structured-question `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gap is the public Brunch product relay: exposing pending Pi extension-UI requests as product-shaped RPC state/events for web/CLI clients, then translating product responses back into Pi's documented `extension_ui_response` messages. Pi source/docs already give strong evidence for the primitive: @@ -239,14 +240,14 @@ Pi source/docs already give strong evidence for the primitive: - `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch must still prove is the composition: assistant tool/custom prompt → input-replacing TUI UI or JSON-editor RPC fallback → self-contained structured result in Pi JSONL → projection as the response side of an elicitation exchange. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that loop is implemented or deliberately moved into a named M5 slice. +The seam Brunch must still prove is the public product relay around that composition: assistant tool/custom prompt → pending Brunch elicitation state/event over the single public RPC surface → product response from web/CLI probe → Pi `extension_ui_response` → self-contained structured result in Pi JSONL → existing response-side exchange projection. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that relay is implemented or deliberately moved into a named M5 slice. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | | Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | -| Registered structured-question tool seam | Pi examples prove tool-call / `toolResult.details` capture; Brunch projection does not yet classify terminal structured tool results as response-side entries. | Prefer the thinnest Pi-supported transcript seam for basic questions/questionnaires; make `toolResult.details` self-contained enough for Brunch projection. | -| TUI input replacement | Pi examples prove `ctx.ui.custom()` component replacement; Brunch has proven it only for spec/session decisions. | Build a Brunch-owned response helper over single-select, multi-select, questionnaire, and freeform-plus-choice patterns. | -| JSON-editor RPC fallback | Pi RPC supports `editor`; Brunch has not yet wrapped schema-tagged JSON editor requests as product pending-elicitation state. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | +| Registered structured-question tool seam | Brunch result-builder/schema tests cover self-contained `toolResult.details`; exchange projection now classifies terminal structured-question results as response-side entries. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side. | +| TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for text, single-select, multi-select, questionnaire, and terminal statuses. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | +| JSON-editor RPC fallback | Brunch helper tests and `npm run test:structured-question-rpc-proof` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | | Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | diff --git a/memory/PLAN.md b/memory/PLAN.md index 9e58f95c..b234a325 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -245,7 +245,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, and schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses. Continue the structured-question proof with Brunch product-surface relay semantics and elicitation-exchange projection before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses, live Pi RPC editor fallback at the adapter layer (`npm run test:structured-question-rpc-proof`), and response-side elicitation-exchange projection for terminal structured-question results. Continue the structured-question proof with Brunch product-surface relay semantics before returning to `graph-data-plane`. ### flue-pattern-adoption diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md index bf92c9d7..b0e7cff0 100644 --- a/memory/REFACTOR.md +++ b/memory/REFACTOR.md @@ -27,8 +27,8 @@ The target state is: 3. [x] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. 4. [x] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. 5. [x] Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. -6. [ ] Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. -7. Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. +6. [x] Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. +7. [ ] Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. ## Decisions diff --git a/memory/SPEC.md b/memory/SPEC.md index 08f5c6ff..0935b369 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -247,7 +247,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor fallback tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; Brunch product relay and projection contract pending) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor helper tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; `npm run test:structured-question-rpc-proof` live-proves Pi RPC `extension_ui_request(editor)` / `extension_ui_response(value)` at the adapter layer; elicitation-exchange projection tests cover terminal structured-question tool results as response-side JSONL entries while ordinary tool results remain prompt-side. Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | From 68f29ea987a49bb7176d823a5a1a5e50758a5e56 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:58:47 +0200 Subject: [PATCH 081/164] Retire structured question refactor queue --- memory/REFACTOR.md | 58 ---------------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md deleted file mode 100644 index b0e7cff0..00000000 --- a/memory/REFACTOR.md +++ /dev/null @@ -1,58 +0,0 @@ -## Problem Statement - -The structured-question implementation has good inner-loop coverage for schemas, result builders, fake TUI custom UI, and fake editor fallback, but it does not yet prove the two architectural claims that make the seam safe to depend on. - -First, RPC sufficiency is not witnessed against a live Pi RPC process: current tests exercise Brunch helpers with fake `ctx.ui.editor`, not the documented `extension_ui_request` / `extension_ui_response` round trip. That leaves uncertainty about whether a real CLI JSON-RPC client can drive the fallback end to end. - -Second, elicitation-exchange projection still treats all Pi tool results as prompt-side transcript entries. Brunch now has typed structured-question result details, but projection does not yet classify terminal structured-question tool results as response-side entries. Until that lands, `toolResult.details` is self-contained but not yet part of the observer extraction unit. - -The current UX refinements for structured questions are intentionally not part of this refactor. They should become a separate plan/scope item after the proof seam is trustworthy. - -## Solution - -Make the existing seam easier to trust before changing the structured-question UX. Add a live RPC proof harness that runs a minimal Brunch/Pi structured-question path through actual RPC extension UI messages, then use that evidence to tighten the projection behavior so typed terminal structured-question tool results become response-side entries while ordinary tool results remain prompt-side. - -The target state is: - -- a repeatable proof command or test fixture can witness `editor` request emission and response handling over Pi RPC; -- the proof verifies the final result payload, not just that an editor request appeared; -- projection has a small typed predicate for structured-question terminal results; -- tests distinguish ordinary tool results from structured-question answers; -- SPEC/PLAN evidence language can honestly say RPC fallback is live-proven for the adapter layer and projection is covered for terminal structured-question results. - -## Commits - -1. [x] Add characterization coverage for the existing structured-question transcript boundary: ordinary tool results stay prompt-side, and typed structured-question result details are recognized by a pure predicate without changing exchange projection yet. -2. [x] Add a live RPC proof harness that launches a minimal structured-question scenario, observes the actual editor UI request, submits a documented RPC UI response, and captures the resulting terminal payload. -3. [x] Wire the proof harness into an executable runbook or targeted test path with stable assertions over the editor request shape and terminal structured-question result details. -4. [x] Change elicitation-exchange projection so terminal structured-question tool results are response-side entries, while ordinary tool results and non-terminal structured-question statuses retain the existing prompt/open behavior as appropriate. -5. [x] Add projection coverage for typed structured-question exchanges, including the contrastive case where an ordinary tool result remains prompt-side. -6. [x] Reconcile documentation and planning evidence: mark the RPC editor fallback as live-proven at the adapter level, mark elicitation-exchange projection for structured-question terminal results as covered, and keep broader Brunch product-surface relay semantics as the remaining gap. -7. [ ] Delete or quarantine any temporary proof-only scaffolding that should not survive as product code, keeping only the reusable runbook/test harness if it remains valuable. - -## Decisions - -- The proof targets the Pi RPC extension UI protocol directly, not a mocked Brunch helper and not a future public Brunch relay. -- The proof result must include the same self-contained structured-question details shape used by the TUI path. -- Projection classifies by typed structured-question result details, not by tool name alone; this prevents accidental response-side classification of unrelated tool results. -- The refactor preserves the current structured-question payload schema unless the live proof reveals a protocol mismatch. -- The public Brunch product relay for pending elicitation remains a follow-up seam, not part of this proof refactor. -- Structured-question UX refinements are intentionally deferred to a separate planning item so proof work does not become interaction-design churn. - -## Testing Decisions - -- Good tests here prove behavior at the same boundaries future callers rely on: RPC protocol messages, Pi JSONL/tool-result payloads, and elicitation-exchange projection output. -- Pure unit tests remain useful for schema and projection classification, but they are insufficient for RPC sufficiency. -- The live RPC proof should be small and deterministic: one structured question, one editor fallback answer, one terminal result assertion. -- The live proof should not depend on model behavior if avoidable; prefer a command/tool-driven harness or deterministic probe over asking an LLM to decide when to call the tool. -- Projection tests must include contrastive ordinary tool results so the new response-side rule cannot accidentally reclassify every `toolResult`. -- Existing targeted suites for structured-question helpers, extension adapter helpers, elicitation-exchange projection, and JSON-RPC handlers are the prior art to preserve. - -## Out of Scope - -- Redesigning the structured-question UX, including richer freeform-plus-choice flows, review-set action surfaces, or establishment-offer orientation views. -- Building the public Brunch pending-elicitation relay for web/CLI clients; this refactor proves the private Pi RPC adapter layer and leaves product-surface relay semantics as the next slice. -- Adding graph writes, observer jobs, review-set acceptance, or command-layer mutation behavior. -- Changing the structured-question schema for aesthetic reasons unless the live RPC proof exposes a real protocol mismatch. -- Making the live RPC proof a mandatory CI gate if host-sensitive process/PTY behavior makes it flaky; it may remain a runbook/probe with deterministic assertions. -- Touching unrelated dirty planning files or the auto-compaction anchor artifact except for deliberate reconciliation after proof results are known. From 1ec688684fcc6395dacd42c38180f237719bacb0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 20:48:01 +0200 Subject: [PATCH 082/164] spec and plan re side- and sub-agents vs side-tasks --- memory/PLAN.md | 14 ++++++++++++++ memory/SPEC.md | 18 ++++++++++++++++-- src/pi-extensions/subagents/config.json | 5 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/pi-extensions/subagents/config.json diff --git a/memory/PLAN.md b/memory/PLAN.md index b234a325..7edec5f0 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -32,6 +32,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. +- `subagents-for-proposal-diversity` — Optional enhancement to candidate-proposal generation (D44-L). Lands when `agent-and-graph-integration` (M5) is far enough along that generative-lens proposal flow exists and would benefit from parallel data-gathering; never a blocker. ### Horizon @@ -154,6 +155,19 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L / A3-L, A11-L, A13-L, A14-L, A16-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) +### subagents-for-proposal-diversity + +- **Name:** Subagents for candidate-proposal diversity (optional enhancement) +- **Linear:** unassigned +- **Kind:** optional enhancement +- **Status:** deferred (lands when `agent-and-graph-integration` is far enough along to benefit; never a blocker for M0–M9) +- **Objective:** Register a single `subagent` Pi tool per D44-L so the main agent can (a) fan out blocking data-gathering calls (scout / researcher / graph-reader) in parallel to ground proposals, then (b) fan out parallel `proposer` invocations to generate diverse candidate variants — the subagent realization of `ln-design`'s "design it twice" pattern and `ln-oracles`'s parallel-fan-out — and finally compose `brunch.review_set_proposal` entries from those variants via the D31-L meta-rubric. Subagent results return as tool content; no `CommandExecutor` access; no Brunch RPC access; isolated `pi --no-session --no-skills --no-extensions` subprocesses inheriting Brunch Pi Profile sealing. +- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/pi-extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one generative-lens fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. +- **Verification:** Inner — `subagent` tool argv-shape tests; TypeBox schema validation of agent frontmatter and `config.json`; per-starter-agent tool-allowlist conformance (proposer must have an empty tool set). Middle — isolation audit (no ambient `.pi/` resources reachable; parent `CommandExecutor` / Brunch RPC handlers absent from subprocess environment); subprocess streaming / abort propagation tests; parallel-fan-out independence test (two `proposer` invocations with distinct framings produce structurally distinct outputs). Outer — proposal-generation fixture invokes scout/researcher/graph-reader to ground, then parallel `proposer` variants, and surfaces the composed review-set proposal with grounding-bundle coverage and `epistemic_status` consistent with the gathered evidence; meta-rubric application visible in the comparison rendering. +- **Cross-cutting obligations:** Preserve the single-authority mutation rule (`CommandExecutor` only — subagents never bypass it) and the sealed Pi Profile (no ambient `.pi/` leakage through the subprocess boundary). Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. Worker-style write-capable subagents are deferred until an execute operational mode exists. +- **Traceability:** R20 / D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L, D44-L / I2-L, I11-L, I24-L, I29-L +- **Design docs:** [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) + ### authority-model - **Name:** Authority model and gated tools (M6) diff --git a/memory/SPEC.md b/memory/SPEC.md index 0935b369..15eb9e4d 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -191,7 +191,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Persistence - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. -- **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Background sub-agents are tracked by a Brunch-owned `SideTaskRegistry`; results are never injected mid-turn and instead arrive at the next-turn boundary through the existing custom-message plus `prepareNextTurn` path. Side-task writes remain subject to the same command-layer authority as primary-agent writes. Depends on: A11-L, D4-L. Supersedes: —. +- **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Side tasks are main-agent-invoked, non-blocking work items: the main agent fires them and continues without awaiting a return value. A Brunch-owned `SideTaskRegistry` tracks status; the only path a side task influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary through the existing `prepareNextTurn` path — never mid-turn. Side-task writes remain subject to the same command-layer authority as primary-agent writes. This is distinct from D44-L Subagent (main-agent-invoked **blocking** tool call whose result is returned directly as tool content). Depends on: A11-L, D4-L. Supersedes: —. - **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions via TypeBox per D41-L; the Drizzle version pin is open per A20-L. Depends on: A3-L, A4-L. Refined by: D41-L. Supersedes: —. - **D18-L — Observer extraction is exchange-keyed durable work, not a chat/turn store.** After a user response closes an elicitation exchange, Brunch may enqueue an observer job keyed by session id plus exchange entry ids; jobs survive process restart and graph writes still route through the command layer. Routine observer jobs are operational queue state, not reconciliation needs by default; low-confidence or conflicting findings may create reconciliation needs. Depends on: A13-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model. - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. @@ -218,6 +218,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. +- **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/pi-extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: + - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). + - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a generative-lens-shaped frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. + This division mirrors the generative-lens family in D26-L: `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, and `propose-oracle-ensembles` are the natural lenses that delegate to fan-out `proposer` invocations; `project-requirements-from-upstream` may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. - **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. - **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. @@ -253,6 +257,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | +| I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | ## Future Direction Register @@ -298,6 +303,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - Whether the elicitation/transcript UI leans more heavily on Vercel AI SDK, TanStack AI primitives, or a thin Brunch-owned spanning abstraction is a post-M3 decision. +### Side chat (deferred) + +- **Side chat** is a non-priority user-invoked overlay (slash commands like `/btw` or `/aside`) where the user reasons about something in a separate context without derailing the main session. On close, a **summary** of the side conversation is inserted into the main Pi JSONL transcript as a single custom entry, in the same spirit as Pi branch-summaries or compaction summaries — the full thread is not merged, only its condensed residue. Authority is read-only; the side chat does not write graph, invoke `CommandExecutor`, or affect runtime posture. Reference implementations in the design space: `btw`, `pi-side-chat`, `pi-ghost`, and `oracle` (the last as the single-shot degenerate case). Persistence shape (in-memory vs `.brunch/sessions/<parent>/side/`), model selection, and `peek_main`-style affordances are all deferred until product pressure justifies the feature. Side chat is *not* a candidate-proposal mechanism (that role belongs to D44-L Subagent); it is a user-productivity affordance. + ### Durable state framing - Brunch's durable state is intentionally split across four semantic substrates: graph truth (nodes/edges), `change_log` audit/history, `coherence_state` verdict, and `reconciliation_need` actionable semantic queue. Routine async work such as observer jobs may use a separate operational queue; if later generalized, table naming may become `work_item` with subtypes, but the POC should not make every observer job a reconciliation need. @@ -360,7 +369,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | | **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | -| **Side task** | A scoped sub-agent or auxiliary LLM invocation whose result returns through the shared command layer or a bounded metadata seam such as Pi `session_info` when it is explicitly presentation metadata. | +| **Side task** | Main-agent-invoked, non-blocking work item tracked by the Brunch `SideTaskRegistry`. The main agent fires it and does not await a return value; the only path it influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary via `prepareNextTurn`. Side-task writes route through the `CommandExecutor`. Distinct from Subagent (blocking) and Side chat (user-invoked). | +| **Subagent** | Main-agent-invoked, **blocking** Pi tool call (`subagent`) that runs an isolated `pi` subprocess with a per-agent tool allowlist and per-agent model. Has no inherited conversation context, no `CommandExecutor` access, and no Brunch RPC access. Result text returns directly as tool result content. POC starter agents split into **data gatherers** (scout / researcher / graph-reader — read-only context fetchers that ground proposals) and a **variant proposer** (proposer — system-prompt-only; one variant per invocation, fan-out via parallel mode realizes the "design it twice" pattern). | +| **Proposer subagent** | The system-prompt-only starter subagent that emits exactly one well-formed candidate-proposal variant per invocation given a grounding bundle plus a generative-lens-shaped frame. Diversity arises from parallel `tasks: []` invocations with intentionally distinct framings; the main agent assembles outputs into a `brunch.review_set_proposal` via the D31-L meta-rubric. Realizes the "design it twice" / parallel-fan-out pattern from `ln-design` and `ln-oracles` skills in subagent form. | +| **Subagent registry** | The set of registered subagent definitions loaded from `src/pi-extensions/subagents/agents/*.md` at extension activation. Brunch-owned only for the POC; cross-extension agent registration is deferred. | +| **Subagent agent definition** | A markdown file with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. The frontmatter is the registry contract; the body is the subagent's standing instructions. | | **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/pi-extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active runtime bundle; falls through to Pi default compaction on auth/empty-output/unexpected errors. | | **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | | **Anchor contract** | The data inside the preserved-anchor JSON config — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | @@ -494,6 +507,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I28-L | Inner — TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | +| I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/pi-extensions/subagents/agents/*.md` frontmatter and `src/pi-extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | ### Design Notes diff --git a/src/pi-extensions/subagents/config.json b/src/pi-extensions/subagents/config.json new file mode 100644 index 00000000..88e47a01 --- /dev/null +++ b/src/pi-extensions/subagents/config.json @@ -0,0 +1,5 @@ +{ + "$comment": "Subagent extension config (D44-L). Reviewable and editable without SPEC churn. Validated through a TypeBox schema when src/pi-extensions/subagents/index.ts lands.", + "version": 1, + "maxConcurrency": 4 +} From e9c78eaee7b0335126599ca4c42f78b796925182 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:09:57 +0200 Subject: [PATCH 083/164] Characterize fixture mention mode --- src/brunch-tui.test.ts | 84 +++++++++++++++++++++++ src/pi-extensions.ts | 7 +- src/pi-extensions/mention-autocomplete.ts | 26 +++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index dc865d67..501d7000 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -750,6 +750,90 @@ describe("Brunch TUI boot", () => { expect(commands).toEqual([]) }) + it("installs fixture graph-code mention autocomplete and prompt guidance from the Brunch shell", async () => { + let providerFactory: (( + current: FakeAutocompleteProvider, + ) => FakeAutocompleteProvider) | undefined + const sessionStart: Array<( + event: unknown, + ctx: FakeExtensionContext, + ) => Promise<void> | void> = [] + const beforeAgentStart: Array<( + event: { systemPrompt: string }, + ctx: FakeExtensionContext, + ) => Promise<unknown> | unknown> = [] + + createBrunchPiExtensionShell( + chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), + undefined, + { coordinator: noOpWorkspaceCoordinator("/tmp/project") }, + )({ + on: (event: string, handler: never) => { + if (event === "session_start") sessionStart.push(handler) + if (event === "before_agent_start") beforeAgentStart.push(handler) + }, + registerCommand: (_name: string, _options: unknown) => {}, + registerShortcut: (_name: string, _options: unknown) => {}, + registerTool: (_tool: unknown) => {}, + registerMessageRenderer: (_type: string) => {}, + sendMessage: (_message: unknown) => {}, + getAllTools: () => [], + setActiveTools: (_tools: string[]) => {}, + } as never) + + const ctx: FakeExtensionContext = { + sessionManager: { + getEntries: () => [], + } as unknown as FakeExtensionContext["sessionManager"], + ui: { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, + setWidget: (_key: string, _content: unknown) => {}, + setWorkingIndicator: (_options) => {}, + setTitle: (_title: string) => {}, + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + addAutocompleteProvider: (factory: typeof providerFactory) => { + providerFactory = factory + }, + } as FakeExtensionUi & { + addAutocompleteProvider: (factory: typeof providerFactory) => void + }, + } + + for (const handler of sessionStart) await handler({}, ctx) + const promptUpdates = await Promise.all( + beforeAgentStart.map((handler) => + Promise.resolve(handler({ systemPrompt: "base" }, ctx)), + ), + ) + + const fallback: FakeAutocompleteProvider = { + getSuggestions: async () => ({ items: [], prefix: "" }), + applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }), + shouldTriggerFileCompletion: () => true, + } + const provider = providerFactory?.(fallback) + + expect( + promptUpdates.some( + (update) => + typeof update === "object" && + update !== null && + "systemPrompt" in update && + String(update.systemPrompt).includes("Brunch graph mention handles"), + ), + ).toBe(true) + await expect( + provider?.getSuggestions(["Discuss #"], 0, 9, {} as never), + ).resolves.toMatchObject({ + prefix: "#", + items: expect.arrayContaining([ + expect.objectContaining({ value: "#D12" }), + ]), + }) + }) + it("registers graph-code mention autocomplete without fixture tag JSON", async () => { let providerFactory: (( current: FakeAutocompleteProvider, diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 7d1084c7..9bdc2687 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -6,6 +6,7 @@ import { import { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" import { + FIXTURE_GRAPH_MENTION_SOURCE, registerBrunchMentionAutocomplete, type GraphMentionSource, } from "./pi-extensions/mention-autocomplete.js" @@ -28,6 +29,7 @@ import { export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" export { + FIXTURE_GRAPH_MENTION_SOURCE, extractHashPrefix, registerBrunchMentionAutocomplete, type GraphMentionCandidate, @@ -109,7 +111,10 @@ export function createBrunchPiExtensionShell( registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) registerBrunchBranchPolicyHandlers(pi) registerBrunchOperationalModePolicy(pi) - registerBrunchMentionAutocomplete(pi, options.graphMentionSource) + registerBrunchMentionAutocomplete( + pi, + options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE, + ) registerBrunchAlternatives(pi) registerBrunchStructuredQuestion(pi) registerBrunchWorkspaceDialog(pi, options) diff --git a/src/pi-extensions/mention-autocomplete.ts b/src/pi-extensions/mention-autocomplete.ts index a72e99c8..5c3142a6 100644 --- a/src/pi-extensions/mention-autocomplete.ts +++ b/src/pi-extensions/mention-autocomplete.ts @@ -24,6 +24,32 @@ const EMPTY_GRAPH_MENTION_SOURCE: GraphMentionSource = { listMentionCandidates: () => [], } +export const FIXTURE_GRAPH_MENTION_SOURCE: GraphMentionSource = { + listMentionCandidates: () => [ + { + code: "D12", + title: "Transcript-native structured prompts", + description: + "Structured elicitation prompt/response entries stay visible in Pi JSONL.", + plane: "design", + }, + { + code: "I9", + title: "Mention ledger uses stable handles", + description: + "Inserted # handles are transcript text; labels are UI-only.", + plane: "intent", + }, + { + code: "A10", + title: "Persistent TUI chrome seam", + description: + "Brunch chrome renders through Pi UI primitives without forking Pi.", + plane: "intent", + }, + ], +} + export function registerBrunchMentionAutocomplete( pi: ExtensionAPI, source: GraphMentionSource = EMPTY_GRAPH_MENTION_SOURCE, From 89d2e8c0415981e5b2809252ec8f56d02ddc0d55 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:12:38 +0200 Subject: [PATCH 084/164] Characterize live chrome footer --- src/brunch-tui.test.ts | 74 ++++++++++++++++++++++++++++++++++--- src/pi-extensions/chrome.ts | 32 ++++++++++++---- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 501d7000..dcbe1583 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -263,6 +263,73 @@ describe("Brunch TUI boot", () => { ) }) + it("renders live footer telemetry and foreign statuses without publishing a chrome status key", async () => { + let footerFactory: unknown + const calls: FakeUiCall[] = [] + const ui: FakeExtensionUi = { + setHeader: (...args: unknown[]) => + calls.push({ method: "setHeader", args }), + setFooter: (factory: unknown) => { + footerFactory = factory + calls.push({ method: "setFooter", args: [factory] }) + }, + setStatus: (...args: unknown[]) => + calls.push({ method: "setStatus", args }), + setWidget: (...args: unknown[]) => + calls.push({ method: "setWidget", args }), + setWorkingIndicator: (_options) => {}, + setTitle: (...args: unknown[]) => + calls.push({ method: "setTitle", args }), + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome(ui, { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + }, + contextUsage: { usedTokens: 1024, maxTokens: 2048 }, + }) + + const footerRenderer = footerFactory as ( + tui: unknown, + theme: unknown, + footerData: unknown, + ) => { render: (width: number) => string[] } + const component = footerRenderer( + { requestRender: () => {} }, + { fg: (_tone: string, value: string) => value }, + { + getGitBranch: () => "main", + getExtensionStatuses: () => + new Map([ + ["brunch.reviewer", "reviewer queued"], + ["brunch.chrome", "should not echo"], + ]), + getAvailableProviderCount: () => 2, + onBranchChange: () => () => {}, + }, + ) + const footer = component.render(100).join("\n") + + expect(footer).toContain("Spec One") + expect(footer).toContain("Interview #1") + expect(footer).toContain("main") + expect(footer).toContain("claude-sonnet") + expect(footer).toContain("thinking medium") + expect(footer).toContain("[█████░░░░░] 1,024/2,048 tokens (50%)") + expect(footer).toContain("reviewer queued") + expect(footer).not.toContain("should not echo") + expect(calls.map((call) => call.method)).not.toContain("setStatus") + }) + it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { const calls: FakeUiCall[] = [] const ui: FakeExtensionUi = { @@ -291,17 +358,13 @@ describe("Brunch TUI boot", () => { expect(calls.map((call) => call.method)).toEqual([ "setHeader", "setFooter", - "setStatus", "setWidget", "setTitle", ]) expect(calls.find((call) => call.method === "setFooter")?.args[0]).toEqual( expect.any(Function), ) - expect(calls.find((call) => call.method === "setStatus")?.args).toEqual([ - "brunch.chrome", - "Brunch · elicitation · Spec One · not reported", - ]) + expect(calls.some((call) => call.method === "setStatus")).toBe(false) expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ "brunch.chrome", [ @@ -531,7 +594,6 @@ describe("Brunch TUI boot", () => { `switch:${target.session.file}`, "replacement:setHeader", "replacement:setFooter", - "replacement:setStatus", "replacement:setWidget", "replacement:setTitle", "replacement:notify", diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index ca9ed7ac..31c41387 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -42,7 +42,13 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { coherence?: BrunchChromeCoherenceVerdict } -export type BrunchChromeUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setStatus" | "setWidget" | "setTitle"> +export type BrunchChromeUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setWidget" | "setTitle"> + +interface BrunchChromeFooterData { + getGitBranch(): string | null + getExtensionStatuses(): ReadonlyMap<string, string> + onBranchChange(callback: () => void): () => void +} export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, @@ -56,13 +62,20 @@ export function formatBrunchChromeHeaderLines( export function formatBrunchChromeFooterLines( chrome: BrunchChromeState, + footerData?: BrunchChromeFooterData, ): string[] { + const statuses = [...(footerData?.getExtensionStatuses() ?? new Map())] + .filter(([key]) => key !== "brunch.chrome") + .map(([, value]) => value) + const branch = footerData?.getGitBranch() return [ `runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}`, `context: ${formatContextUsage(chrome.contextUsage)}`, `state: ${chrome.chatMode} · coherence: ${chrome.coherence ?? "unknown"} · worker: ${formatWorker(chrome)}`, - `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}`, - "", + `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}${ + branch ? ` · branch: ${branch}` : "" + }`, + statuses.length > 0 ? `status: ${statuses.join(" · ")}` : "", ] } @@ -101,11 +114,14 @@ export function renderBrunchChrome( render: () => formatBrunchChromeHeaderLines(chrome), invalidate: () => {}, })) - ui.setFooter(() => ({ - render: () => formatBrunchChromeFooterLines(chrome), - invalidate: () => {}, - })) - ui.setStatus("brunch.chrome", formatBrunchStatus(chrome)) + ui.setFooter((tui, _theme, footerData) => { + const unsubscribe = footerData.onBranchChange(() => tui.requestRender()) + return { + render: () => formatBrunchChromeFooterLines(chrome, footerData), + invalidate: () => {}, + dispose: unsubscribe, + } + }) ui.setWidget("brunch.chrome", formatChromeWidgetLines(chrome), { placement: "aboveEditor", }) From 8336e748f61d6281e9fb7883d286f19706d4f41b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:14:54 +0200 Subject: [PATCH 085/164] Extract chrome formatting helpers --- src/brunch-tui.test.ts | 33 ++++++++++++++++++++- src/pi-extensions.ts | 5 ++++ src/pi-extensions/chrome.ts | 58 +++++++++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index dcbe1583..59e2b8d6 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -25,9 +25,14 @@ import { chromeStateForWorkspace, createBrunchPiExtensionShell, formatBrunchChromeFooterLines, + alignChromeColumns, formatBrunchChromeHeaderLines, formatBrunchStatus, + formatChromeIdentity, formatChromeWidgetLines, + formatContextGauge, + formatTokenCount, + sanitizeChromeStatuses, extractHashPrefix, registerBrunchAlternatives, registerBrunchMentionAutocomplete, @@ -263,6 +268,32 @@ describe("Brunch TUI boot", () => { ) }) + it("provides reusable chrome formatting helpers", () => { + expect(formatTokenCount(999)).toBe("999") + expect(formatTokenCount(1536)).toBe("1.5k") + expect(formatContextGauge({ usedTokens: 1024, maxTokens: 2048 })).toBe( + "[█████░░░░░] 1,024/2,048 tokens (50%)", + ) + expect( + sanitizeChromeStatuses( + new Map([ + ["brunch.chrome", "ignored"], + ["brunch.reviewer", "reviewer queued"], + ]), + ), + ).toEqual(["reviewer queued"]) + expect( + formatChromeIdentity({ + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }), + ).toBe("spec: Spec One · session: Interview #1") + expect(alignChromeColumns("left", "right", 14)).toBe("left right") + }) + it("renders live footer telemetry and foreign statuses without publishing a chrome status key", async () => { let footerFactory: unknown const calls: FakeUiCall[] = [] @@ -317,7 +348,7 @@ describe("Brunch TUI boot", () => { onBranchChange: () => () => {}, }, ) - const footer = component.render(100).join("\n") + const footer = component.render(200).join("\n") expect(footer).toContain("Spec One") expect(footer).toContain("Interview #1") diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 9bdc2687..df2298cb 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -54,12 +54,17 @@ export { type ResolvedBrunchAgentState, } from "./pi-extensions/operational-mode.js" export { + alignChromeColumns, chromeStateForWorkspace, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, formatBrunchStatus, + formatChromeIdentity, formatChromeWidgetLines, + formatContextGauge, + formatTokenCount, renderBrunchChrome, + sanitizeChromeStatuses, type BrunchChromeCoherenceVerdict, type BrunchChromeStage, type BrunchChromeState, diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index 31c41387..11b46550 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -1,4 +1,5 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" import type { WorkspaceSessionChromeState, @@ -63,18 +64,20 @@ export function formatBrunchChromeHeaderLines( export function formatBrunchChromeFooterLines( chrome: BrunchChromeState, footerData?: BrunchChromeFooterData, + width?: number, ): string[] { - const statuses = [...(footerData?.getExtensionStatuses() ?? new Map())] - .filter(([key]) => key !== "brunch.chrome") - .map(([, value]) => value) + const statuses = sanitizeChromeStatuses(footerData?.getExtensionStatuses()) const branch = footerData?.getGitBranch() + const identity = `${formatChromeIdentity(chrome)}${ + branch ? ` · branch: ${branch}` : "" + }` + const runtime = `runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}` + const context = `context: ${formatContextUsage(chrome.contextUsage)}` return [ - `runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}`, - `context: ${formatContextUsage(chrome.contextUsage)}`, + width === undefined ? runtime : alignChromeColumns(runtime, context, width), + ...(width === undefined ? [context] : []), `state: ${chrome.chatMode} · coherence: ${chrome.coherence ?? "unknown"} · worker: ${formatWorker(chrome)}`, - `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}${ - branch ? ` · branch: ${branch}` : "" - }`, + identity, statuses.length > 0 ? `status: ${statuses.join(" · ")}` : "", ] } @@ -94,6 +97,42 @@ export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { ] } +export function formatChromeIdentity(chrome: BrunchChromeState): string { + return `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}` +} + +export function sanitizeChromeStatuses( + statuses: ReadonlyMap<string, string> | undefined, +): string[] { + return [...(statuses ?? new Map())] + .filter( + ([key, value]) => key !== "brunch.chrome" && value.trim().length > 0, + ) + .map(([, value]) => value.trim()) +} + +export function formatTokenCount(tokens: number): string { + const normalized = Math.max(0, tokens) + if (normalized < 1000) return String(normalized) + return `${(normalized / 1000).toFixed(1)}k` +} + +export function formatContextGauge( + usage: BrunchChromeContextUsage | undefined, +): string { + return formatContextUsage(usage) +} + +export function alignChromeColumns( + left: string, + right: string, + width: number, +): string { + const available = Math.max(0, width) + const gap = Math.max(1, available - visibleWidth(left) - visibleWidth(right)) + return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, available) +} + export function chromeStateForWorkspace( workspace: WorkspaceSessionReadyState, ): BrunchChromeState { @@ -117,7 +156,8 @@ export function renderBrunchChrome( ui.setFooter((tui, _theme, footerData) => { const unsubscribe = footerData.onBranchChange(() => tui.requestRender()) return { - render: () => formatBrunchChromeFooterLines(chrome, footerData), + render: (width: number) => + formatBrunchChromeFooterLines(chrome, footerData, width), invalidate: () => {}, dispose: unsubscribe, } From 8b2610865d63e039b5e7e2eaf66b7849f4767d1a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:15:46 +0200 Subject: [PATCH 086/164] Recover chrome header summary --- src/brunch-tui.test.ts | 29 ++++++++++++++++++++++++++--- src/pi-extensions/chrome.ts | 6 +++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 59e2b8d6..1f156ca8 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -202,6 +202,29 @@ describe("Brunch TUI boot", () => { ) }) + it("formats chrome header as wordmark plus runtime-state summary", async () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + chatMode: "responding-to-elicitation" as const, + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + lens: "step-by-step", + }, + } + + expect(formatBrunchChromeHeaderLines(state)).toEqual([ + "brunch", + "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step", + "spec: Spec One · session: Interview #1 · phase: elicitation", + ]) + }) + it("formats honest Brunch chrome from one product-state snapshot", async () => { const state = { cwd: "/tmp/project", @@ -212,9 +235,9 @@ describe("Brunch TUI boot", () => { } expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch · Spec One", - "cwd: /tmp/project", - "session: Interview #1 · phase: elicitation", + "brunch", + "runtime: not reported", + "spec: Spec One · session: Interview #1 · phase: elicitation", ]) expect(formatBrunchChromeFooterLines(state)).toEqual([ "runtime: not reported · build: not reported", diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index 11b46550..3f89120d 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -55,9 +55,9 @@ export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, ): string[] { return [ - `brunch · ${formatSpec(chrome)}`, - `cwd: ${chrome.cwd}`, - `session: ${formatSession(chrome)} · phase: ${chrome.phase}`, + "brunch", + `runtime: ${formatRuntime(chrome)}`, + `${formatChromeIdentity(chrome)} · phase: ${chrome.phase}`, ] } From 1e303594da7a39449d075d9f4327c6fe2b579c20 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:17:56 +0200 Subject: [PATCH 087/164] Reconcile chrome status ownership --- docs/architecture/pi-ui-extension-patterns.md | 17 ++++++++--------- memory/PLAN.md | 2 +- memory/SPEC.md | 2 +- src/brunch-tui.test.ts | 4 ---- src/pi-extensions.ts | 1 - src/pi-extensions/chrome.ts | 4 ---- 6 files changed, 10 insertions(+), 20 deletions(-) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index b907285b..27611ade 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/pi-components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns an honest footer projection, writes compact `setStatus`, expanded string-array `setWidget`, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/pi-components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-question RPC oracle:** `npm run test:structured-question-rpc-proof` launches a real Pi RPC subprocess with a minimal Brunch structured-question proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_question.result` payload as the TUI/helper path. @@ -123,7 +123,7 @@ The same probe emitted corresponding `notify` requests (`cancel switch new`, `ca The Brunch extension entrypoint is intentionally a registration map. `src/pi-extensions.ts` composes flat product-owned modules by Pi surface/responsibility: -- `chrome.ts` owns `BrunchChromeState`, formatting, and `renderBrunchChrome()`. +- `chrome.ts` owns `BrunchChromeState`, reusable formatting helpers, and `renderBrunchChrome()`. - `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. - `command-policy.ts` owns branch/session effect blocking for unsupported Pi flows. - `workspace-dialog.ts` owns `/brunch`, `ctrl+shift+b`, and the in-session spec/session picker activation adapter. @@ -133,13 +133,12 @@ The Brunch extension entrypoint is intentionally a registration map. `src/pi-ext `renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: -- header: product identity plus cwd, active spec, and real activated session id/label; -- footer: phase/chat mode plus active spec/session; -- status: compact persistent phase/spec summary; -- widget: cwd, spec, session, and chat mode diagnostics; +- header: plain wordmark plus runtime-state initialization summary, active spec, real activated session id/label, and phase; +- footer: a live TUI compositor that combines product facts from `BrunchChromeState` with Pi footer telemetry (`footerData.getGitBranch()` and foreign `ctx.ui.setStatus()` entries); +- widget: cwd, spec, session, runtime, context, and chat-mode diagnostics; - title: compact Brunch-owned terminal title derived from activated workspace state. -The wrapper uses plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; status/widget/title provide deterministic state strings for tests and RPC-compatible clients. The wrapper deliberately does not fabricate build version, model/thinking, git state, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; widget/title provide deterministic state strings for tests and RPC-compatible clients. `ctx.ui.setStatus(key, text)` remains available as a lateral contribution channel for other extensions and future dynamic Brunch state; the chrome wrapper does not publish a `brunch.chrome` status key and filters that key if a stale producer contributes it. The wrapper deliberately does not fabricate build version, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: @@ -148,7 +147,7 @@ Observed behavior: | Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one snapshot; tests assert the same formatter output used by the wrapper. | `src/brunch-tui.test.ts` | | `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | | Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/brunch-tui.test.ts` | -| RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Fixture drivers should assert status/widget events, not TUI-only header/footer. | Pi RPC source + temp RPC JSONL probe | +| RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Brunch chrome currently uses TUI-only header/footer plus diagnostic widget/title; fixture drivers should not assert TUI-only header/footer or a chrome-owned status key. | Pi RPC source + temp RPC JSONL probe | ## Startup/splash logo asset decision @@ -193,7 +192,7 @@ Reviewed Pi docs/examples remain useful for downstream M5/M6/M7 affordance desig | `structured-output` | Pi example evidence | Relevant to future agent/tool result rendering, not current workspace-dialog proof. | | `titlebar-spinner` / working indicator examples | Pi example evidence only | Brunch leaves Pi's working indicator untouched; custom spinner styling is deferred until a live side-task/reviewer spinner is product-proven. | | `custom-header` / `custom-footer` | Raw Pi TUI proof plus Brunch wrapper tests | Brunch uses header for product identity and restores the default footer; replacing the footer should remain intentional. | -| `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports compact persistent state; Brunch currently uses `setStatus` and widget diagnostics, not a custom editor/border. | +| `status-line` / `border-status-editor` | Pi example plus Brunch wrapper tests | Supports lateral extension status contributions; Brunch chrome currently renders foreign statuses in the TUI footer and uses widget diagnostics rather than publishing its own status key or replacing the editor/border. | ## RPC controllability observations relevant to command containment and chrome diff --git a/memory/PLAN.md b/memory/PLAN.md index 7edec5f0..c163c6f5 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -255,7 +255,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives, and RPC fixtures should assert only chrome events that Pi actually emits (`setStatus`, string-array `setWidget`, `setTitle`, notifications). +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). diff --git a/memory/SPEC.md b/memory/SPEC.md index 15eb9e4d..c875d050 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -125,7 +125,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setStatus`, `setWidget`, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id; reload and session replacement reconstruct chrome by rerunning extension setup with a fresh Brunch snapshot. RPC clients should rely on status/widget/title events because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs or rendering placeholder session state such as `unbound` after a session is activated. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. #### Data model & vocabulary diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 1f156ca8..337c355d 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -27,7 +27,6 @@ import { formatBrunchChromeFooterLines, alignChromeColumns, formatBrunchChromeHeaderLines, - formatBrunchStatus, formatChromeIdentity, formatChromeWidgetLines, formatContextGauge, @@ -246,9 +245,6 @@ describe("Brunch TUI boot", () => { "spec: Spec One · session: Interview #1", "", ]) - expect(formatBrunchStatus(state)).toBe( - "Brunch · elicitation · Spec One · not reported", - ) expect(formatChromeWidgetLines(state)).toEqual([ "cwd: /tmp/project", "spec: Spec One", diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index df2298cb..6e4cbdb7 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -58,7 +58,6 @@ export { chromeStateForWorkspace, formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, - formatBrunchStatus, formatChromeIdentity, formatChromeWidgetLines, formatContextGauge, diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index 3f89120d..59788667 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -82,10 +82,6 @@ export function formatBrunchChromeFooterLines( ] } -export function formatBrunchStatus(chrome: BrunchChromeState): string { - return `Brunch · ${chrome.phase} · ${formatSpec(chrome)} · ${formatRuntime(chrome)}` -} - export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { return [ `cwd: ${chrome.cwd}`, From 9a6b7911c9220f0ef2a51cfa94b08b5b72dc10a1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:27:17 +0200 Subject: [PATCH 088/164] Move chrome behavior tests to chrome module --- src/brunch-tui.test.ts | 255 --------------------------- src/pi-extensions/chrome.test.ts | 290 +++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 255 deletions(-) create mode 100644 src/pi-extensions/chrome.test.ts diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 337c355d..258d1ab9 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -24,19 +24,10 @@ import { BRUNCH_WORKSPACE_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensionShell, - formatBrunchChromeFooterLines, - alignChromeColumns, - formatBrunchChromeHeaderLines, - formatChromeIdentity, - formatChromeWidgetLines, - formatContextGauge, - formatTokenCount, - sanitizeChromeStatuses, extractHashPrefix, registerBrunchAlternatives, registerBrunchMentionAutocomplete, registerBrunchOperationalModePolicy, - renderBrunchChrome, runBrunchWorkspaceCommand, runBrunchWorkspaceAction, } from "./pi-extensions.js" @@ -191,247 +182,6 @@ describe("Brunch TUI boot", () => { ) }) - it("passes activated session state into chrome instead of fabricating unbound", async () => { - const state = chromeStateForWorkspace( - readyWorkspace("/tmp/project", "session-real"), - ) - - expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( - "session-real", - ) - }) - - it("formats chrome header as wordmark plus runtime-state summary", async () => { - const state = { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation" as const, - chatMode: "responding-to-elicitation" as const, - runtime: { - bundle: "elicit-default", - role: "elicitor", - model: "claude-sonnet", - thinking: "medium", - lens: "step-by-step", - }, - } - - expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch", - "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step", - "spec: Spec One · session: Interview #1 · phase: elicitation", - ]) - }) - - it("formats honest Brunch chrome from one product-state snapshot", async () => { - const state = { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation" as const, - chatMode: "responding-to-elicitation" as const, - } - - expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch", - "runtime: not reported", - "spec: Spec One · session: Interview #1 · phase: elicitation", - ]) - expect(formatBrunchChromeFooterLines(state)).toEqual([ - "runtime: not reported · build: not reported", - "context: not reported", - "state: responding-to-elicitation · coherence: unknown · worker: not reported", - "spec: Spec One · session: Interview #1", - "", - ]) - expect(formatChromeWidgetLines(state)).toEqual([ - "cwd: /tmp/project", - "spec: Spec One", - "session: Interview #1", - "runtime: not reported", - "context: not reported", - "chat mode: responding-to-elicitation", - ]) - }) - - it("formats rich optional runtime and context metadata without fabricating missing fields", () => { - const state = { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation" as const, - chatMode: "responding-to-elicitation" as const, - runtime: { - bundle: "elicit-default", - role: "elicitor", - model: "claude-sonnet", - thinking: "medium", - lens: "step-by-step", - }, - build: { version: "v0.0.0", dev: "dev abc123" }, - contextUsage: { usedTokens: 1024, maxTokens: 2048 }, - worker: { stage: "observer-review" as const, status: "queued" as const }, - coherence: "needs_review" as const, - } - - expect(formatBrunchChromeFooterLines(state)).toEqual([ - "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", - "context: [█████░░░░░] 1,024/2,048 tokens (50%)", - "state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued", - "spec: Spec One · session: Interview #1", - "", - ]) - expect(formatChromeWidgetLines(state)).toContain( - "context: [█████░░░░░] 1,024/2,048 tokens (50%)", - ) - }) - - it("provides reusable chrome formatting helpers", () => { - expect(formatTokenCount(999)).toBe("999") - expect(formatTokenCount(1536)).toBe("1.5k") - expect(formatContextGauge({ usedTokens: 1024, maxTokens: 2048 })).toBe( - "[█████░░░░░] 1,024/2,048 tokens (50%)", - ) - expect( - sanitizeChromeStatuses( - new Map([ - ["brunch.chrome", "ignored"], - ["brunch.reviewer", "reviewer queued"], - ]), - ), - ).toEqual(["reviewer queued"]) - expect( - formatChromeIdentity({ - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - }), - ).toBe("spec: Spec One · session: Interview #1") - expect(alignChromeColumns("left", "right", 14)).toBe("left right") - }) - - it("renders live footer telemetry and foreign statuses without publishing a chrome status key", async () => { - let footerFactory: unknown - const calls: FakeUiCall[] = [] - const ui: FakeExtensionUi = { - setHeader: (...args: unknown[]) => - calls.push({ method: "setHeader", args }), - setFooter: (factory: unknown) => { - footerFactory = factory - calls.push({ method: "setFooter", args: [factory] }) - }, - setStatus: (...args: unknown[]) => - calls.push({ method: "setStatus", args }), - setWidget: (...args: unknown[]) => - calls.push({ method: "setWidget", args }), - setWorkingIndicator: (_options) => {}, - setTitle: (...args: unknown[]) => - calls.push({ method: "setTitle", args }), - notify: (_message: string, _type?: "info" | "warning" | "error") => {}, - } - - renderBrunchChrome(ui, { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - runtime: { - bundle: "elicit-default", - role: "elicitor", - model: "claude-sonnet", - thinking: "medium", - }, - contextUsage: { usedTokens: 1024, maxTokens: 2048 }, - }) - - const footerRenderer = footerFactory as ( - tui: unknown, - theme: unknown, - footerData: unknown, - ) => { render: (width: number) => string[] } - const component = footerRenderer( - { requestRender: () => {} }, - { fg: (_tone: string, value: string) => value }, - { - getGitBranch: () => "main", - getExtensionStatuses: () => - new Map([ - ["brunch.reviewer", "reviewer queued"], - ["brunch.chrome", "should not echo"], - ]), - getAvailableProviderCount: () => 2, - onBranchChange: () => () => {}, - }, - ) - const footer = component.render(200).join("\n") - - expect(footer).toContain("Spec One") - expect(footer).toContain("Interview #1") - expect(footer).toContain("main") - expect(footer).toContain("claude-sonnet") - expect(footer).toContain("thinking medium") - expect(footer).toContain("[█████░░░░░] 1,024/2,048 tokens (50%)") - expect(footer).toContain("reviewer queued") - expect(footer).not.toContain("should not echo") - expect(calls.map((call) => call.method)).not.toContain("setStatus") - }) - - it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { - const calls: FakeUiCall[] = [] - const ui: FakeExtensionUi = { - setHeader: (...args: unknown[]) => - calls.push({ method: "setHeader", args }), - setFooter: (...args: unknown[]) => - calls.push({ method: "setFooter", args }), - setStatus: (...args: unknown[]) => - calls.push({ method: "setStatus", args }), - setWidget: (...args: unknown[]) => - calls.push({ method: "setWidget", args }), - setWorkingIndicator: (_options) => {}, - setTitle: (...args: unknown[]) => - calls.push({ method: "setTitle", args }), - notify: (_message: string, _type?: "info" | "warning" | "error") => {}, - } - - renderBrunchChrome(ui, { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - }) - - expect(calls.map((call) => call.method)).toEqual([ - "setHeader", - "setFooter", - "setWidget", - "setTitle", - ]) - expect(calls.find((call) => call.method === "setFooter")?.args[0]).toEqual( - expect.any(Function), - ) - expect(calls.some((call) => call.method === "setStatus")).toBe(false) - expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ - "brunch.chrome", - [ - "cwd: /tmp/project", - "spec: Spec One", - "session: session-1", - "runtime: not reported", - "context: not reported", - "chat mode: responding-to-elicitation", - ], - { placement: "aboveEditor" }, - ]) - expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ - "brunch — Spec One", - ]) - }) - it("binds replacement sessions through internal session boundary events", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-tui-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch", "sessions")) @@ -1199,11 +949,6 @@ function fakeUi( } } -interface FakeUiCall { - method: string - args: unknown[] -} - type FakeExtensionContext = Pick<ExtensionContext, "sessionManager"> & { ui: FakeExtensionUi } diff --git a/src/pi-extensions/chrome.test.ts b/src/pi-extensions/chrome.test.ts new file mode 100644 index 00000000..1abc382a --- /dev/null +++ b/src/pi-extensions/chrome.test.ts @@ -0,0 +1,290 @@ +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" + +import { describe, expect, it } from "vitest" + +import type { WorkspaceSessionReadyState } from "../workspace-session-coordinator.js" +import { + alignChromeColumns, + chromeStateForWorkspace, + formatBrunchChromeFooterLines, + formatBrunchChromeHeaderLines, + formatChromeIdentity, + formatChromeWidgetLines, + formatContextGauge, + formatTokenCount, + renderBrunchChrome, + sanitizeChromeStatuses, +} from "./chrome.js" + +describe("Brunch chrome projection", () => { + it("uses activated session state instead of fabricating unbound", async () => { + const state = chromeStateForWorkspace( + readyWorkspace("/tmp/project", "session-real"), + ) + + expect(formatBrunchChromeHeaderLines(state).join("\n")).toContain( + "session-real", + ) + }) + + it("formats chrome header as wordmark plus runtime-state summary", async () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + chatMode: "responding-to-elicitation" as const, + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + lens: "step-by-step", + }, + } + + expect(formatBrunchChromeHeaderLines(state)).toEqual([ + "brunch", + "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step", + "spec: Spec One · session: Interview #1 · phase: elicitation", + ]) + }) + + it("formats honest Brunch chrome from one product-state snapshot", async () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + chatMode: "responding-to-elicitation" as const, + } + + expect(formatBrunchChromeHeaderLines(state)).toEqual([ + "brunch", + "runtime: not reported", + "spec: Spec One · session: Interview #1 · phase: elicitation", + ]) + expect(formatBrunchChromeFooterLines(state)).toEqual([ + "runtime: not reported · build: not reported", + "context: not reported", + "state: responding-to-elicitation · coherence: unknown · worker: not reported", + "spec: Spec One · session: Interview #1", + "", + ]) + expect(formatChromeWidgetLines(state)).toEqual([ + "cwd: /tmp/project", + "spec: Spec One", + "session: Interview #1", + "runtime: not reported", + "context: not reported", + "chat mode: responding-to-elicitation", + ]) + }) + + it("formats rich optional runtime and context metadata without fabricating missing fields", () => { + const state = { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation" as const, + chatMode: "responding-to-elicitation" as const, + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + lens: "step-by-step", + }, + build: { version: "v0.0.0", dev: "dev abc123" }, + contextUsage: { usedTokens: 1024, maxTokens: 2048 }, + worker: { stage: "observer-review" as const, status: "queued" as const }, + coherence: "needs_review" as const, + } + + expect(formatBrunchChromeFooterLines(state)).toEqual([ + "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", + "context: [█████░░░░░] 1,024/2,048 tokens (50%)", + "state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued", + "spec: Spec One · session: Interview #1", + "", + ]) + expect(formatChromeWidgetLines(state)).toContain( + "context: [█████░░░░░] 1,024/2,048 tokens (50%)", + ) + }) + + it("provides reusable chrome formatting helpers", () => { + expect(formatTokenCount(999)).toBe("999") + expect(formatTokenCount(1536)).toBe("1.5k") + expect(formatContextGauge({ usedTokens: 1024, maxTokens: 2048 })).toBe( + "[█████░░░░░] 1,024/2,048 tokens (50%)", + ) + expect( + sanitizeChromeStatuses( + new Map([ + ["brunch.chrome", "ignored"], + ["brunch.reviewer", "reviewer queued"], + ]), + ), + ).toEqual(["reviewer queued"]) + expect( + formatChromeIdentity({ + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }), + ).toBe("spec: Spec One · session: Interview #1") + expect(alignChromeColumns("left", "right", 14)).toBe("left right") + }) + + it("renders live footer telemetry and foreign statuses without publishing a chrome status key", async () => { + let footerFactory: unknown + const calls: FakeUiCall[] = [] + const ui: FakeExtensionUi = { + setHeader: (...args: unknown[]) => + calls.push({ method: "setHeader", args }), + setFooter: (factory: unknown) => { + footerFactory = factory + calls.push({ method: "setFooter", args: [factory] }) + }, + setStatus: (...args: unknown[]) => + calls.push({ method: "setStatus", args }), + setWidget: (...args: unknown[]) => + calls.push({ method: "setWidget", args }), + setWorkingIndicator: (_options) => {}, + setTitle: (...args: unknown[]) => + calls.push({ method: "setTitle", args }), + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome(ui, { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + }, + contextUsage: { usedTokens: 1024, maxTokens: 2048 }, + }) + + const footerRenderer = footerFactory as ( + tui: unknown, + theme: unknown, + footerData: unknown, + ) => { render: (width: number) => string[] } + const component = footerRenderer( + { requestRender: () => {} }, + { fg: (_tone: string, value: string) => value }, + { + getGitBranch: () => "main", + getExtensionStatuses: () => + new Map([ + ["brunch.reviewer", "reviewer queued"], + ["brunch.chrome", "should not echo"], + ]), + getAvailableProviderCount: () => 2, + onBranchChange: () => () => {}, + }, + ) + const footer = component.render(200).join("\n") + + expect(footer).toContain("Spec One") + expect(footer).toContain("Interview #1") + expect(footer).toContain("main") + expect(footer).toContain("claude-sonnet") + expect(footer).toContain("thinking medium") + expect(footer).toContain("[█████░░░░░] 1,024/2,048 tokens (50%)") + expect(footer).toContain("reviewer queued") + expect(footer).not.toContain("should not echo") + expect(calls.map((call) => call.method)).not.toContain("setStatus") + }) + + it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { + const calls: FakeUiCall[] = [] + const ui: FakeExtensionUi = { + setHeader: (...args: unknown[]) => + calls.push({ method: "setHeader", args }), + setFooter: (...args: unknown[]) => + calls.push({ method: "setFooter", args }), + setStatus: (...args: unknown[]) => + calls.push({ method: "setStatus", args }), + setWidget: (...args: unknown[]) => + calls.push({ method: "setWidget", args }), + setWorkingIndicator: (_options) => {}, + setTitle: (...args: unknown[]) => + calls.push({ method: "setTitle", args }), + notify: (_message: string, _type?: "info" | "warning" | "error") => {}, + } + + renderBrunchChrome(ui, { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }) + + expect(calls.map((call) => call.method)).toEqual([ + "setHeader", + "setFooter", + "setWidget", + "setTitle", + ]) + expect(calls.find((call) => call.method === "setFooter")?.args[0]).toEqual( + expect.any(Function), + ) + expect(calls.some((call) => call.method === "setStatus")).toBe(false) + expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ + "brunch.chrome", + [ + "cwd: /tmp/project", + "spec: Spec One", + "session: session-1", + "runtime: not reported", + "context: not reported", + "chat mode: responding-to-elicitation", + ], + { placement: "aboveEditor" }, + ]) + expect(calls.find((call) => call.method === "setTitle")?.args).toEqual([ + "brunch — Spec One", + ]) + }) +}) + +function readyWorkspace( + cwd: string, + sessionId: string, +): WorkspaceSessionReadyState { + const spec = { id: "spec-1", title: "Spec One" } + return { + status: "ready", + cwd, + spec, + session: { + id: sessionId, + file: `/sessions/${sessionId}.jsonl`, + manager: {} as WorkspaceSessionReadyState["session"]["manager"], + }, + chrome: { + cwd, + spec, + phase: "elicitation", + chatMode: "responding-to-elicitation", + }, + } +} + +interface FakeUiCall { + method: string + args: unknown[] +} + +type FakeExtensionUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setStatus" | "setWidget" | "setWorkingIndicator" | "setTitle" | "notify"> From 14351615438848d06d2ceefd93aff9d6b998573b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:29:01 +0200 Subject: [PATCH 089/164] Move mention autocomplete tests to feature module --- src/brunch-tui.test.ts | 87 +---------- .../mention-autocomplete.test.ts | 143 ++++++++++++++++++ 2 files changed, 144 insertions(+), 86 deletions(-) create mode 100644 src/pi-extensions/mention-autocomplete.test.ts diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 258d1ab9..ed6c3eaa 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -24,9 +24,7 @@ import { BRUNCH_WORKSPACE_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensionShell, - extractHashPrefix, registerBrunchAlternatives, - registerBrunchMentionAutocomplete, registerBrunchOperationalModePolicy, runBrunchWorkspaceCommand, runBrunchWorkspaceAction, @@ -612,7 +610,7 @@ describe("Brunch TUI boot", () => { expect(commands).toEqual([]) }) - it("installs fixture graph-code mention autocomplete and prompt guidance from the Brunch shell", async () => { + it("wires the fixture graph-code mention source through the Brunch shell", async () => { let providerFactory: (( current: FakeAutocompleteProvider, ) => FakeAutocompleteProvider) | undefined @@ -620,10 +618,6 @@ describe("Brunch TUI boot", () => { event: unknown, ctx: FakeExtensionContext, ) => Promise<void> | void> = [] - const beforeAgentStart: Array<( - event: { systemPrompt: string }, - ctx: FakeExtensionContext, - ) => Promise<unknown> | unknown> = [] createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), @@ -632,7 +626,6 @@ describe("Brunch TUI boot", () => { )({ on: (event: string, handler: never) => { if (event === "session_start") sessionStart.push(handler) - if (event === "before_agent_start") beforeAgentStart.push(handler) }, registerCommand: (_name: string, _options: unknown) => {}, registerShortcut: (_name: string, _options: unknown) => {}, @@ -664,11 +657,6 @@ describe("Brunch TUI boot", () => { } for (const handler of sessionStart) await handler({}, ctx) - const promptUpdates = await Promise.all( - beforeAgentStart.map((handler) => - Promise.resolve(handler({ systemPrompt: "base" }, ctx)), - ), - ) const fallback: FakeAutocompleteProvider = { getSuggestions: async () => ({ items: [], prefix: "" }), @@ -677,15 +665,6 @@ describe("Brunch TUI boot", () => { } const provider = providerFactory?.(fallback) - expect( - promptUpdates.some( - (update) => - typeof update === "object" && - update !== null && - "systemPrompt" in update && - String(update.systemPrompt).includes("Brunch graph mention handles"), - ), - ).toBe(true) await expect( provider?.getSuggestions(["Discuss #"], 0, 9, {} as never), ).resolves.toMatchObject({ @@ -696,70 +675,6 @@ describe("Brunch TUI boot", () => { }) }) - it("registers graph-code mention autocomplete without fixture tag JSON", async () => { - let providerFactory: (( - current: FakeAutocompleteProvider, - ) => FakeAutocompleteProvider) | undefined - const source = { - listMentionCandidates: () => [ - { - code: "D12", - title: "Command containment", - description: "Blocks branchy Pi flows", - plane: "design" as const, - }, - { code: "I9", title: "Mention ledger", plane: "intent" as const }, - ], - } - - registerBrunchMentionAutocomplete( - { - on: (event: string, handler: (event: never, ctx: never) => unknown) => { - if (event === "session_start") { - void handler({} as never, { - ui: { - addAutocompleteProvider: (factory: typeof providerFactory) => { - providerFactory = factory - }, - }, - } as never) - } - }, - } as never, - source, - ) - - const fallback: FakeAutocompleteProvider = { - getSuggestions: async () => ({ items: [], prefix: "" }), - applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }), - shouldTriggerFileCompletion: () => true, - } - const provider = providerFactory?.(fallback) - - expect(extractHashPrefix("See #D1", 7)).toBe("#D1") - await expect( - provider?.getSuggestions(["See #D1"], 0, 7, {} as never), - ).resolves.toEqual({ - prefix: "#D1", - items: [ - { - value: "#D12", - label: "#D12 Command containment", - description: "Blocks branchy Pi flows", - }, - ], - }) - expect( - provider?.applyCompletion( - ["See #D"], - 0, - 6, - { value: "#D12", label: "#D12 Command containment" }, - "#D", - ), - ).toEqual({ lines: ["See #D12"], cursorLine: 0, cursorCol: 8 }) - }) - it("loads the elicit operational-mode tool policy from product code", async () => { const events: Record<string, (event: never) => unknown> = {} const activeTools: string[][] = [] diff --git a/src/pi-extensions/mention-autocomplete.test.ts b/src/pi-extensions/mention-autocomplete.test.ts new file mode 100644 index 00000000..7e1ec0a2 --- /dev/null +++ b/src/pi-extensions/mention-autocomplete.test.ts @@ -0,0 +1,143 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent" + +import { describe, expect, it } from "vitest" + +import { + extractHashPrefix, + registerBrunchMentionAutocomplete, + type GraphMentionSource, +} from "./mention-autocomplete.js" + +describe("Brunch mention autocomplete", () => { + it("adds graph mention prompt guidance", async () => { + const beforeAgentStart: Array<( + event: { systemPrompt: string }, + ctx: FakeExtensionContext, + ) => Promise<unknown> | unknown> = [] + + registerBrunchMentionAutocomplete({ + on: (event: string, handler: never) => { + if (event === "before_agent_start") beforeAgentStart.push(handler) + }, + } as never) + + const promptUpdates = await Promise.all( + beforeAgentStart.map((handler) => + Promise.resolve(handler({ systemPrompt: "base" }, fakeContext())), + ), + ) + + expect( + promptUpdates.some( + (update) => + typeof update === "object" && + update !== null && + "systemPrompt" in update && + String(update.systemPrompt).includes("Brunch graph mention handles"), + ), + ).toBe(true) + }) + + it("registers graph-code mention autocomplete without fixture tag JSON", async () => { + let providerFactory: (( + current: FakeAutocompleteProvider, + ) => FakeAutocompleteProvider) | undefined + const source: GraphMentionSource = { + listMentionCandidates: () => [ + { + code: "D12", + title: "Command containment", + description: "Blocks branchy Pi flows", + plane: "design", + }, + { code: "I9", title: "Mention ledger", plane: "intent" }, + ], + } + + registerBrunchMentionAutocomplete( + { + on: (event: string, handler: (event: never, ctx: never) => unknown) => { + if (event === "session_start") { + void handler({} as never, { + ui: { + addAutocompleteProvider: (factory: typeof providerFactory) => { + providerFactory = factory + }, + }, + } as never) + } + }, + } as never, + source, + ) + + const fallback: FakeAutocompleteProvider = { + getSuggestions: async () => ({ items: [], prefix: "" }), + applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }), + shouldTriggerFileCompletion: () => true, + } + const provider = providerFactory?.(fallback) + + expect(extractHashPrefix("See #D1", 7)).toBe("#D1") + await expect( + provider?.getSuggestions(["See #D1"], 0, 7, {} as never), + ).resolves.toEqual({ + prefix: "#D1", + items: [ + { + value: "#D12", + label: "#D12 Command containment", + description: "Blocks branchy Pi flows", + }, + ], + }) + expect( + provider?.applyCompletion( + ["See #D"], + 0, + 6, + { value: "#D12", label: "#D12 Command containment" }, + "#D", + ), + ).toEqual({ lines: ["See #D12"], cursorLine: 0, cursorCol: 8 }) + }) +}) + +function fakeContext(): FakeExtensionContext { + return { + sessionManager: { + getEntries: () => [], + } as unknown as FakeExtensionContext["sessionManager"], + ui: {} as never, + } +} + +type FakeExtensionContext = Pick<ExtensionContext, "sessionManager"> & { + ui: unknown +} + +interface FakeAutocompleteItem { + value: string + label: string +} + +interface FakeAutocompleteProvider { + getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: never, + ): Promise<unknown> + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: FakeAutocompleteItem, + prefix: string, + ): unknown + shouldTriggerFileCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + ): boolean +} From 308f7437b96e8e09c6b9542bc4a9708a4c480662 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:30:13 +0200 Subject: [PATCH 090/164] Expose chrome footer telemetry projection --- src/pi-extensions/chrome.test.ts | 76 ++++++++++---------------------- src/pi-extensions/chrome.ts | 35 +++++++++++++-- 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/src/pi-extensions/chrome.test.ts b/src/pi-extensions/chrome.test.ts index 1abc382a..21bfa8c2 100644 --- a/src/pi-extensions/chrome.test.ts +++ b/src/pi-extensions/chrome.test.ts @@ -12,6 +12,7 @@ import { formatChromeWidgetLines, formatContextGauge, formatTokenCount, + projectBrunchChromeFooterLines, renderBrunchChrome, sanitizeChromeStatuses, } from "./chrome.js" @@ -139,61 +140,31 @@ describe("Brunch chrome projection", () => { expect(alignChromeColumns("left", "right", 14)).toBe("left right") }) - it("renders live footer telemetry and foreign statuses without publishing a chrome status key", async () => { - let footerFactory: unknown - const calls: FakeUiCall[] = [] - const ui: FakeExtensionUi = { - setHeader: (...args: unknown[]) => - calls.push({ method: "setHeader", args }), - setFooter: (factory: unknown) => { - footerFactory = factory - calls.push({ method: "setFooter", args: [factory] }) - }, - setStatus: (...args: unknown[]) => - calls.push({ method: "setStatus", args }), - setWidget: (...args: unknown[]) => - calls.push({ method: "setWidget", args }), - setWorkingIndicator: (_options) => {}, - setTitle: (...args: unknown[]) => - calls.push({ method: "setTitle", args }), - notify: (_message: string, _type?: "info" | "warning" | "error") => {}, - } - - renderBrunchChrome(ui, { - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - runtime: { - bundle: "elicit-default", - role: "elicitor", - model: "claude-sonnet", - thinking: "medium", + it("projects footer telemetry and foreign statuses without publishing a chrome status key", async () => { + const footer = projectBrunchChromeFooterLines( + { + cwd: "/tmp/project", + spec: { id: "spec-1", title: "Spec One" }, + session: { id: "session-1", label: "Interview #1" }, + phase: "elicitation", + chatMode: "responding-to-elicitation", + runtime: { + bundle: "elicit-default", + role: "elicitor", + model: "claude-sonnet", + thinking: "medium", + }, + contextUsage: { usedTokens: 1024, maxTokens: 2048 }, }, - contextUsage: { usedTokens: 1024, maxTokens: 2048 }, - }) - - const footerRenderer = footerFactory as ( - tui: unknown, - theme: unknown, - footerData: unknown, - ) => { render: (width: number) => string[] } - const component = footerRenderer( - { requestRender: () => {} }, - { fg: (_tone: string, value: string) => value }, { - getGitBranch: () => "main", - getExtensionStatuses: () => - new Map([ - ["brunch.reviewer", "reviewer queued"], - ["brunch.chrome", "should not echo"], - ]), - getAvailableProviderCount: () => 2, - onBranchChange: () => () => {}, + gitBranch: "main", + statuses: new Map([ + ["brunch.reviewer", "reviewer queued"], + ["brunch.chrome", "should not echo"], + ]), }, - ) - const footer = component.render(200).join("\n") + 200, + ).join("\n") expect(footer).toContain("Spec One") expect(footer).toContain("Interview #1") @@ -203,7 +174,6 @@ describe("Brunch chrome projection", () => { expect(footer).toContain("[█████░░░░░] 1,024/2,048 tokens (50%)") expect(footer).toContain("reviewer queued") expect(footer).not.toContain("should not echo") - expect(calls.map((call) => call.method)).not.toContain("setStatus") }) it("renders Brunch chrome through one wrapper over Pi UI calls", async () => { diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index 59788667..941a65ca 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -28,6 +28,11 @@ export interface BrunchChromeBuildState { dev?: string } +export interface BrunchChromeFooterTelemetry { + gitBranch?: string | null + statuses?: ReadonlyMap<string, string> +} + export interface BrunchChromeState extends WorkspaceSessionChromeState { session: { id: string @@ -66,8 +71,25 @@ export function formatBrunchChromeFooterLines( footerData?: BrunchChromeFooterData, width?: number, ): string[] { - const statuses = sanitizeChromeStatuses(footerData?.getExtensionStatuses()) - const branch = footerData?.getGitBranch() + return projectBrunchChromeFooterLines( + chrome, + footerData === undefined + ? undefined + : { + gitBranch: footerData.getGitBranch(), + statuses: footerData.getExtensionStatuses(), + }, + width, + ) +} + +export function projectBrunchChromeFooterLines( + chrome: BrunchChromeState, + telemetry?: BrunchChromeFooterTelemetry, + width?: number, +): string[] { + const statuses = sanitizeChromeStatuses(telemetry?.statuses) + const branch = telemetry?.gitBranch const identity = `${formatChromeIdentity(chrome)}${ branch ? ` · branch: ${branch}` : "" }` @@ -153,7 +175,14 @@ export function renderBrunchChrome( const unsubscribe = footerData.onBranchChange(() => tui.requestRender()) return { render: (width: number) => - formatBrunchChromeFooterLines(chrome, footerData, width), + projectBrunchChromeFooterLines( + chrome, + { + gitBranch: footerData.getGitBranch(), + statuses: footerData.getExtensionStatuses(), + }, + width, + ), invalidate: () => {}, dispose: unsubscribe, } From b950756cb72d7fd487bc6d055bb85e0fe71e7edc Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:31:06 +0200 Subject: [PATCH 091/164] Narrow aggregate chrome exports --- src/brunch-tui.ts | 4 ++-- src/pi-extensions.ts | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 39de25a1..bf2be7c5 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -28,10 +28,10 @@ export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, createBrunchPiExtensionShell, - formatBrunchChromeHeaderLines, - formatChromeWidgetLines, + projectBrunchChromeFooterLines, renderBrunchChrome, type BrunchChromeCoherenceVerdict, + type BrunchChromeFooterTelemetry, type BrunchChromeStage, type BrunchChromeState, type BrunchChromeWorkerStatus, diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 6e4cbdb7..1cdc435b 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -54,17 +54,11 @@ export { type ResolvedBrunchAgentState, } from "./pi-extensions/operational-mode.js" export { - alignChromeColumns, chromeStateForWorkspace, - formatBrunchChromeFooterLines, - formatBrunchChromeHeaderLines, - formatChromeIdentity, - formatChromeWidgetLines, - formatContextGauge, - formatTokenCount, + projectBrunchChromeFooterLines, renderBrunchChrome, - sanitizeChromeStatuses, type BrunchChromeCoherenceVerdict, + type BrunchChromeFooterTelemetry, type BrunchChromeStage, type BrunchChromeState, type BrunchChromeUi, From 172e27a856b739a5d9e5200e8c640313849c3ef0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:31:45 +0200 Subject: [PATCH 092/164] Hide fixture mention exports --- src/pi-extensions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 1cdc435b..a74934e6 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -29,8 +29,6 @@ import { export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" export { - FIXTURE_GRAPH_MENTION_SOURCE, - extractHashPrefix, registerBrunchMentionAutocomplete, type GraphMentionCandidate, type GraphMentionSource, From 135b53f19a4760c2dc35fe80271b886aefc2b150 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:33:00 +0200 Subject: [PATCH 093/164] Prune private chrome helper exports --- src/pi-extensions/chrome.test.ts | 36 ++-------------------------- src/pi-extensions/chrome.ts | 41 +++----------------------------- 2 files changed, 5 insertions(+), 72 deletions(-) diff --git a/src/pi-extensions/chrome.test.ts b/src/pi-extensions/chrome.test.ts index 21bfa8c2..a13da6a7 100644 --- a/src/pi-extensions/chrome.test.ts +++ b/src/pi-extensions/chrome.test.ts @@ -4,17 +4,11 @@ import { describe, expect, it } from "vitest" import type { WorkspaceSessionReadyState } from "../workspace-session-coordinator.js" import { - alignChromeColumns, chromeStateForWorkspace, - formatBrunchChromeFooterLines, formatBrunchChromeHeaderLines, - formatChromeIdentity, formatChromeWidgetLines, - formatContextGauge, - formatTokenCount, projectBrunchChromeFooterLines, renderBrunchChrome, - sanitizeChromeStatuses, } from "./chrome.js" describe("Brunch chrome projection", () => { @@ -65,7 +59,7 @@ describe("Brunch chrome projection", () => { "runtime: not reported", "spec: Spec One · session: Interview #1 · phase: elicitation", ]) - expect(formatBrunchChromeFooterLines(state)).toEqual([ + expect(projectBrunchChromeFooterLines(state)).toEqual([ "runtime: not reported · build: not reported", "context: not reported", "state: responding-to-elicitation · coherence: unknown · worker: not reported", @@ -102,7 +96,7 @@ describe("Brunch chrome projection", () => { coherence: "needs_review" as const, } - expect(formatBrunchChromeFooterLines(state)).toEqual([ + expect(projectBrunchChromeFooterLines(state)).toEqual([ "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", "context: [█████░░░░░] 1,024/2,048 tokens (50%)", "state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued", @@ -114,32 +108,6 @@ describe("Brunch chrome projection", () => { ) }) - it("provides reusable chrome formatting helpers", () => { - expect(formatTokenCount(999)).toBe("999") - expect(formatTokenCount(1536)).toBe("1.5k") - expect(formatContextGauge({ usedTokens: 1024, maxTokens: 2048 })).toBe( - "[█████░░░░░] 1,024/2,048 tokens (50%)", - ) - expect( - sanitizeChromeStatuses( - new Map([ - ["brunch.chrome", "ignored"], - ["brunch.reviewer", "reviewer queued"], - ]), - ), - ).toEqual(["reviewer queued"]) - expect( - formatChromeIdentity({ - cwd: "/tmp/project", - spec: { id: "spec-1", title: "Spec One" }, - session: { id: "session-1", label: "Interview #1" }, - phase: "elicitation", - chatMode: "responding-to-elicitation", - }), - ).toBe("spec: Spec One · session: Interview #1") - expect(alignChromeColumns("left", "right", 14)).toBe("left right") - }) - it("projects footer telemetry and foreign statuses without publishing a chrome status key", async () => { const footer = projectBrunchChromeFooterLines( { diff --git a/src/pi-extensions/chrome.ts b/src/pi-extensions/chrome.ts index 941a65ca..97731718 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/pi-extensions/chrome.ts @@ -50,12 +50,6 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { export type BrunchChromeUi = Pick<ExtensionUIContext, "setFooter" | "setHeader" | "setWidget" | "setTitle"> -interface BrunchChromeFooterData { - getGitBranch(): string | null - getExtensionStatuses(): ReadonlyMap<string, string> - onBranchChange(callback: () => void): () => void -} - export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, ): string[] { @@ -66,23 +60,6 @@ export function formatBrunchChromeHeaderLines( ] } -export function formatBrunchChromeFooterLines( - chrome: BrunchChromeState, - footerData?: BrunchChromeFooterData, - width?: number, -): string[] { - return projectBrunchChromeFooterLines( - chrome, - footerData === undefined - ? undefined - : { - gitBranch: footerData.getGitBranch(), - statuses: footerData.getExtensionStatuses(), - }, - width, - ) -} - export function projectBrunchChromeFooterLines( chrome: BrunchChromeState, telemetry?: BrunchChromeFooterTelemetry, @@ -115,11 +92,11 @@ export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { ] } -export function formatChromeIdentity(chrome: BrunchChromeState): string { +function formatChromeIdentity(chrome: BrunchChromeState): string { return `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}` } -export function sanitizeChromeStatuses( +function sanitizeChromeStatuses( statuses: ReadonlyMap<string, string> | undefined, ): string[] { return [...(statuses ?? new Map())] @@ -129,19 +106,7 @@ export function sanitizeChromeStatuses( .map(([, value]) => value.trim()) } -export function formatTokenCount(tokens: number): string { - const normalized = Math.max(0, tokens) - if (normalized < 1000) return String(normalized) - return `${(normalized / 1000).toFixed(1)}k` -} - -export function formatContextGauge( - usage: BrunchChromeContextUsage | undefined, -): string { - return formatContextUsage(usage) -} - -export function alignChromeColumns( +function alignChromeColumns( left: string, right: string, width: number, From 3357e10a7f51e5e0e0ef9e15fb6a82275885e9f4 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 11:26:02 +0200 Subject: [PATCH 094/164] big disambiguation sync re spec and session state, grades, postures, etc --- HANDOFF.md | 184 ++++++++++++++++++ archive/docs/archive/PLAN_HISTORY.md | 204 ++++++++++++++++++++ docs/archive/PLAN_HISTORY.md | 272 +++++++-------------------- memory/PLAN.md | 158 +++++----------- memory/SPEC.md | 127 +++++++------ 5 files changed, 574 insertions(+), 371 deletions(-) create mode 100644 HANDOFF.md create mode 100644 archive/docs/archive/PLAN_HISTORY.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..859e9b40 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,184 @@ +# Handoff + +> Generated by `ln-handoff` at 2026-05-27T18:06:40Z. Read this file to resume work. +> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. + +## Goal + +Finish FE-744's Pi UI extension-pattern proof with enough evidence to trust structured-question RPC fallback and projection, then decide whether structured-question refinements should become a new plan item before returning to graph-data-plane work. + +## Session State + +- **Last completed skill**: `ln-refactor` — planned the proof/refactor sequence for structured-question RPC sufficiency and projection findings; the builder reports completing all refactor commits and deleting `memory/REFACTOR.md`. +- **Current skill**: `ln-handoff` — capturing state for a new thread. +- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → refactor → handoff` +- **Handoff trigger**: user requested a handoff after the builder completed the structured-question proof/refactor queue. + +## In-flight work + +> CRITICAL: These artifacts exist only in the prior conversation, not on disk. +> Reproduce them here with full fidelity. + +### 2026-05-27 late update — chrome recovery was misinterpreted + +After the chrome/mention refactor queue was completed, the user manually inspected the persistent Brunch chrome and found it still looks like a plain diagnostic dump, not the intended recovered Brunch chrome. Screenshot supplied by user: + +- `/Users/lunelson/Library/Application Support/CleanShot/media/media_L5qbnxixR0/CleanShot 2026-05-27 at 21.34.52@2x.png` + +Important correction: the previous “Restore rich Brunch chrome projection” work recovered a **data model / semantic rows** (`runtime`, `context`, `state`, `spec/session`, branch/status telemetry), but **not the actual visual chrome implementation** that had existed in `.pi/extensions/brunch-chrome.ts`. The recovery was incorrectly interpreted as semantic richness rather than restoring the actual code and visual treatment. + +The next agent must go back to git history for the retired probe file and recover the **actual implementation**, especially the code using Pi/TUI theme tokens (`ctx.theme` / themed colors / branded layout), not just the plain string-array projection. Relevant history: + +- `.pi/extensions/brunch-chrome.ts` existed before retirement. +- `git log --oneline -- .pi/extensions/brunch-chrome.ts` shows: + - `6c2e3823 header and footer looking reasonable` + - `68520f66 wip on brunch pi extensions` + - `b1721c22 FE-744 retire pi probe runtime` +- Start by inspecting `git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and related nearby commits, then port the real visual code into `src/pi-extensions/chrome.ts` / a private chrome submodule as appropriate. + +Do **not** repeat the mistake of only asserting data-bearing lines. Add an oracle that would fail for the current plain dump — e.g. ANSI/themed snapshot, renderer-level assertion over theme-token usage, or a manual screenshot/runbook check paired with stable structural assertions. The target is the actual Brunch chrome UX, not merely “more fields in the footer.” + +### Completed chrome/mention structural refactor queue + +The user asked to work through `memory/REFACTOR.md` in order, committing after each item. This was completed with the following commits: + +1. `e40f4530` — Move chrome behavior tests to chrome module +2. `93507ca1` — Move mention autocomplete tests to feature module +3. `68f51402` — Expose chrome footer telemetry projection +4. `c6ed3354` — Narrow aggregate chrome exports +5. `ccc9da01` — Hide fixture mention exports +6. `e1f896a7` — Prune private chrome helper exports + +Verification was run before each commit (`npm run verify` passed each time). These commits improved module ownership/export shape but did **not** fix the visual chrome regression described above. + +### Completed refactor sequence from `memory/REFACTOR.md` + +The temporary refactor plan targeted two review findings: + +1. RPC sufficiency was not witnessed against a live Pi RPC process; fake `ctx.ui.editor` tests were not enough. +2. Elicitation-exchange projection still treated typed structured-question tool results as prompt-side entries rather than response-side entries. + +Builder reports all refactor items were completed in order and committed: + +1. `a3530c9b` — Characterize structured question terminal details +2. `0a9fdd15` — Prove structured question RPC editor fallback +3. `fc797e56` — Expose structured question RPC proof test +4. `b8916d33` — Project terminal structured question responses +5. `c49f3303` — Cover structured question JSONL projection +6. `97e66b8e` — Reconcile structured question proof evidence +7. `5035485b` — Retire structured question refactor queue + +The queue is exhausted; `memory/REFACTOR.md` and `memory/CARDS.md` do not exist. + +### Remaining live FE-744 work + +The canonical `memory/PLAN.md` current pointer now says the spec/session picker correction and structured-question adapter proof are complete through: + +- hierarchical spec/session picker; +- TypeBox-validated `workspace.selectionState` / `workspace.activate`; +- startup no-resume pty oracle; +- structured-question schema/builder; +- TUI/editor adapters; +- live Pi RPC editor fallback at the adapter layer via `npm run test:structured-question-rpc-proof`; +- response-side elicitation-exchange projection for terminal structured-question results. + +Remaining planned FE-744 seam: **Brunch product-surface relay semantics** for pending structured elicitation. The raw/private Pi RPC adapter proof is now stronger, but public Brunch clients still should not coordinate raw Pi RPC and Brunch RPC as two product APIs. `memory/PLAN.md` says: “Continue the structured-question proof with Brunch product-surface relay semantics before returning to `graph-data-plane`.” + +### User-stated pending refinements + +The user said: “I have some refinements on the whole structured question front, which perhaps need to be punted to a new plan item.” These refinements have **not** yet been captured in SPEC/PLAN because the user has not enumerated them. Next thread should ask for those refinements or route through `ln-grill`/`ln-spec` before scoping further structured-question UX changes. + +Likely interpretation: do **not** immediately keep building structured-question behavior beyond the proof seam until the user has explained the refinements. They may affect whether the remaining product relay belongs inside FE-744 or a new frontier/plan item. + +### Review findings + +> ALL findings from ln-review / ln-judo-review, not just the one being acted on. + +| # | Finding | Status | Implications | +| --- | --- | --- | --- | +| 1 | Obsolete flat picker API remained exported/tested alongside hierarchical spec/session model. | addressed | Builder committed `308b3d34`; old flat API should be gone. | +| 2 | `WorkspaceSwitchDecision` lexicon was stale because it sounded like changing workspaces rather than activating spec/session. | addressed | Builder committed rename work in the judo-fix queue; check current symbols if continuing in this seam. | +| 3 | RPC activation parsing was ad-hoc/cast-heavy and violated the new TypeBox boundary direction. | addressed | Builder committed `76fadb32`; `workspace.activate` uses TypeBox-backed parsing. | +| 4 | `createRpcHandlers` accepted partial coordinator capabilities while registering methods that required them. | addressed | Builder committed `76fadb32`; handler factory now requires explicit activation capability. | +| 5 | Picker dev build tag styling regressed by folding dev metadata into the accent version line. | addressed | Builder committed `308b3d34`; verify visually if touching picker header. | +| 6 | Persistent Brunch chrome regressed to minimal cwd/spec/session dump rather than the intended branded/theme-token chrome. | **not addressed** | Earlier commits (`13464e68`, then the chrome refactor queue) restored semantic rows/data projection only. The user confirmed the live chrome still looks wrong. Recover the actual historical `.pi/extensions/brunch-chrome.ts` implementation from git (especially `6c2e3823`) and port the real themed code, not just the data model. | +| 7 | RPC sufficiency for structured-question editor fallback was not live-proven. | addressed for adapter layer | Builder committed `0a9fdd15`/`fc797e56`; `npm run test:structured-question-rpc-proof` now live-proves Pi RPC editor fallback at adapter layer. Public Brunch product relay remains pending. | +| 8 | Elicitation-exchange projection did not classify terminal structured-question tool results as response-side entries. | addressed | Builder committed `b8916d33`/`c49f3303`; SPEC I23 marks projection tests covered for terminal structured-question results while ordinary tool results remain prompt-side. | +| 9 | Structured-question interaction model likely needs user refinements. | deferred | Needs fresh user input; probably route through `ln-grill` / `ln-spec` or `ln-plan` depending on how durable the refinements are. | +| 10 | Process mismatch: builder claimed clean state, but this session observed dirty SPEC changes at least once. | still watch | Current `git status -sb` shows `memory/SPEC.md` modified. Do not overwrite/revert without checking whether this came from parallel work. | + +### Diagnostic evidence + +- `npm run check && npx vitest --run src/structured-question.test.ts src/pi-extensions/structured-question.test.ts src/elicitation-exchange.test.ts src/rpc.test.ts` passed before the refactor proof, but that only covered helper/fake RPC behavior. +- The refactor proof added `npm run test:structured-question-rpc-proof`, and final `npm run verify` passed with `src/structured-question-rpc-proof.test.ts` included. +- Final `npm run verify` output in this session: 21 test files, 194 tests passed; build passed. +- The live RPC proof test reported: `structured-question RPC proof > round-trips an editor fallback through Pi RPC extension UI` passed. +- SPEC I23 now says structured-question coverage is partial but stronger: schema/builder, TUI adapter, JSON-over-editor helper, live Pi RPC editor proof, and response-side projection are covered; **Brunch public product relay remains pending**. +- Pi examples consulted as basis for the proof seam: `examples/extensions/question.ts`, `examples/extensions/questionnaire.ts`, `examples/extensions/rpc-demo.ts`, and `examples/rpc-extension-ui.ts` from the installed `pi-coding-agent` package. + +## Decisions and assumptions + +| Item | Type | Status | Source | +| ---- | ---- | ------ | ------ | +| Workspace means cwd/root scope, not a user-created object. | decision | persisted | `memory/SPEC.md` D11-L / Lexicon | +| Spec/session selection is hierarchical and transport-specific. | decision | persisted | `memory/SPEC.md` D36-L | +| RPC/headless startup must expose structured selection/activation, not TUI picker code. | invariant/decision | persisted | `memory/SPEC.md` D36-L / I22-L | +| Structured-question details may be canonical structured response payload for basic questions. | decision | persisted | `memory/SPEC.md` D37-L / I23-L | +| JSON-over-editor is a private Pi RPC compatibility seam, not a second product API. | decision | persisted | `memory/SPEC.md` D38-L | +| Terminal structured-question tool results are response-side projection entries; ordinary tool results remain prompt-side. | decision | persisted by code/tests and SPEC I23 evidence | refactor commits + `memory/SPEC.md` I23 | +| Public Brunch product relay for pending structured elicitation is still missing. | assumption/open work | persisted in PLAN current pointer | `memory/PLAN.md` FE-744 current execution pointer | +| User has structured-question UX refinements that may need a new plan item. | assumption/open work | volatile | conversation only; ask user next | +| Dirty `memory/SPEC.md` currently contains subagent/side-task decision edits not part of this handoff's main thread. | assumption/open process state | volatile/current filesystem | current git diff; inspect before acting | + +## Repo state + +- **Branch**: `ln/fe-744-pi-ui-extension-patterns` +- **Recent commits**: + - `5035485b` Retire structured question refactor queue + - `97e66b8e` Reconcile structured question proof evidence + - `c49f3303` Cover structured question JSONL projection + - `b8916d33` Project terminal structured question responses + - `fc797e56` Expose structured question RPC proof test + - `0a9fdd15` Prove structured question RPC editor fallback + - `a3530c9b` Characterize structured question terminal details +- **Dirty files at handoff time**: + - `memory/SPEC.md` is modified relative to HEAD. The visible diff concerns side-task/subagent decisions (`D15-L`, new `D44-L`) and appears unrelated to the structured-question proof. Confirm with the user/parallel thread before committing, reverting, or editing it. + - `HANDOFF.md` will be untracked/modified after this handoff write. +- **Test status**: `npm run verify` passed in this session after the refactor commits. 21 test files, 194 tests passed, build passed. + +## Artifact status + +| Artifact | Exists | Current vs conversation | +| --- | --- | --- | +| `memory/SPEC.md` | yes | mostly current for structured-question proof; currently dirty with unrelated-looking subagent edits that need inspection | +| `memory/PLAN.md` | yes | current for FE-744 structured-question proof; current pointer says product-surface relay remains | +| `memory/CARDS.md` | no | exhausted/deleted | +| `memory/REFACTOR.md` | no | exhausted/deleted | +| `HANDOFF.md` | yes after this write | current volatile transfer state | + +## Next steps + +1. **Start with `ln-consult` or `ln-grill` on the user's structured-question refinements.** Ask the user to enumerate the refinements before scoping more structured-question work; they may change whether the remaining product relay is FE-744 continuation or a new frontier item. +2. **Inspect the dirty `memory/SPEC.md` subagent/side-task edits before touching planning docs.** Do not revert or commit them blindly; they may be from parallel work. +3. **If refinements do not change the architecture, run `ln-scope` for the remaining FE-744 product-surface relay semantics.** The scope should distinguish private Pi RPC adapter proof (now done) from public Brunch pending-elicitation methods/events (still missing). +4. **If refinements change durable product semantics, route to `ln-spec` / `ln-plan` first.** Structured-question interaction model changes should not be hidden inside a follow-up build slice. +5. **Before tying off FE-744, run an outer/manual TUI walkthrough.** Chrome richness, spec/session picker feel, structured-question TUI feel, and final session/chrome state still benefit from qualitative smoke beyond the automated proof. + +## Retirement rule + +- Delete or overwrite this file once the volatile state above is absorbed into `memory/SPEC.md`, `memory/PLAN.md`, code, or a newer `HANDOFF.md`. + +## Open questions + +- What are the user's structured-question refinements? +- Should public Brunch product-surface relay semantics stay inside FE-744, or become a separate PLAN frontier/item? +- Are the dirty `memory/SPEC.md` subagent edits intentional parallel work, and should they be committed separately? +- Should `test:structured-question-rpc-proof` remain part of full `npm run test` permanently, or become a runbook/targeted proof if it proves host-sensitive? + +## Resume prompt + +Paste this into a new session: + +> Read `HANDOFF.md` in the workspace root for this work area. It contains the full state of FE-744 structured-question proof work. +> The immediate next step is: ask me for my structured-question refinements and decide whether they require `ln-spec`/`ln-plan` before more build work. +> Start by inspecting the current dirty `memory/SPEC.md` diff, then use `ln-grill` or `ln-consult` to route the structured-question refinement discussion. diff --git a/archive/docs/archive/PLAN_HISTORY.md b/archive/docs/archive/PLAN_HISTORY.md new file mode 100644 index 00000000..4e829b36 --- /dev/null +++ b/archive/docs/archive/PLAN_HISTORY.md @@ -0,0 +1,204 @@ +# Plan History + +Archived from the legacy phase-ledger form of `memory/PLAN.md` on 2026-04-14 during FE-584. + +## Completed Phases + +- 2026-04-14 — **Phase 1: Foundation** — walking skeleton proved SDK → SSE → React end to end, then SQLite persistence landed. +- 2026-04-14 — **Phase 2: Architecture** — turn-tree schema, Drizzle core extraction, and multi-project routing became the durable app spine. +- 2026-04-14 — **Phase 3: Interview Engine** — rich chat UI, structured scope interview, parts-based persistence, observer extraction, and the AI SDK pivot all shipped. +- 2026-04-14 — **Phase 4: Interaction + Knowledge Foundations** — streaming fixes, flexible turn responses, generic knowledge persistence, and phase-aware observer widening landed. +- 2026-04-14 — **Phase 5: Mode Closure + Full Interview** — explicit phase outcomes, canonical knowledge model, design mode, requirements review, and criteria review all closed the full interview loop. +- 2026-04-14 — **Phase 6: Readiness Surfaces + Export** — dashboard workflow state, knowledge workspace review surface, export, and richer fixture seeding shipped. +- 2026-04-14 — **Phase 7: Distribution + Brownfield + UI Alignment** — UI alignment, shiki/debug cleanup, local-first `npx` distribution, and brownfield kickoff all shipped. +- 2026-04-14 — **Phase 10: Route Ownership Refactor** — router seam characterization, route wrapper extraction, file-route infrastructure, and final route-directory consolidation all completed. +- 2026-04-14 — **Phase 11: Routing & Layout Refactor** — directory-based routing, three layout shells, per-phase views, entity sidebar relocation, and graph-view stub all completed. + +## Completed Hardening / Meta Work + +- 2026-04-14 — **Ad-hoc: Typing Hygiene** — Zod was removed from non-LLM boundaries while preserving LLM and HTTP validation seams. +- 2026-04-14 — **Phase 9 completed items** — launcher/runtime guard hardening (14b), trusted fixture hardening (16a), and capture-backed golden corpus work (16b) all shipped. + +- 2026-04-14 — **Phase terminal staging and auto-present current turn** — open phases auto-initiate the current turn, answered-turn replay filters control/closure artifacts, closed phases end with handoff/completion card. + +## Recent Frontier Archives + +- 2026-05-15 — **Side-chat persistence V4a retired as an independent frontier** — persistent side-chat history was absorbed into Conversational Workspace Runtime Track 2. After the chat/thread reconciliation, the near-term runtime uses durable secondary chats over the existing chat/turn substrate; schema-level `thread` is deferred until chat/turn proves insufficient. +- 2026-05-08 — **Side-chat V3.0 hard-impact cascade** — FE-674 / PR #115 + #116 + #117 shipped hard-impact cascade through `reconciliation_need`, Pending review listing, and idempotent resolve. Verified: `npm run verify` (1063 tests, 0 lint warnings). Watch moved forward to V3.1 / reconciliation-runtime walkthroughs. +- 2026-04-27 — **Runtime JSON payload hardening (FE-625)** — Express API parsing now accepts chat-sized request bodies above the default parser ceiling and returns a JSON 413 response instead of Express HTML when a payload exceeds the app limit. Verified: `npm run verify`. Watch: if real chat requests still exceed the 5 MB limit, investigate client history / tool-result pruning rather than only raising the ceiling. +- 2026-04-24 — **Distribution hardening release path (FE-531)** — `package.json` now declares the Node 22+ engine floor, explicit shipped files, and public scoped publish config; `npm run release` drives release-it at repo root, rebuilds and dry-runs the packaged artifact, and documents npm auth prerequisites. Verified: `npm run verify`. Watch: CI trusted publishing is still intentionally out of scope. +- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — revised D115: the interviewer now chooses whether to include options per-question based on conversational trajectory; observer interprets selections as resonance (grounding) or commitment (design); ActiveQuestionCard has phase-aware submit gating and "none of the above" copy. +- 2026-04-22 — **Review revision card contract consistency retired** — acceptance now carries predecessor metadata across sparse regenerated review sets, regeneration context/prompt sources preserve reference codes + rationale + grounding refs + explicit `Added in revision` / `Revised` semantics, and criteria-phase active/replayed/pending review-card routes plus source-owned examples prove the same contract as requirements. +- 2026-04-23 — **Brownfield workspace-analysis grounding brief parity / proving frontier retired** — retired `Active → Track A — Interaction model → Brownfield workspace-analysis grounding brief parity / proving` after a real brownfield start confirmed that the grounding brief, paired question, and live activity chrome read as one coherent turn lifecycle rather than three disconnected states. Follow-on interaction-model work now shifts to reusing that same context-gathering seam beyond the opening grounding turn instead of continuing to treat startup parity as an open proof question. +- 2026-04-22 — **Specification runtime state-machine proving frontier retired** — retired `Active → Track B — Runtime / workflow ownership → Specification runtime state-machine proving` after landing a lightweight specification-scoped lifecycle seam for deferred observer capture. Structured grounding/design responses now unlock their successor turn as soon as interviewer generation is durable, observer capture runs afterward through turn-owned `/api/specifications/:id/turns/:turnId/observer-capture`, unfinished capture reseeds from durable turns after reload, and late completion stays attached to the answered turn rather than the current frontier. Current limitations were kept explicit instead of papered over: server dedupe is process-local, and deferred capture is not yet generalized beyond grounding/design structured responses. +- 2026-04-22 — **Query ownership remediation frontier retired** — retired `Active → Track A — Query ownership → Query ownership remediation` after automated route/query ownership coverage plus manual walkthrough validation across `brownfield-grounding-replay`, `issue-tracker-requirements-ready`, `issue-tracker-criteria-ready`, and `issue-tracker-all-phases-closed`. The retired frontier established one authoritative specification bundle domain, one separately invalidable entities domain, and a transcript subtree no longer rendered by an entity-subscribing layout component. Follow-on observer-backlog / review-revision issues were recorded as a separate planning concern rather than reopening this retired frontier. +- 2026-04-22 — **Query ownership sub-slices archived out of live recently-completed state** — the fake `core` / `turns` split collapsed into one authoritative specification bundle domain, entities remained the separately invalidable domain, `/specification/$id/` redirect plus route priming moved onto the same bundle-owned helper, and route/query ownership integration oracles proved entities invalidation does not remount the interview route while bundle invalidation and direct navigation still use one authoritative fetch path. +- 2026-04-21 — **Review per-item commenting and regeneration** — per-item comment toggles on review set items, structured `itemComments` payloads, version badges, revision cards, and prior-revision collapsing all landed under the accepted-review frontier. +- 2026-04-21 — **Turn-internal grounding cards** — grounding-card plus question-card stacking landed within one turn lifecycle, with observer capture treating the validated turn as one unit. +- 2026-04-18 — **Accepted-set authority cleanup and legacy review semantics retirement** — replaced `reviewStatus`-driven downstream behavior with accepted-set projections, removed review badges from the UI, rewired requirements / criteria review prompts and submission semantics around explicit full-set review actions, and updated seeded walkthrough fixtures so confirmed review sets replay through the same accepted-set seam used at runtime. +- 2026-04-18 — **Explicit review-action payload seam for full-set review turns** — requirements and criteria review turns now carry explicit `reviewActions` metadata in the persisted tool payload, the submit path validates and persists the matching explicit `reviewAction`, targeted `requirementReview` / `criterionReview` turn metadata and per-item response writes are retired, and seeded review fixtures replay through the same contract. +- 2026-04-18 — **Phase header close action now follows the force-close policy seam** — the routed interview header now shows `Close Phase` only when `getForceClosePhaseAction()` says force-close is actually available, so review proposal states no longer expose a contradictory invalid close path while design keeps the existing typed force-close command. +- 2026-04-18 — **Review-phase proposal cards use review-specific acceptance copy** — proposal-pending requirements and criteria states in the routed interview view now render review-specific accept-the-reviewed-set copy instead of the generic closure-proposal presenter, while the confirmation flow still submits the same typed closure command payload. +- 2026-04-18 — **Review-phase kickoff and recovery cards use review-specific copy** — requirements and criteria kickoff / recovery states in the routed interview view now describe candidate-set review flow instead of generic interview-step copy, while the existing proceed / continue action wiring stays unchanged. +- 2026-04-18 — **Review-specific closed-phase completion cards** — closed requirements and criteria phases now render through `ReviewPhaseCompletionCard` in the routed interview view, so review closure states use review-specific handoff/export copy while non-review phases keep the generic closed-state shell. +- 2026-04-18 — **Dedicated active review-turn presenter split** — active requirements and criteria frontier turns now render through a dedicated `ActiveReviewSetCard` path instead of the generic `ActiveQuestionCard`, while persisted active turns and streamed pending turns still submit the same whole-set review action payload and preserve the shared `ReviewSetCard` boundary. +- 2026-04-18 — **Shared review-card route cutover with preserved card structure** — requirements and criteria review turns now render through the same `ReviewSetCard` path in both persisted and streamed pending states, while the richer shared card structure (stats and per-item comment affordances) remains intact even though review authority stays at the whole-set accept/request-changes seam. +- 2026-04-16 — **Specification-first creation and workspace-owned grounding kickoff** — new-spec creation now asks only for the specification name, the grounding strategy choice moved into the grounding kickoff inside the workspace, and touched entry/workspace copy now uses specification/workspace language while internal `project` identifiers remain unchanged. +- 2026-04-16 — **Frontier lifecycle skeleton across open phases** — the open-phase seam now bottoms out in fixed kickoff turns, visible generation states, same-turn review accept-to-close progression, and exceptional recovery turns, closing the no-dead-state frontier tracked under D94. +- 2026-04-16 — **Persist explicit full-set review actions through response + fixture seams** — requirements/criteria review submissions now carry explicit persisted `reviewAction` semantics, server acceptance no longer depends on option copy, client review submissions include the action in transport, and manifest/corpus/synthetic fixture seams round-trip the full-set review action without modeling per-item review turns as the user interaction. +- 2026-04-16 — **Criteria review accept-to-close wiring** — accepting the criteria full-set review now marks the presented criterion set approved, closes criteria on the same durable turn, makes the workflow output-ready, and suppresses the stale review text from being forwarded into chat after workflow completion. +- 2026-04-16 — **Lightweight review turn v1 across requirements + criteria** — both review phases now use full-set review turns with stable item reference codes, one review note, explicit `Accept review` / `Request changes` actions, and accept-to-close progression into the next kickoff/output frontier. +- 2026-04-16 — **Criteria full-set review turn parity** — criteria gained the same full-set review prompt/context/UI seam as requirements, including current criterion inventory, stable criterion reference codes, one review note, and explicit `Accept review` / `Request changes` actions. +- 2026-04-16 — **Requirements review accept-to-close wiring** — accepting the requirements full-set review now marks the presented requirement set approved, closes requirements on the same durable turn, creates the criteria kickoff frontier, and suppresses the stale review text from being forwarded into criteria chat. +- 2026-04-16 — **Transcript parity for existing turn families** — persisted assistant-side replay now stores concise activity summaries instead of raw reasoning / tool parts, hydrated answered / frontier cards reuse the same activity-placeholder family as live transcript updates, and route invalidation no longer needs generic placeholder fallbacks for existing turn families. Done: `npm run verify`. Watch: manual reload / invalidation walkthrough still outstanding. +- 2026-04-16 — **DrawerCard-based question card family and generating-turn placeholder** — ordinary interview turns now render through dedicated question-card components: compact answered cards, expanded active cards, inline activity placeholders, and a skeleton-backed generating-turn placeholder, replacing the older generic turn-card treatment for question-turn replay and in-flight generation. +- 2026-04-15 — **Center pane sticky header and ChatScroll integration** — `InterviewView` now renders phase metadata and state-gated actions in the sticky center header, with `ChatScroll` as the route-owned transcript container. +- 2026-04-15 — **Knowledge sidebar grouping registry** — `EntitySidebar` now groups visible knowledge kinds behind the hard-coded display registry with compact `DrawerCard` items and stable reference-code display. +- 2026-04-15 — **Phase stepper sidebar** — `PhaseNavigationSidebar` now renders the sticky specification header, sequential phase timeline, and conditional Output row. +- 2026-04-15 — **Top bar and phase label canonicalization** — RouteRoot gained the canonical top bar and shared phase-label registry across dashboard, sidebar, transcript copy, and fixtures. +- 2026-04-15 — **Story-first turn-card refinement** — DrawerCard, question/knowledge detail cards, chat transcript story, and token scale canon landed as the presentational base for route integration. +- 2026-04-14 — **Turn-owned captured-item projection and trailing observer attachment** — answered turns project captured knowledge with stable reference codes and keep late observer completion attached to the originating turn. +- 2026-04-14 — **Turn-owned submit/interviewer-processing choreography** — active turns stay mounted through submit, lock inline during processing, and collapse only when the next state is ready. +- 2026-04-14 — **Workspace shell first honesty pass** — dashboard links became real, root/dashboard scrolling was fixed, future phases became visible-but-disabled, review phases gained distinct shell framing, and transcript replay shifted from user bubbles toward compact answered-turn cards plus control markers. +- 2026-04-14 — **Fixture-backed walkthrough workspace** — walkthrough-ready seed scenarios now front-load the public seed catalog, prove resume after re-open, and cover export-ready/manual-inspection states. + +## 2026-04-18 Sync Archive + +Archived out of `memory/PLAN.md` during `ln-sync` once the live frontier narrowed to a few active cleanup tails plus the next major projector work. + +- 2026-04-18 — **Runtime review turns now persist an explicit review-set payload derived from the current review inventory** — `src/server/interview.ts` can now synthesize `data-review-set` payloads for requirements and criteria from the current review inventory, and `src/server/app.ts` now persists that synthesized review set onto runtime review turns even when the model only emits the structured question itself. +- 2026-04-18 — **Accepted review now materializes only the persisted review-set items instead of blanket-accepting all project-wide review entities** — `src/server/db.ts` can now materialize requirement / criterion items from a persisted `data-review-set`, reusing matching existing rows when present, and `src/server/app.ts` now prefers that accepted-review path over blanket-linking every project-wide requirement / criterion row. +- 2026-04-18 — **Requirement and criterion durability authority is now explicit in shared code** — `src/shared/knowledge.ts` codified the lifecycle boundary: exploration kinds stay observer-captured, while `requirement` and `criterion` are review-authoritative and tied to accepted review phases. +- 2026-04-18 — **Observer prompt and output schema now derive from shared ontology policy** — `src/server/observer.ts` now builds its output schema from the canonical knowledge registry and derives phase bias / kind-semantics prompt text from shared ontology policy. +- 2026-04-18 — **Shared observer ontology policy now declares phase-valid kinds and kind semantics** — `src/shared/knowledge.ts` now exports one phase-by-phase observer ontology policy plus per-kind semantic-role text. +- 2026-04-18 — **Story, fixture, and demo samples now teach the canonical reference-code contract** — seeded review-set fixture scenarios and chat/review/turn-lifecycle story fixtures now derive visible knowledge codes from `createKnowledgeReferenceCode()`. +- 2026-04-18 — **Canonical reference-code prefixes now emit the intended short-form contract** — the shared knowledge registry now emits `G` / `T` / `CTX` / `CON` / `R` / `AC` / `D` / `A`, and UI fallback badges consume the shared prefix map. +- 2026-04-18 — **Reference-code test fixtures now derive from the shared generator instead of freezing literals** — high-churn UI/server/shared tests now call `createKnowledgeReferenceCode()` for incidental codes. +- 2026-04-18 — **Registry-owned reference-code prefixes now drive runtime code generation** — `src/shared/knowledge.ts` now stores the current reference-code prefix on each `knowledgeKindRegistry` entry and derives `createKnowledgeReferenceCode()` from registry metadata. +- 2026-04-18 — **Captured-item replay now projects through one collection-driven entity path** — `src/server/db.ts` now builds `captured_items` for replay by iterating the canonical project-wide entity collections through `knowledgeKindRegistry`. +- 2026-04-18 — **Decision and assumption transport schemas now derive from the canonical knowledge-item contract** — `src/shared/api-types.ts` now defines decision and assumption entity schemas by projecting `knowledgeItemSchema`. +- 2026-04-18 — **Decision and assumption entity reads now share one knowledge-item projection helper** — `src/server/db.ts` no longer uses bespoke `toDecision` / `toAssumption` adapters for entity projection. +- 2026-04-18 — **Active-path generic entity filtering now runs through one collection-driven helper** — `src/server/db.ts` now filters generic knowledge collections for active-path projection through a shared registry-driven helper. +- 2026-04-18 — **Shared kind lookup maps now drive both sidebar and server relationship projection** — `src/shared/knowledge.ts` now exports canonical `kind -> collectionKey` and `kind -> entityCollection` maps consumed by both UI and server projection seams. +- 2026-04-18 — **Project-wide generic entity projection now uses one shared knowledge-item path** — `src/server/db.ts` now projects `requirement` and `criterion` through the same generic knowledge-item helper used for the other canonical `knowledge_item` kinds. +- 2026-04-18 — **Shared ontology tuples now drive API kind enums and manifest collection mapping** — `src/shared/knowledge.ts` now exports canonical knowledge-kind / collection tuples plus `knowledgeCollectionKeyByKind`, and manifest seeding now consumes the shared kind→collection mapping. +- 2026-04-18 — **Non-compatibility `framing` references retired from active fixtures and test naming** — route infrastructure tests now distinguish canonical grounding routes from the legacy `/framing` redirect seam, server observer/app tests use canonical `context` naming, and the active issue-tracker manifest no longer describes criteria review in framing-era language. +- 2026-04-18 — **Shared requirement/criterion entity contracts dropped legacy `reviewStatus`** — `src/shared/api-types.ts` now exposes canonical requirement and criterion entities without `reviewStatus`, and client/sidebar/graph/interview fixtures now use the canonical read-model shape. +- 2026-04-18 — **Distinct review-phase UI rebuilt on accepted-set authority** — requirements and criteria now render through review-specific entry, active, replayed, proposal, and closed-state presenters, while the routed header close action follows the same force-close policy seam as the transcript. +- 2026-04-18 — **Canonical grounding route cut over with legacy framing redirect** — the first phase entered through `/grounding`, index/export/in-workspace navigation targeted the canonical grounding URL, the file-routed interview surface gained a dedicated `grounding.tsx` entry, and the legacy `/framing` route was reduced to a temporary redirect seam until full retirement. +- 2026-04-19 — **Legacy knowledge facade cleanup retired** — decision/assumption entity references unified on `knowledge_item`, dead legacy per-type schema/relationship tables were removed, and migration `0010_retire_legacy_knowledge_tables` made the canonical storage seam authoritative for boot, seeding, and projection. + +## 2026-04-19 Frontier Retirement Archive + +Archived out of `memory/PLAN.md` when the phase-transition / handoff frontier retired and the live plan advanced to naming normalization. + +- 2026-04-19 — **Legacy fixture side path removed; one TS-native fixture model remains** — walkthroughs, app tests, and observer probes now seed through direct TypeScript builders/helpers only. +- 2026-04-19 — **Force-close and close-confirmation now read as explicit in-flight control actions** — typed proposal confirmations and design force-close requests now surface control markers in the workspace stream while stale proposal/frontier projection is suppressed during submit. +- 2026-04-19 — **Closed-phase stream artifacts now read as explicit completion / handoff states** — accepted closure replay now renders through dedicated completion chrome instead of the generic workspace-state shell, and non-review closed-phase handoffs carry explicit handoff framing in the workspace stream. +- 2026-04-19 — **Public walkthrough seeds now prefer canonical review-turn helpers over stale late-phase scenario slices** — the public `requirements-ready` / `criteria-ready` seeds now resolve through the helper-backed full-set review seams, and the `issue-tracker-*` kickoff-ready later-phase walkthrough fixtures no longer slice stale per-item requirement drafts into public truth. Walkthrough regression coverage now asserts persisted `data-review-set` metadata on the seeded requirements / criteria review turns. +- 2026-04-19 — **D113 rejected auto-submit hardening landed under the handoff frontier** — specification-scoped auto phase intents now treat rejected submit promises as failed submissions instead of leaving the reachable phase stuck in lifecycle-owned generating state. The helper marks the current auto intent failed, re-projects the kickoff/recovery control, and still suppresses duplicate retry across rerender/remount until durable landing changes. +- 2026-04-19 — **D113 recovery auto-continue proving slice landed under the handoff frontier** — current reachable recovery restoration now auto-submits typed `phase-continue` through the same specification-scoped lifecycle helper as kickoff auto-present, suppresses duplicate submit across rerender/remount, and falls back to the projected recovery card after failed auto-submit instead of retry-looping. Router ownership of navigation plus durable read-model rendering remains unchanged, and no second durable workflow model was introduced. +- 2026-04-19 — **Narrow D113 lifecycle proving slice landed under the handoff frontier** — current reachable kickoff auto-present now runs through a specification-scoped lifecycle helper rather than a route-local effect, uses typed phase-entry intent submission only, suppresses duplicate submit across rerender/remount, and preserves router ownership of navigation plus durable read-model rendering. Grounding strategy kickoff on `scope` remained explicit pending the follow-on lifecycle slices. +- 2026-04-19 — **Turn-artifact persistence and brownfield replay hardening retired from active execution** — interviewer-owned review, grounding, activity, and closure artifacts now materialize through one server-owned seam, active review buttons submit by semantic action metadata instead of assumed option order, and the walkthrough catalog now includes a named brownfield reusable-grounding replay scenario. +- 2026-04-19 — **Interaction-family canonicalization retired from the active frontier** — the workspace stream now carries projected kickoff/recovery/handoff controls plus durable grounding/question/review turns as one canonical interaction family, and brownfield grounding no longer depends on a one-shot repo-summary question ritual. +- 2026-04-19 — **Merged-stream projector cutover retired from the active frontier** — project creation, phase confirmation / force-close, and requirements acceptance no longer pre-seed next-phase kickoff rows; resumed state, phase-entry kickoff, and closed-phase advancement now rely on derived `landing` plus durable workflow outcomes instead of fabricated control rows. + +## 2026-04-19 Sync Archive + +Archived out of `memory/PLAN.md` during `ln-sync` once the live frontier narrowed to the handoff frontier plus only the most recent retirement markers. + +- 2026-04-19 — **Brownfield grounding-card opening sequence landed** — brownfield kickoff now begins with a provisional grounding card instead of a repo-summary question handoff, grounding cards replay as their own workspace-stream turn family, and observer capture skips answered grounding-card continues while still advancing to the next interviewer turn. +- 2026-04-19 — **Transitional control-row runtime plumbing was retired behind derived landing + typed phase intent** — legacy control-row fabrication left production helpers, phase-intent chat submits now prepare interviewer turns directly from derived landing, control markers depend only on typed `data-phase-intent`, and projected kickoff/recovery controls submit through the shared phase-intent seam rather than branching on persisted control rows. +- 2026-04-19 — **Workspace-stream projection consolidated around one bottom-artifact and ordered artifact seam** — the routed interview surface moved to one discriminated bottom-artifact contract, stream ordering now projects through one client seam, projected control artifacts use control/artifact terminology, and review/control/handoff markers render inline in the ordered workspace stream. +- 2026-04-19 — **Landing derivation and seed-first walkthrough authority replaced canonical control rows** — specification-state reads stay projection-only, open-phase landing now derives from workflow state plus active-path turns, fixture/corpus/manifest seams normalize to derived landing, canonical transition fixtures seed durable authority instead of authoritative control rows, and projected kickoff strategy selection no longer requires a seeded kickoff turn row. +- 2026-04-19 — **Legacy route and knowledge-facade cleanup retired** — the legacy `/framing` compatibility route was removed from the active app surface, and the last legacy knowledge-facade/schema cleanup landed so runtime boot, seeding, and projection now flow only through the canonical knowledge seams. +- 2026-04-18 — **Runtime-generated review turns now persist their own interviewer-owned review metadata** — review turns can carry explicit `reviewActions` plus a durable `reviewSet`, and the happy path now replays and accepts that authoritative runtime metadata instead of relying on synthesized fallback inventory. + +## 2026-04-21 Live-plan cleanup archive + +Archived out of `memory/PLAN.md` when the active frontier moved from the mostly-landed dramaturgical hardening bundle to the next grounding interaction-model slices, and older completion entries were trimmed back to the last three completed items. + +- 2026-04-20 — **Canonical `grounding` workflow key landed under the naming frontier** — the first phase now uses `grounding` across shared contracts, persistence/runtime logic, fixtures, tests, and export/read-model seams instead of preserving `scope` as the internal key. +- 2026-04-20 — **Canonical specification-named browser and HTTP path family landed under the naming frontier** — routed workspace/export entry moved through `/specification/...`, client fetch/mutation seams targeted `/api/specifications/...`, and legacy `/project/...` plus `/api/projects/...` paths survived only as explicit compatibility seams. +- 2026-04-20 — **Client-owned terminology cleanup slices landed under the naming frontier** — client-facing state seams defaulted to `Specification*` aliases, specification/workspace helper and module names replaced the remaining client runtime `project` wording, and exhausted execution-queue artifacts were retired without changing DB identifiers. +- 2026-04-19 — **Phase transition and handoff stabilization retired from the active frontier** — requirements acceptance now advances directly into criteria kickoff, criteria acceptance closes the workflow into export-ready state, and closed phases project explicit handoff/completion artifacts. + +## 2026-04-21 Sync archive + +- 2026-04-21 — **Grounding free-text question format with hint-guided prompts** — grounding questions use open free-text format; hint-guided priority-ordered topic list replaces unconstrained prompt; schema, prompt, response, and observer seams all aligned. Traceability: D115, D120; A59, A63; Requirements 4, 27. +- 2026-04-21 — **Homepage workspace binding** — root route surfaces workspace (CWD) identity with workspace name + path. Traceability: D122; Requirement 26. +- 2026-04-20 — **Alias deletion retired the naming frontier** — removed remaining `/api/projects/...` compatibility entry points and deleted shared/server `project` alias seams. +- 2026-04-20 — **Specification routes moved to canonical ownership** — routed workspace/export entry through `/specification/...` and client seams through `/api/specifications/...`. + +## 2026-04-23 Sync archive + +- 2026-04-22 — **Transcript/entity boundary repair** — moved the entities subscription out of `src/client/routes/specification/$id/_view/route.tsx`'s transcript-owning `ViewLayout` into entity-owned child surfaces only, and strengthened the mounted-route router oracle to prove entities invalidation refetches only `/entities` without remounting or rerendering the interview route. Verified: `npm run verify`. + +## 2026-04-23 Plan revision archive + +Archived when Track A (interaction model) completed and the frontier shifted to Track B (infrastructure). SPEC.md was pruned: 8 embedded assumptions and 33 embedded decisions retired from the live register. + +- 2026-04-23 — **Phase- and mode-agnostic context gathering** — `present_preface` + exploration tools available in all phases when `cwd` is present (not just brownfield grounding); lightweight context-gathering addendum appended to all phase prompts; "grounding card" terminology replaced with "preface card" in code, tests, and canonical docs. +- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — revised D115 so the interviewer chooses whether to include options per-question; observer interprets selections as resonance in grounding, commitment in design; ActiveQuestionCard has phase-aware submit gate and "none of the above" copy. +- 2026-04-23 — **Brownfield workspace-analysis grounding brief parity / proving retired** — real brownfield start confirmed the grounding brief, paired question, and live activity chrome read as one coherent turn lifecycle. +- 2026-04-23 — **Transcript activity chrome and workspace polish** — task activity mirrors reasoning's auto-open/auto-collapse behavior, live tool activity surfaces richer target details during streaming, duplicate `src/components/ai-elements` tree removed. + +## 2026-04-27 Sync archive + +- 2026-04-24 — **Compiled CLI runtime boundary for distribution hardening** — `npm run build` emits `dist/server/cli.js`, `bin/brunch.js` targets the compiled runtime, and build-backed package-bin smoke coverage proves help-path execution plus local-first launcher startup against the built client artifact. +- 2026-04-23 — **Phase- and mode-agnostic context gathering** — `present_preface` + exploration tools became available in all phases when `cwd` is present, and "grounding card" terminology was replaced with "preface card". +- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — the interviewer chooses whether to include options per question; observer capture interprets selections phase-appropriately while the client keeps grounding free-text-required and design selection-gated. +- 2026-04-23 — **SPEC.md pruning** — retired embedded assumptions and decisions from the live register, leaving only active uncertainty, seam-defining decisions, and future-facing constraints. + +## 2026-04-30 Sync archive + +- 2026-04-24 — **Workflow ownership extraction** — workflow projector extraction, turn-response transition extraction, chat-route transition/application extraction, and phase-close / force-close write-path ownership now live behind runtime-owned seams. Verified: `npm run verify`. + +## 2026-05-05 Sync Archive + +Archived out of `memory/PLAN.md` during design-doc reconciliation once the live frontier narrowed to continuous workspace plus only the last three completed items. + +- 2026-04-24 — **Distribution hardening release path** — `package.json` declares the Node 22+ engine floor, explicit shipped files, and public scoped publish config; `npm run release` drives release-it at repo root, rebuilds and dry-runs the packaged artifact, and documents npm auth prerequisites. Verified: `npm run verify`. Watch: CI trusted publishing remains intentionally out of scope. + +## 2026-05-07 Sync Archive + +Archived out of `memory/PLAN.md` after FE-697 multi-chat substrate landed and FE-673 side-chat V2 plumbing reached branch-complete. Three older completed entries rotated out to keep the live ledger at the last three items. + +- 2026-04-30 — **FE-639 relation-first observer capture first cut** — eligible answered turns enter one background observer-capture backlog, observer prompts use compact existing-knowledge anchors, observer output persists validated graph-delta relationship candidates, and accepted review grounding refs reuse the same conservative relation policy. Verified: `npm run verify`. Watch: A66 remains open until corpus/manual graph-review proves edge precision and density are useful. +- 2026-04-29 — **Workflow ownership extraction (FE-616)** — workflow projector extraction, turn-response transition extraction, chat-route transition/application extraction, and phase-close / force-close write-path ownership now live behind runtime-owned seams. Verified: `npm run verify`. Unblocks continuous workspace. +- 2026-04-27 — **Runtime JSON payload hardening** — Express API parsing now accepts chat-sized request bodies above the default parser ceiling and returns a JSON 413 response instead of Express HTML when a payload exceeds the app limit. Verified: `npm run verify`. Watch: if real chat requests still exceed the 5 MB limit, investigate client history / tool-result pruning rather than only raising the ceiling. + +Use `memory/PLAN.md` for the live frontier only. + +## 2026-05-11 Sync Archive + +Archived out of `memory/PLAN.md` after side-chat V3.1 closed end-to-end (PR #124) and the live ledger narrowed to V3.0 + V3.1 + the multi-chat substrate. The **six** dated `-` bullets immediately below (2026-05-08 through 2026-05-01) rotate prior ledger rows out of the live plan; the first bullet is the slice-4 classifier ship note folded into the V3.1 closure. + +- 2026-05-08 — **Side-chat V3.1 slice 4 — reconciliation classifier (schema + run-agent route)** (FE-674) — added three nullable agent_* columns on `reconciliation_need` (migration 0019); pure `classifyNeed()` over a stubbed-or-live AI SDK adapter; `POST /api/specifications/:id/reconciliation-needs/run-agent` walks every awaiting open need through the lifecycle `null → queued → classifying → classified | failed`; new `reconciliation-classifier.md` prompt asset registered in the prompt-loader; listing endpoint now exposes the three classifier columns with null defaults. SPEC.md gains I114 and the seed corpus location at `src/server/__corpus__/reconciliation-classifier-seeds.json`. Verified: `npm run verify` (1126 passed, +17 net new tests). Folded into V3.1 closure on 2026-05-11. +- 2026-05-08 — **FE-674 planning sync** — reconciled `docs/design/SIDE_CHAT.md` §5.3 / §8 / §9 / §13 against the downstack FE-697 substrate; SPEC.md adds A88 (Path 1 sufficiency without agent), D146 (cascade routes through `reconciliation_need`, `deferred: true` apply contract removed at V3.0 ship), I113 (apply opens at least one need per typed dependency edge), and rewrites Acceptance Criterion 7. Doc-only, no `src/` touched. PR #110 stacked on FE-704. +- 2026-05-07 — **FE-698 prompt/context scenario substrate** — Packaged markdown prompt registry + observer context-pack foundation + scenario runner capture skeleton/composition + agent mutation-surface audit. Server interviewer, observer, and side-chat role prompts now load from markdown assets through a typed prompt registry, observer capture renders its existing prompt context through the first typed scenario-specific context pack, and seeded observer-capture prompt scenarios now compose the production observer prompt with typed context-pack output into deterministic no-provider probe artifacts. Verified: `npm run verify` for code slices; audit verified by code-search/document consistency. +- 2026-05-07 — **Side-chat V2 — Edit / Drill-down / Propose-edge plumbing** (FE-673, PR #97) — added `edit`, `edge`, and `drill-down` patch kinds. Server `classifyEditImpact` returns `none | soft | hard`; soft applies directly with undo, hard returns `deferred: true` placeholder (removed at V3.0 ship). Client: patch-list reducer + three applier factories with real undo handlers. Verified: `npm run verify` (935 tests, 19 new). +- 2026-05-04 — **Graph view structured-list peer route** — `/specification/$id/graph` now renders project-wide entities through the structured-list layout with relationship subsections, relation chips, empty state, row controls, and a back-to-chat affordance. Follow-up active-path filtering and spatial canvas remain horizon work. +- 2026-05-01 — **Side-chat V1.1 — Explore vertical slice** — end-to-end graph-launched chat interaction shipped: prompt builder, POST `/side-chat` SSE endpoint, popover host, graph-view wiring, SSE consumer, and active-button activation. Follow-up refactor collapsed pending assistant text into the message list and extracted `SideChatHost` so activation is a tree-mount fact. + +## 2026-05-13 Sync Archive + +Archived out of `memory/PLAN.md` during `ln-sync` so the live plan keeps only the rolling frontier plus the last three completed items. Entries already archived in the 2026-05-11 sync archive were not duplicated here. + +- [2026-05-08] FE-698 prompt/context follow-up hardening — Candidate-spec prompt scenarios no longer advertise durable changeset submission, prompt scenario artifacts report schema version 2 for the fingerprinted shape, scenario definitions require typed context data, empty prompt assets are cached correctly, context-pack anchors use intent vocabulary, and `context-pack.ts` now remains the public entry point over private scenario-specific context-pack modules. Verified: `npm run verify`. Watch: this is still FE-698 continuation hardening; broader generative quality review and additional scenario probes remain later slices. +- [2026-05-08] FE-698 prompt/context remediation + candidate scenario — Prompt scenario definitions are now discriminated by scenario kind, candidate-spec scenarios render deterministic no-provider proposal artifacts from typed context packs, scenario artifacts include prompt/context fingerprints, server prompt asset copying mirrors current source assets, prompt golden coverage protects production prompt text, and the build-boundary prompt test writes isolated output. Verified: `npm run verify`. Watch: full generative quality review for candidate-spec output remains a later execution/probe slice. +- [2026-05-08] FE-698 scenario execution error hardening — Scenario execution failures now serialize safe deterministic summaries: API-key-like provider errors are redacted, non-Error rejections avoid object dumps, and ordinary errors remain reviewable. Verified: `npm run verify`. +- [2026-05-08] FE-698 Anthropic scenario adapter — Added a probe-only Anthropic AI SDK adapter behind the existing `PromptScenarioModelAdapter` seam. Web-research prompt scenarios now map rendered prompts to AI SDK system content and rendered context packs to user prompt content under mocked tests, with unsupported providers rejected before model construction. Verified: `npm run verify`. Watch: this is not the shared AI runtime provider seam; OpenRouter/provider-neutral routing, credential UX, Pi, web tools, CLI/UI, persistence, and Brunch mutations remain out of scope. +- [2026-05-08] FE-698 prompt scenario execution probe — Web-research prompt scenarios can now execute through an injected fakeable model adapter and serialize `succeeded` / `failed` execution results with raw output or deterministic error text, while no-provider artifacts remain deterministic `not-run` snapshots. Structured parsing is explicitly `not-applicable` for this prose-only web-research path. Verified: `npm run verify`. Watch: real provider adapters, Pi, web tools, CLI/UI, persistence, and mutating Brunch handlers remain out of scope for this foundation slice. +- [2026-05-06] Multi-chat substrate + reconciliation needs (FE-697) — `chat` table with one interview chat per spec, nullable `turn.chat_id`, `specification.primary_chat_id`, mirrored `chat.active_turn_id`, plus the `reconciliation_need` queue with directed source/target items, narrow `kind`/`status`, partial unique index on open rows, cascade FK. Spec creation inserts spec + interview chat in one transaction; `advanceHead` is transactional. No user-visible change. Verified: `npm run verify` (673 tests) plus manual fixture playback (39 specs / 81 turns / dual-pointer equivalence). A82 / A83 validated for Phase 1. +- 2026-05-20 — **Pre-POC archive and reseed** — razed pre-POC implementation, archived legacy docs and planning memory under `archive/`, tagged `next-baseline`, and reseeded `memory/SPEC.md` and `memory/PLAN.md` from the three canonical POC architecture docs. Phase 3 infra bootstrap was folded into `walking-skeleton` rather than remaining an independent frontier. + +## 2026-05-22 Sync archive + +Archived out of `memory/PLAN.md` when `web-shell` closed and the live frontier advanced to `graph-data-plane`. + +- 2026-05-22 — **web-shell judo review fixes** — Session projection reads share a canonical Brunch session envelope, prompt-side custom-entry classification uses an explicit allowlist, and the React shell builds transcript query params from a typed session projection target without non-null assertions. Verified: `npm run verify` after each slice. +- 2026-05-22 — **web-shell tie-off queue** — Explicit session projection rejects ambiguous self-description (`brunch.session_binding` duplicates, missing/duplicate Pi headers, binding/header session-id mismatch); `session.transcriptDisplay` includes displayable transcript-native `brunch.elicitation_prompt` rows; M3 browser-open smoke debt was adjudicated as environment-blocked after direct HTTP/WebSocket postconditions passed. +- 2026-05-22 — **web-shell hardening slices** — Shared JSON-RPC protocol helpers, `ws`-backed `/rpc` transport, persistent browser RPC multiplexing, traversal-safe static asset serving, stable React runtime ownership, and explicit read-only session projection by durable session id landed without REST product reads or connection-as-session semantics. +- 2026-05-21 — **web-shell initial slices** — Linear transcript policy hardening landed before browser consumption, transcript readers fail fast on non-linear Pi JSONL, the minimal native web HTTP shell and WebSocket RPC bridge came online, and the React shell rendered `workspace.snapshot` chrome via one WebSocket RPC client. +- 2026-05-20 — **walking-skeleton** — Brunch launches through a pi-backed TUI boot path with coordinator-first spec gating, project-local `.brunch/` state, self-describing Pi JSONL sessions, same-spec `/new`, persistent chrome through pi's extension widget seam, a bin shim, and the store-only runbook checker. Verified: `npm run verify`, manual TUI smoke, automated TUI/coordinator tests, and runbook oracle. diff --git a/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md index 4e829b36..8f72f245 100644 --- a/docs/archive/PLAN_HISTORY.md +++ b/docs/archive/PLAN_HISTORY.md @@ -1,204 +1,72 @@ # Plan History -Archived from the legacy phase-ledger form of `memory/PLAN.md` on 2026-04-14 during FE-584. - -## Completed Phases - -- 2026-04-14 — **Phase 1: Foundation** — walking skeleton proved SDK → SSE → React end to end, then SQLite persistence landed. -- 2026-04-14 — **Phase 2: Architecture** — turn-tree schema, Drizzle core extraction, and multi-project routing became the durable app spine. -- 2026-04-14 — **Phase 3: Interview Engine** — rich chat UI, structured scope interview, parts-based persistence, observer extraction, and the AI SDK pivot all shipped. -- 2026-04-14 — **Phase 4: Interaction + Knowledge Foundations** — streaming fixes, flexible turn responses, generic knowledge persistence, and phase-aware observer widening landed. -- 2026-04-14 — **Phase 5: Mode Closure + Full Interview** — explicit phase outcomes, canonical knowledge model, design mode, requirements review, and criteria review all closed the full interview loop. -- 2026-04-14 — **Phase 6: Readiness Surfaces + Export** — dashboard workflow state, knowledge workspace review surface, export, and richer fixture seeding shipped. -- 2026-04-14 — **Phase 7: Distribution + Brownfield + UI Alignment** — UI alignment, shiki/debug cleanup, local-first `npx` distribution, and brownfield kickoff all shipped. -- 2026-04-14 — **Phase 10: Route Ownership Refactor** — router seam characterization, route wrapper extraction, file-route infrastructure, and final route-directory consolidation all completed. -- 2026-04-14 — **Phase 11: Routing & Layout Refactor** — directory-based routing, three layout shells, per-phase views, entity sidebar relocation, and graph-view stub all completed. - -## Completed Hardening / Meta Work - -- 2026-04-14 — **Ad-hoc: Typing Hygiene** — Zod was removed from non-LLM boundaries while preserving LLM and HTTP validation seams. -- 2026-04-14 — **Phase 9 completed items** — launcher/runtime guard hardening (14b), trusted fixture hardening (16a), and capture-backed golden corpus work (16b) all shipped. - -- 2026-04-14 — **Phase terminal staging and auto-present current turn** — open phases auto-initiate the current turn, answered-turn replay filters control/closure artifacts, closed phases end with handoff/completion card. - -## Recent Frontier Archives - -- 2026-05-15 — **Side-chat persistence V4a retired as an independent frontier** — persistent side-chat history was absorbed into Conversational Workspace Runtime Track 2. After the chat/thread reconciliation, the near-term runtime uses durable secondary chats over the existing chat/turn substrate; schema-level `thread` is deferred until chat/turn proves insufficient. -- 2026-05-08 — **Side-chat V3.0 hard-impact cascade** — FE-674 / PR #115 + #116 + #117 shipped hard-impact cascade through `reconciliation_need`, Pending review listing, and idempotent resolve. Verified: `npm run verify` (1063 tests, 0 lint warnings). Watch moved forward to V3.1 / reconciliation-runtime walkthroughs. -- 2026-04-27 — **Runtime JSON payload hardening (FE-625)** — Express API parsing now accepts chat-sized request bodies above the default parser ceiling and returns a JSON 413 response instead of Express HTML when a payload exceeds the app limit. Verified: `npm run verify`. Watch: if real chat requests still exceed the 5 MB limit, investigate client history / tool-result pruning rather than only raising the ceiling. -- 2026-04-24 — **Distribution hardening release path (FE-531)** — `package.json` now declares the Node 22+ engine floor, explicit shipped files, and public scoped publish config; `npm run release` drives release-it at repo root, rebuilds and dry-runs the packaged artifact, and documents npm auth prerequisites. Verified: `npm run verify`. Watch: CI trusted publishing is still intentionally out of scope. -- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — revised D115: the interviewer now chooses whether to include options per-question based on conversational trajectory; observer interprets selections as resonance (grounding) or commitment (design); ActiveQuestionCard has phase-aware submit gating and "none of the above" copy. -- 2026-04-22 — **Review revision card contract consistency retired** — acceptance now carries predecessor metadata across sparse regenerated review sets, regeneration context/prompt sources preserve reference codes + rationale + grounding refs + explicit `Added in revision` / `Revised` semantics, and criteria-phase active/replayed/pending review-card routes plus source-owned examples prove the same contract as requirements. -- 2026-04-23 — **Brownfield workspace-analysis grounding brief parity / proving frontier retired** — retired `Active → Track A — Interaction model → Brownfield workspace-analysis grounding brief parity / proving` after a real brownfield start confirmed that the grounding brief, paired question, and live activity chrome read as one coherent turn lifecycle rather than three disconnected states. Follow-on interaction-model work now shifts to reusing that same context-gathering seam beyond the opening grounding turn instead of continuing to treat startup parity as an open proof question. -- 2026-04-22 — **Specification runtime state-machine proving frontier retired** — retired `Active → Track B — Runtime / workflow ownership → Specification runtime state-machine proving` after landing a lightweight specification-scoped lifecycle seam for deferred observer capture. Structured grounding/design responses now unlock their successor turn as soon as interviewer generation is durable, observer capture runs afterward through turn-owned `/api/specifications/:id/turns/:turnId/observer-capture`, unfinished capture reseeds from durable turns after reload, and late completion stays attached to the answered turn rather than the current frontier. Current limitations were kept explicit instead of papered over: server dedupe is process-local, and deferred capture is not yet generalized beyond grounding/design structured responses. -- 2026-04-22 — **Query ownership remediation frontier retired** — retired `Active → Track A — Query ownership → Query ownership remediation` after automated route/query ownership coverage plus manual walkthrough validation across `brownfield-grounding-replay`, `issue-tracker-requirements-ready`, `issue-tracker-criteria-ready`, and `issue-tracker-all-phases-closed`. The retired frontier established one authoritative specification bundle domain, one separately invalidable entities domain, and a transcript subtree no longer rendered by an entity-subscribing layout component. Follow-on observer-backlog / review-revision issues were recorded as a separate planning concern rather than reopening this retired frontier. -- 2026-04-22 — **Query ownership sub-slices archived out of live recently-completed state** — the fake `core` / `turns` split collapsed into one authoritative specification bundle domain, entities remained the separately invalidable domain, `/specification/$id/` redirect plus route priming moved onto the same bundle-owned helper, and route/query ownership integration oracles proved entities invalidation does not remount the interview route while bundle invalidation and direct navigation still use one authoritative fetch path. -- 2026-04-21 — **Review per-item commenting and regeneration** — per-item comment toggles on review set items, structured `itemComments` payloads, version badges, revision cards, and prior-revision collapsing all landed under the accepted-review frontier. -- 2026-04-21 — **Turn-internal grounding cards** — grounding-card plus question-card stacking landed within one turn lifecycle, with observer capture treating the validated turn as one unit. -- 2026-04-18 — **Accepted-set authority cleanup and legacy review semantics retirement** — replaced `reviewStatus`-driven downstream behavior with accepted-set projections, removed review badges from the UI, rewired requirements / criteria review prompts and submission semantics around explicit full-set review actions, and updated seeded walkthrough fixtures so confirmed review sets replay through the same accepted-set seam used at runtime. -- 2026-04-18 — **Explicit review-action payload seam for full-set review turns** — requirements and criteria review turns now carry explicit `reviewActions` metadata in the persisted tool payload, the submit path validates and persists the matching explicit `reviewAction`, targeted `requirementReview` / `criterionReview` turn metadata and per-item response writes are retired, and seeded review fixtures replay through the same contract. -- 2026-04-18 — **Phase header close action now follows the force-close policy seam** — the routed interview header now shows `Close Phase` only when `getForceClosePhaseAction()` says force-close is actually available, so review proposal states no longer expose a contradictory invalid close path while design keeps the existing typed force-close command. -- 2026-04-18 — **Review-phase proposal cards use review-specific acceptance copy** — proposal-pending requirements and criteria states in the routed interview view now render review-specific accept-the-reviewed-set copy instead of the generic closure-proposal presenter, while the confirmation flow still submits the same typed closure command payload. -- 2026-04-18 — **Review-phase kickoff and recovery cards use review-specific copy** — requirements and criteria kickoff / recovery states in the routed interview view now describe candidate-set review flow instead of generic interview-step copy, while the existing proceed / continue action wiring stays unchanged. -- 2026-04-18 — **Review-specific closed-phase completion cards** — closed requirements and criteria phases now render through `ReviewPhaseCompletionCard` in the routed interview view, so review closure states use review-specific handoff/export copy while non-review phases keep the generic closed-state shell. -- 2026-04-18 — **Dedicated active review-turn presenter split** — active requirements and criteria frontier turns now render through a dedicated `ActiveReviewSetCard` path instead of the generic `ActiveQuestionCard`, while persisted active turns and streamed pending turns still submit the same whole-set review action payload and preserve the shared `ReviewSetCard` boundary. -- 2026-04-18 — **Shared review-card route cutover with preserved card structure** — requirements and criteria review turns now render through the same `ReviewSetCard` path in both persisted and streamed pending states, while the richer shared card structure (stats and per-item comment affordances) remains intact even though review authority stays at the whole-set accept/request-changes seam. -- 2026-04-16 — **Specification-first creation and workspace-owned grounding kickoff** — new-spec creation now asks only for the specification name, the grounding strategy choice moved into the grounding kickoff inside the workspace, and touched entry/workspace copy now uses specification/workspace language while internal `project` identifiers remain unchanged. -- 2026-04-16 — **Frontier lifecycle skeleton across open phases** — the open-phase seam now bottoms out in fixed kickoff turns, visible generation states, same-turn review accept-to-close progression, and exceptional recovery turns, closing the no-dead-state frontier tracked under D94. -- 2026-04-16 — **Persist explicit full-set review actions through response + fixture seams** — requirements/criteria review submissions now carry explicit persisted `reviewAction` semantics, server acceptance no longer depends on option copy, client review submissions include the action in transport, and manifest/corpus/synthetic fixture seams round-trip the full-set review action without modeling per-item review turns as the user interaction. -- 2026-04-16 — **Criteria review accept-to-close wiring** — accepting the criteria full-set review now marks the presented criterion set approved, closes criteria on the same durable turn, makes the workflow output-ready, and suppresses the stale review text from being forwarded into chat after workflow completion. -- 2026-04-16 — **Lightweight review turn v1 across requirements + criteria** — both review phases now use full-set review turns with stable item reference codes, one review note, explicit `Accept review` / `Request changes` actions, and accept-to-close progression into the next kickoff/output frontier. -- 2026-04-16 — **Criteria full-set review turn parity** — criteria gained the same full-set review prompt/context/UI seam as requirements, including current criterion inventory, stable criterion reference codes, one review note, and explicit `Accept review` / `Request changes` actions. -- 2026-04-16 — **Requirements review accept-to-close wiring** — accepting the requirements full-set review now marks the presented requirement set approved, closes requirements on the same durable turn, creates the criteria kickoff frontier, and suppresses the stale review text from being forwarded into criteria chat. -- 2026-04-16 — **Transcript parity for existing turn families** — persisted assistant-side replay now stores concise activity summaries instead of raw reasoning / tool parts, hydrated answered / frontier cards reuse the same activity-placeholder family as live transcript updates, and route invalidation no longer needs generic placeholder fallbacks for existing turn families. Done: `npm run verify`. Watch: manual reload / invalidation walkthrough still outstanding. -- 2026-04-16 — **DrawerCard-based question card family and generating-turn placeholder** — ordinary interview turns now render through dedicated question-card components: compact answered cards, expanded active cards, inline activity placeholders, and a skeleton-backed generating-turn placeholder, replacing the older generic turn-card treatment for question-turn replay and in-flight generation. -- 2026-04-15 — **Center pane sticky header and ChatScroll integration** — `InterviewView` now renders phase metadata and state-gated actions in the sticky center header, with `ChatScroll` as the route-owned transcript container. -- 2026-04-15 — **Knowledge sidebar grouping registry** — `EntitySidebar` now groups visible knowledge kinds behind the hard-coded display registry with compact `DrawerCard` items and stable reference-code display. -- 2026-04-15 — **Phase stepper sidebar** — `PhaseNavigationSidebar` now renders the sticky specification header, sequential phase timeline, and conditional Output row. -- 2026-04-15 — **Top bar and phase label canonicalization** — RouteRoot gained the canonical top bar and shared phase-label registry across dashboard, sidebar, transcript copy, and fixtures. -- 2026-04-15 — **Story-first turn-card refinement** — DrawerCard, question/knowledge detail cards, chat transcript story, and token scale canon landed as the presentational base for route integration. -- 2026-04-14 — **Turn-owned captured-item projection and trailing observer attachment** — answered turns project captured knowledge with stable reference codes and keep late observer completion attached to the originating turn. -- 2026-04-14 — **Turn-owned submit/interviewer-processing choreography** — active turns stay mounted through submit, lock inline during processing, and collapse only when the next state is ready. -- 2026-04-14 — **Workspace shell first honesty pass** — dashboard links became real, root/dashboard scrolling was fixed, future phases became visible-but-disabled, review phases gained distinct shell framing, and transcript replay shifted from user bubbles toward compact answered-turn cards plus control markers. -- 2026-04-14 — **Fixture-backed walkthrough workspace** — walkthrough-ready seed scenarios now front-load the public seed catalog, prove resume after re-open, and cover export-ready/manual-inspection states. - -## 2026-04-18 Sync Archive - -Archived out of `memory/PLAN.md` during `ln-sync` once the live frontier narrowed to a few active cleanup tails plus the next major projector work. - -- 2026-04-18 — **Runtime review turns now persist an explicit review-set payload derived from the current review inventory** — `src/server/interview.ts` can now synthesize `data-review-set` payloads for requirements and criteria from the current review inventory, and `src/server/app.ts` now persists that synthesized review set onto runtime review turns even when the model only emits the structured question itself. -- 2026-04-18 — **Accepted review now materializes only the persisted review-set items instead of blanket-accepting all project-wide review entities** — `src/server/db.ts` can now materialize requirement / criterion items from a persisted `data-review-set`, reusing matching existing rows when present, and `src/server/app.ts` now prefers that accepted-review path over blanket-linking every project-wide requirement / criterion row. -- 2026-04-18 — **Requirement and criterion durability authority is now explicit in shared code** — `src/shared/knowledge.ts` codified the lifecycle boundary: exploration kinds stay observer-captured, while `requirement` and `criterion` are review-authoritative and tied to accepted review phases. -- 2026-04-18 — **Observer prompt and output schema now derive from shared ontology policy** — `src/server/observer.ts` now builds its output schema from the canonical knowledge registry and derives phase bias / kind-semantics prompt text from shared ontology policy. -- 2026-04-18 — **Shared observer ontology policy now declares phase-valid kinds and kind semantics** — `src/shared/knowledge.ts` now exports one phase-by-phase observer ontology policy plus per-kind semantic-role text. -- 2026-04-18 — **Story, fixture, and demo samples now teach the canonical reference-code contract** — seeded review-set fixture scenarios and chat/review/turn-lifecycle story fixtures now derive visible knowledge codes from `createKnowledgeReferenceCode()`. -- 2026-04-18 — **Canonical reference-code prefixes now emit the intended short-form contract** — the shared knowledge registry now emits `G` / `T` / `CTX` / `CON` / `R` / `AC` / `D` / `A`, and UI fallback badges consume the shared prefix map. -- 2026-04-18 — **Reference-code test fixtures now derive from the shared generator instead of freezing literals** — high-churn UI/server/shared tests now call `createKnowledgeReferenceCode()` for incidental codes. -- 2026-04-18 — **Registry-owned reference-code prefixes now drive runtime code generation** — `src/shared/knowledge.ts` now stores the current reference-code prefix on each `knowledgeKindRegistry` entry and derives `createKnowledgeReferenceCode()` from registry metadata. -- 2026-04-18 — **Captured-item replay now projects through one collection-driven entity path** — `src/server/db.ts` now builds `captured_items` for replay by iterating the canonical project-wide entity collections through `knowledgeKindRegistry`. -- 2026-04-18 — **Decision and assumption transport schemas now derive from the canonical knowledge-item contract** — `src/shared/api-types.ts` now defines decision and assumption entity schemas by projecting `knowledgeItemSchema`. -- 2026-04-18 — **Decision and assumption entity reads now share one knowledge-item projection helper** — `src/server/db.ts` no longer uses bespoke `toDecision` / `toAssumption` adapters for entity projection. -- 2026-04-18 — **Active-path generic entity filtering now runs through one collection-driven helper** — `src/server/db.ts` now filters generic knowledge collections for active-path projection through a shared registry-driven helper. -- 2026-04-18 — **Shared kind lookup maps now drive both sidebar and server relationship projection** — `src/shared/knowledge.ts` now exports canonical `kind -> collectionKey` and `kind -> entityCollection` maps consumed by both UI and server projection seams. -- 2026-04-18 — **Project-wide generic entity projection now uses one shared knowledge-item path** — `src/server/db.ts` now projects `requirement` and `criterion` through the same generic knowledge-item helper used for the other canonical `knowledge_item` kinds. -- 2026-04-18 — **Shared ontology tuples now drive API kind enums and manifest collection mapping** — `src/shared/knowledge.ts` now exports canonical knowledge-kind / collection tuples plus `knowledgeCollectionKeyByKind`, and manifest seeding now consumes the shared kind→collection mapping. -- 2026-04-18 — **Non-compatibility `framing` references retired from active fixtures and test naming** — route infrastructure tests now distinguish canonical grounding routes from the legacy `/framing` redirect seam, server observer/app tests use canonical `context` naming, and the active issue-tracker manifest no longer describes criteria review in framing-era language. -- 2026-04-18 — **Shared requirement/criterion entity contracts dropped legacy `reviewStatus`** — `src/shared/api-types.ts` now exposes canonical requirement and criterion entities without `reviewStatus`, and client/sidebar/graph/interview fixtures now use the canonical read-model shape. -- 2026-04-18 — **Distinct review-phase UI rebuilt on accepted-set authority** — requirements and criteria now render through review-specific entry, active, replayed, proposal, and closed-state presenters, while the routed header close action follows the same force-close policy seam as the transcript. -- 2026-04-18 — **Canonical grounding route cut over with legacy framing redirect** — the first phase entered through `/grounding`, index/export/in-workspace navigation targeted the canonical grounding URL, the file-routed interview surface gained a dedicated `grounding.tsx` entry, and the legacy `/framing` route was reduced to a temporary redirect seam until full retirement. -- 2026-04-19 — **Legacy knowledge facade cleanup retired** — decision/assumption entity references unified on `knowledge_item`, dead legacy per-type schema/relationship tables were removed, and migration `0010_retire_legacy_knowledge_tables` made the canonical storage seam authoritative for boot, seeding, and projection. - -## 2026-04-19 Frontier Retirement Archive - -Archived out of `memory/PLAN.md` when the phase-transition / handoff frontier retired and the live plan advanced to naming normalization. - -- 2026-04-19 — **Legacy fixture side path removed; one TS-native fixture model remains** — walkthroughs, app tests, and observer probes now seed through direct TypeScript builders/helpers only. -- 2026-04-19 — **Force-close and close-confirmation now read as explicit in-flight control actions** — typed proposal confirmations and design force-close requests now surface control markers in the workspace stream while stale proposal/frontier projection is suppressed during submit. -- 2026-04-19 — **Closed-phase stream artifacts now read as explicit completion / handoff states** — accepted closure replay now renders through dedicated completion chrome instead of the generic workspace-state shell, and non-review closed-phase handoffs carry explicit handoff framing in the workspace stream. -- 2026-04-19 — **Public walkthrough seeds now prefer canonical review-turn helpers over stale late-phase scenario slices** — the public `requirements-ready` / `criteria-ready` seeds now resolve through the helper-backed full-set review seams, and the `issue-tracker-*` kickoff-ready later-phase walkthrough fixtures no longer slice stale per-item requirement drafts into public truth. Walkthrough regression coverage now asserts persisted `data-review-set` metadata on the seeded requirements / criteria review turns. -- 2026-04-19 — **D113 rejected auto-submit hardening landed under the handoff frontier** — specification-scoped auto phase intents now treat rejected submit promises as failed submissions instead of leaving the reachable phase stuck in lifecycle-owned generating state. The helper marks the current auto intent failed, re-projects the kickoff/recovery control, and still suppresses duplicate retry across rerender/remount until durable landing changes. -- 2026-04-19 — **D113 recovery auto-continue proving slice landed under the handoff frontier** — current reachable recovery restoration now auto-submits typed `phase-continue` through the same specification-scoped lifecycle helper as kickoff auto-present, suppresses duplicate submit across rerender/remount, and falls back to the projected recovery card after failed auto-submit instead of retry-looping. Router ownership of navigation plus durable read-model rendering remains unchanged, and no second durable workflow model was introduced. -- 2026-04-19 — **Narrow D113 lifecycle proving slice landed under the handoff frontier** — current reachable kickoff auto-present now runs through a specification-scoped lifecycle helper rather than a route-local effect, uses typed phase-entry intent submission only, suppresses duplicate submit across rerender/remount, and preserves router ownership of navigation plus durable read-model rendering. Grounding strategy kickoff on `scope` remained explicit pending the follow-on lifecycle slices. -- 2026-04-19 — **Turn-artifact persistence and brownfield replay hardening retired from active execution** — interviewer-owned review, grounding, activity, and closure artifacts now materialize through one server-owned seam, active review buttons submit by semantic action metadata instead of assumed option order, and the walkthrough catalog now includes a named brownfield reusable-grounding replay scenario. -- 2026-04-19 — **Interaction-family canonicalization retired from the active frontier** — the workspace stream now carries projected kickoff/recovery/handoff controls plus durable grounding/question/review turns as one canonical interaction family, and brownfield grounding no longer depends on a one-shot repo-summary question ritual. -- 2026-04-19 — **Merged-stream projector cutover retired from the active frontier** — project creation, phase confirmation / force-close, and requirements acceptance no longer pre-seed next-phase kickoff rows; resumed state, phase-entry kickoff, and closed-phase advancement now rely on derived `landing` plus durable workflow outcomes instead of fabricated control rows. - -## 2026-04-19 Sync Archive - -Archived out of `memory/PLAN.md` during `ln-sync` once the live frontier narrowed to the handoff frontier plus only the most recent retirement markers. - -- 2026-04-19 — **Brownfield grounding-card opening sequence landed** — brownfield kickoff now begins with a provisional grounding card instead of a repo-summary question handoff, grounding cards replay as their own workspace-stream turn family, and observer capture skips answered grounding-card continues while still advancing to the next interviewer turn. -- 2026-04-19 — **Transitional control-row runtime plumbing was retired behind derived landing + typed phase intent** — legacy control-row fabrication left production helpers, phase-intent chat submits now prepare interviewer turns directly from derived landing, control markers depend only on typed `data-phase-intent`, and projected kickoff/recovery controls submit through the shared phase-intent seam rather than branching on persisted control rows. -- 2026-04-19 — **Workspace-stream projection consolidated around one bottom-artifact and ordered artifact seam** — the routed interview surface moved to one discriminated bottom-artifact contract, stream ordering now projects through one client seam, projected control artifacts use control/artifact terminology, and review/control/handoff markers render inline in the ordered workspace stream. -- 2026-04-19 — **Landing derivation and seed-first walkthrough authority replaced canonical control rows** — specification-state reads stay projection-only, open-phase landing now derives from workflow state plus active-path turns, fixture/corpus/manifest seams normalize to derived landing, canonical transition fixtures seed durable authority instead of authoritative control rows, and projected kickoff strategy selection no longer requires a seeded kickoff turn row. -- 2026-04-19 — **Legacy route and knowledge-facade cleanup retired** — the legacy `/framing` compatibility route was removed from the active app surface, and the last legacy knowledge-facade/schema cleanup landed so runtime boot, seeding, and projection now flow only through the canonical knowledge seams. -- 2026-04-18 — **Runtime-generated review turns now persist their own interviewer-owned review metadata** — review turns can carry explicit `reviewActions` plus a durable `reviewSet`, and the happy path now replays and accepts that authoritative runtime metadata instead of relying on synthesized fallback inventory. - -## 2026-04-21 Live-plan cleanup archive - -Archived out of `memory/PLAN.md` when the active frontier moved from the mostly-landed dramaturgical hardening bundle to the next grounding interaction-model slices, and older completion entries were trimmed back to the last three completed items. - -- 2026-04-20 — **Canonical `grounding` workflow key landed under the naming frontier** — the first phase now uses `grounding` across shared contracts, persistence/runtime logic, fixtures, tests, and export/read-model seams instead of preserving `scope` as the internal key. -- 2026-04-20 — **Canonical specification-named browser and HTTP path family landed under the naming frontier** — routed workspace/export entry moved through `/specification/...`, client fetch/mutation seams targeted `/api/specifications/...`, and legacy `/project/...` plus `/api/projects/...` paths survived only as explicit compatibility seams. -- 2026-04-20 — **Client-owned terminology cleanup slices landed under the naming frontier** — client-facing state seams defaulted to `Specification*` aliases, specification/workspace helper and module names replaced the remaining client runtime `project` wording, and exhausted execution-queue artifacts were retired without changing DB identifiers. -- 2026-04-19 — **Phase transition and handoff stabilization retired from the active frontier** — requirements acceptance now advances directly into criteria kickoff, criteria acceptance closes the workflow into export-ready state, and closed phases project explicit handoff/completion artifacts. - -## 2026-04-21 Sync archive - -- 2026-04-21 — **Grounding free-text question format with hint-guided prompts** — grounding questions use open free-text format; hint-guided priority-ordered topic list replaces unconstrained prompt; schema, prompt, response, and observer seams all aligned. Traceability: D115, D120; A59, A63; Requirements 4, 27. -- 2026-04-21 — **Homepage workspace binding** — root route surfaces workspace (CWD) identity with workspace name + path. Traceability: D122; Requirement 26. -- 2026-04-20 — **Alias deletion retired the naming frontier** — removed remaining `/api/projects/...` compatibility entry points and deleted shared/server `project` alias seams. -- 2026-04-20 — **Specification routes moved to canonical ownership** — routed workspace/export entry through `/specification/...` and client seams through `/api/specifications/...`. - -## 2026-04-23 Sync archive - -- 2026-04-22 — **Transcript/entity boundary repair** — moved the entities subscription out of `src/client/routes/specification/$id/_view/route.tsx`'s transcript-owning `ViewLayout` into entity-owned child surfaces only, and strengthened the mounted-route router oracle to prove entities invalidation refetches only `/entities` without remounting or rerendering the interview route. Verified: `npm run verify`. - -## 2026-04-23 Plan revision archive - -Archived when Track A (interaction model) completed and the frontier shifted to Track B (infrastructure). SPEC.md was pruned: 8 embedded assumptions and 33 embedded decisions retired from the live register. - -- 2026-04-23 — **Phase- and mode-agnostic context gathering** — `present_preface` + exploration tools available in all phases when `cwd` is present (not just brownfield grounding); lightweight context-gathering addendum appended to all phase prompts; "grounding card" terminology replaced with "preface card" in code, tests, and canonical docs. -- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — revised D115 so the interviewer chooses whether to include options per-question; observer interprets selections as resonance in grounding, commitment in design; ActiveQuestionCard has phase-aware submit gate and "none of the above" copy. -- 2026-04-23 — **Brownfield workspace-analysis grounding brief parity / proving retired** — real brownfield start confirmed the grounding brief, paired question, and live activity chrome read as one coherent turn lifecycle. -- 2026-04-23 — **Transcript activity chrome and workspace polish** — task activity mirrors reasoning's auto-open/auto-collapse behavior, live tool activity surfaces richer target details during streaming, duplicate `src/components/ai-elements` tree removed. - -## 2026-04-27 Sync archive - -- 2026-04-24 — **Compiled CLI runtime boundary for distribution hardening** — `npm run build` emits `dist/server/cli.js`, `bin/brunch.js` targets the compiled runtime, and build-backed package-bin smoke coverage proves help-path execution plus local-first launcher startup against the built client artifact. -- 2026-04-23 — **Phase- and mode-agnostic context gathering** — `present_preface` + exploration tools became available in all phases when `cwd` is present, and "grounding card" terminology was replaced with "preface card". -- 2026-04-23 — **Interviewer-autonomous question format with phase-aware gating** — the interviewer chooses whether to include options per question; observer capture interprets selections phase-appropriately while the client keeps grounding free-text-required and design selection-gated. -- 2026-04-23 — **SPEC.md pruning** — retired embedded assumptions and decisions from the live register, leaving only active uncertainty, seam-defining decisions, and future-facing constraints. - -## 2026-04-30 Sync archive - -- 2026-04-24 — **Workflow ownership extraction** — workflow projector extraction, turn-response transition extraction, chat-route transition/application extraction, and phase-close / force-close write-path ownership now live behind runtime-owned seams. Verified: `npm run verify`. - -## 2026-05-05 Sync Archive - -Archived out of `memory/PLAN.md` during design-doc reconciliation once the live frontier narrowed to continuous workspace plus only the last three completed items. - -- 2026-04-24 — **Distribution hardening release path** — `package.json` declares the Node 22+ engine floor, explicit shipped files, and public scoped publish config; `npm run release` drives release-it at repo root, rebuilds and dry-runs the packaged artifact, and documents npm auth prerequisites. Verified: `npm run verify`. Watch: CI trusted publishing remains intentionally out of scope. - -## 2026-05-07 Sync Archive - -Archived out of `memory/PLAN.md` after FE-697 multi-chat substrate landed and FE-673 side-chat V2 plumbing reached branch-complete. Three older completed entries rotated out to keep the live ledger at the last three items. - -- 2026-04-30 — **FE-639 relation-first observer capture first cut** — eligible answered turns enter one background observer-capture backlog, observer prompts use compact existing-knowledge anchors, observer output persists validated graph-delta relationship candidates, and accepted review grounding refs reuse the same conservative relation policy. Verified: `npm run verify`. Watch: A66 remains open until corpus/manual graph-review proves edge precision and density are useful. -- 2026-04-29 — **Workflow ownership extraction (FE-616)** — workflow projector extraction, turn-response transition extraction, chat-route transition/application extraction, and phase-close / force-close write-path ownership now live behind runtime-owned seams. Verified: `npm run verify`. Unblocks continuous workspace. -- 2026-04-27 — **Runtime JSON payload hardening** — Express API parsing now accepts chat-sized request bodies above the default parser ceiling and returns a JSON 413 response instead of Express HTML when a payload exceeds the app limit. Verified: `npm run verify`. Watch: if real chat requests still exceed the 5 MB limit, investigate client history / tool-result pruning rather than only raising the ceiling. - -Use `memory/PLAN.md` for the live frontier only. - -## 2026-05-11 Sync Archive - -Archived out of `memory/PLAN.md` after side-chat V3.1 closed end-to-end (PR #124) and the live ledger narrowed to V3.0 + V3.1 + the multi-chat substrate. The **six** dated `-` bullets immediately below (2026-05-08 through 2026-05-01) rotate prior ledger rows out of the live plan; the first bullet is the slice-4 classifier ship note folded into the V3.1 closure. - -- 2026-05-08 — **Side-chat V3.1 slice 4 — reconciliation classifier (schema + run-agent route)** (FE-674) — added three nullable agent_* columns on `reconciliation_need` (migration 0019); pure `classifyNeed()` over a stubbed-or-live AI SDK adapter; `POST /api/specifications/:id/reconciliation-needs/run-agent` walks every awaiting open need through the lifecycle `null → queued → classifying → classified | failed`; new `reconciliation-classifier.md` prompt asset registered in the prompt-loader; listing endpoint now exposes the three classifier columns with null defaults. SPEC.md gains I114 and the seed corpus location at `src/server/__corpus__/reconciliation-classifier-seeds.json`. Verified: `npm run verify` (1126 passed, +17 net new tests). Folded into V3.1 closure on 2026-05-11. -- 2026-05-08 — **FE-674 planning sync** — reconciled `docs/design/SIDE_CHAT.md` §5.3 / §8 / §9 / §13 against the downstack FE-697 substrate; SPEC.md adds A88 (Path 1 sufficiency without agent), D146 (cascade routes through `reconciliation_need`, `deferred: true` apply contract removed at V3.0 ship), I113 (apply opens at least one need per typed dependency edge), and rewrites Acceptance Criterion 7. Doc-only, no `src/` touched. PR #110 stacked on FE-704. -- 2026-05-07 — **FE-698 prompt/context scenario substrate** — Packaged markdown prompt registry + observer context-pack foundation + scenario runner capture skeleton/composition + agent mutation-surface audit. Server interviewer, observer, and side-chat role prompts now load from markdown assets through a typed prompt registry, observer capture renders its existing prompt context through the first typed scenario-specific context pack, and seeded observer-capture prompt scenarios now compose the production observer prompt with typed context-pack output into deterministic no-provider probe artifacts. Verified: `npm run verify` for code slices; audit verified by code-search/document consistency. -- 2026-05-07 — **Side-chat V2 — Edit / Drill-down / Propose-edge plumbing** (FE-673, PR #97) — added `edit`, `edge`, and `drill-down` patch kinds. Server `classifyEditImpact` returns `none | soft | hard`; soft applies directly with undo, hard returns `deferred: true` placeholder (removed at V3.0 ship). Client: patch-list reducer + three applier factories with real undo handlers. Verified: `npm run verify` (935 tests, 19 new). -- 2026-05-04 — **Graph view structured-list peer route** — `/specification/$id/graph` now renders project-wide entities through the structured-list layout with relationship subsections, relation chips, empty state, row controls, and a back-to-chat affordance. Follow-up active-path filtering and spatial canvas remain horizon work. -- 2026-05-01 — **Side-chat V1.1 — Explore vertical slice** — end-to-end graph-launched chat interaction shipped: prompt builder, POST `/side-chat` SSE endpoint, popover host, graph-view wiring, SSE consumer, and active-button activation. Follow-up refactor collapsed pending assistant text into the message list and extracted `SideChatHost` so activation is a tree-mount fact. - -## 2026-05-13 Sync Archive - -Archived out of `memory/PLAN.md` during `ln-sync` so the live plan keeps only the rolling frontier plus the last three completed items. Entries already archived in the 2026-05-11 sync archive were not duplicated here. - -- [2026-05-08] FE-698 prompt/context follow-up hardening — Candidate-spec prompt scenarios no longer advertise durable changeset submission, prompt scenario artifacts report schema version 2 for the fingerprinted shape, scenario definitions require typed context data, empty prompt assets are cached correctly, context-pack anchors use intent vocabulary, and `context-pack.ts` now remains the public entry point over private scenario-specific context-pack modules. Verified: `npm run verify`. Watch: this is still FE-698 continuation hardening; broader generative quality review and additional scenario probes remain later slices. -- [2026-05-08] FE-698 prompt/context remediation + candidate scenario — Prompt scenario definitions are now discriminated by scenario kind, candidate-spec scenarios render deterministic no-provider proposal artifacts from typed context packs, scenario artifacts include prompt/context fingerprints, server prompt asset copying mirrors current source assets, prompt golden coverage protects production prompt text, and the build-boundary prompt test writes isolated output. Verified: `npm run verify`. Watch: full generative quality review for candidate-spec output remains a later execution/probe slice. -- [2026-05-08] FE-698 scenario execution error hardening — Scenario execution failures now serialize safe deterministic summaries: API-key-like provider errors are redacted, non-Error rejections avoid object dumps, and ordinary errors remain reviewable. Verified: `npm run verify`. -- [2026-05-08] FE-698 Anthropic scenario adapter — Added a probe-only Anthropic AI SDK adapter behind the existing `PromptScenarioModelAdapter` seam. Web-research prompt scenarios now map rendered prompts to AI SDK system content and rendered context packs to user prompt content under mocked tests, with unsupported providers rejected before model construction. Verified: `npm run verify`. Watch: this is not the shared AI runtime provider seam; OpenRouter/provider-neutral routing, credential UX, Pi, web tools, CLI/UI, persistence, and Brunch mutations remain out of scope. -- [2026-05-08] FE-698 prompt scenario execution probe — Web-research prompt scenarios can now execute through an injected fakeable model adapter and serialize `succeeded` / `failed` execution results with raw output or deterministic error text, while no-provider artifacts remain deterministic `not-run` snapshots. Structured parsing is explicitly `not-applicable` for this prose-only web-research path. Verified: `npm run verify`. Watch: real provider adapters, Pi, web tools, CLI/UI, persistence, and mutating Brunch handlers remain out of scope for this foundation slice. -- [2026-05-06] Multi-chat substrate + reconciliation needs (FE-697) — `chat` table with one interview chat per spec, nullable `turn.chat_id`, `specification.primary_chat_id`, mirrored `chat.active_turn_id`, plus the `reconciliation_need` queue with directed source/target items, narrow `kind`/`status`, partial unique index on open rows, cascade FK. Spec creation inserts spec + interview chat in one transaction; `advanceHead` is transactional. No user-visible change. Verified: `npm run verify` (673 tests) plus manual fixture playback (39 specs / 81 turns / dual-pointer equivalence). A82 / A83 validated for Phase 1. -- 2026-05-20 — **Pre-POC archive and reseed** — razed pre-POC implementation, archived legacy docs and planning memory under `archive/`, tagged `next-baseline`, and reseeded `memory/SPEC.md` and `memory/PLAN.md` from the three canonical POC architecture docs. Phase 3 infra bootstrap was folded into `walking-skeleton` rather than remaining an independent frontier. - -## 2026-05-22 Sync archive - -Archived out of `memory/PLAN.md` when `web-shell` closed and the live frontier advanced to `graph-data-plane`. - -- 2026-05-22 — **web-shell judo review fixes** — Session projection reads share a canonical Brunch session envelope, prompt-side custom-entry classification uses an explicit allowlist, and the React shell builds transcript query params from a typed session projection target without non-null assertions. Verified: `npm run verify` after each slice. -- 2026-05-22 — **web-shell tie-off queue** — Explicit session projection rejects ambiguous self-description (`brunch.session_binding` duplicates, missing/duplicate Pi headers, binding/header session-id mismatch); `session.transcriptDisplay` includes displayable transcript-native `brunch.elicitation_prompt` rows; M3 browser-open smoke debt was adjudicated as environment-blocked after direct HTTP/WebSocket postconditions passed. -- 2026-05-22 — **web-shell hardening slices** — Shared JSON-RPC protocol helpers, `ws`-backed `/rpc` transport, persistent browser RPC multiplexing, traversal-safe static asset serving, stable React runtime ownership, and explicit read-only session projection by durable session id landed without REST product reads or connection-as-session semantics. -- 2026-05-21 — **web-shell initial slices** — Linear transcript policy hardening landed before browser consumption, transcript readers fail fast on non-linear Pi JSONL, the minimal native web HTTP shell and WebSocket RPC bridge came online, and the React shell rendered `workspace.snapshot` chrome via one WebSocket RPC client. -- 2026-05-20 — **walking-skeleton** — Brunch launches through a pi-backed TUI boot path with coordinator-first spec gating, project-local `.brunch/` state, self-describing Pi JSONL sessions, same-spec `/new`, persistent chrome through pi's extension widget seam, a bin shim, and the store-only runbook checker. Verified: `npm run verify`, manual TUI smoke, automated TUI/coordinator tests, and runbook oracle. +This file is the active POC-line plan archive for `memory/PLAN.md`. +Legacy pre-`next` history was moved out of the live docs tree with the old archived implementation. + +## 2026-05-28 Sync archive + +Archived from `memory/PLAN.md` so the live plan only carries active, next, horizon, and recent-completion state. + +### walking-skeleton + +- **Name:** Walking skeleton — `brunch` binary + TUI over pi +- **Linear:** [FE-729](https://linear.app/hash/issue/FE-729) (sub-issue of FE-702) +- **Branch:** `ln/fe-729-walking-skeleton` (off `next`) +- **Kind:** structural +- **Status:** done (bootstrap slice landed on `next` as commit `b104fc40`; coordinator/runbook and TUI boot/chrome slices landed on the frontier branch; manual M0 smoke + store-only runbook oracle passed) +- **Objective:** Prove the wrapping model works at all: a `brunch` binary launches a pi-backed TUI session through the `WorkspaceSessionCoordinator`, scopes durable state to `.brunch/`, hardcodes Brunch's prompt and curated toolset, and mounts the persistent TUI chrome and spec-selector gate. +- **Why now / unlocks:** First architectural proof of D1-L (depend on `pi-coding-agent`) and D2-L (opinionated product, not pi shell). Unlocks every subsequent milestone. Also doubles as the Phase-3 infra bootstrap (package.json, tsconfig, oxlint/oxfmt, vitest). +- **Acceptance:** `brunch` launches a TUI session in a project directory; `.brunch/` is created; boot routes through a `WorkspaceSessionCoordinator` that returns `ready | select_spec | needs_human`; the spec-selector is presented before any agent loop runs when no bound spec is ready; the selected spec is written as the session's `brunch.session_binding`; `/new` creates another session bound to the same spec rather than mutating the current session's spec; the chrome region displays cwd / spec / phase / runtime bundle at all times; `npm run verify` is green. +- **Verification:** Inner — `npm run fix` / `npm run verify` plus coordinator state/unit tests. Middle — M0 runbook oracle: manual TUI smoke against a scratch project paired with artifact/query postconditions for `.brunch/`, `brunch.session_binding`, same-spec `/new`, and chrome/workspace state (SPEC §Runbook Oracle Design). Outer — defer; first replay-regression fixture lands in M1. +- **Cross-cutting obligations:** Preserve the `cwd → spec → session` hierarchy, one-spec-per-session binding, and persistent chrome region as durable product surfaces, not temporary bootstrapping hacks. Do not let TUI, RPC, or fixture code create/open Pi sessions or write `brunch.session_binding` directly; route boot, spec selection, and `/new` through the workspace-session seam. +- **Traceability:** R1, R2, R3, R4, R19 / D1-L, D2-L, D6-L, D11-L, D21-L / I8-L, I13-L / A1-L, A10-L +- **Design docs:** [prd.md §M0](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §3](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) +- **Current execution pointer:** complete; proceed to `mode-shell-and-fixture-driver`. + +### mode-shell-and-fixture-driver + +- **Name:** Mode shell (print + rpc) and first fixture driver +- **Linear:** [FE-735](https://linear.app/hash/issue/FE-735/mode-shell-and-fixture-driver-m1) (sub-issue of FE-702) +- **Branch:** `ln/fe-735-mode-shell-fixture-driver` (stacked on `ln/fe-729-walking-skeleton`) +- **Kind:** structural +- **Status:** done +- **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression fixtures for at least briefs #1–#3. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. +- **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. +- **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.brunch-fixtures/`; the first three curated briefs are captured. +- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./runbooks/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the runbook. +- **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. +- **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L +- **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) +- **Current execution pointer:** complete after M1 review fixes; proceed to `jsonl-session-viability`. + +### jsonl-session-viability + +- **Name:** JSONL session viability proof +- **Linear:** [FE-736](https://linear.app/hash/issue/FE-736/jsonl-session-viability-proof) +- **Branch:** `ln/fe-736-jsonl-session-viability` (stacked on `ln/fe-735-mode-shell-fixture-driver`) +- **Kind:** structural +- **Status:** done +- **Objective:** Prove whether pi `SessionManager` JSONL in `.brunch/sessions/` is rich enough to carry raw assistant/user payloads, Brunch session binding (`brunch.session_binding`), structured elicitation prompt/response entries when needed, other custom entries (`brunch.lens_switch`, `brunch.side_task_result`, `worldUpdate`, `brunch.mention`, `brunch.mention_staleness_hint`), and session-scoped continuity metadata (`lastSeenLsn`, interest sets, compaction anchors) through reload. +- **Why now / unlocks:** Validated the JSONL-first transcript strategy and pinned D6-L for Brunch-supported linear sessions. If JSONL had been insufficient, M2 would have produced a sharply scoped fallback proposal that all later milestones could plan against. +- **Acceptance:** Round-trip reload of a captured linear session preserves raw payloads byte-equivalent (modulo timestamps); session binding and structured elicitation entries survive; elicitation exchanges can be re-projected after reload; all named Brunch custom entries survive, including side-task-result delivery entries when present; continuity metadata survives. Defensive branch-shape tests document Pi substrate behavior, but branch-aware Brunch sessions are not product-supported per D24-L. If core linear-session viability fails, the failure is sharply documented and a fallback path is proposed (project richer substrate / mirror JSONL into richer records / propose pi upstream change). +- **Verification:** Inner — verify gate plus synthetic JSONL projection tests. Middle — JSONL round-trip/property tests for raw payloads, `brunch.session_binding`, structured elicitation entries, defensive branch-shape projection behavior, coordinator-created `/new` sessions, and M1 fixture replay parity. Outer — fixture replay parity across the transcript-first run bundle; no new human review was required because brief content and scripted user notes did not change. +- **Cross-cutting obligations:** This frontier is the transcript-side proof for the shared event substrate that later carries structured elicitation entries, session binding, lens switches, side-task results, mentions, and `worldUpdate` without inventing a parallel channel or canonical chat/turn store. JSONL viability must validate sessions created through the `WorkspaceSessionCoordinator`, including the first-entry binding and `/new` same-spec behavior. +- **Traceability:** R7, R8, R16, R17, R19 / D6-L, D11-L, D12-L, D13-L, D18-L, D24-L / I3-L, I8-L, I10-L, I19-L +- **Design docs:** archived [jsonl-session-viability-note](file:///Users/lunelson/Code/hashintel/brunch-next/archive/archive/docs/architecture/jsonl-session-viability-note.md) +- **Current execution pointer:** complete; proceed to `web-shell`. + +### web-shell + +- **Name:** Web shell over the same host (M3) +- **Linear:** [FE-737](https://linear.app/hash/issue/FE-737/web-shell-over-the-same-host-m3) +- **Branch:** `ln/fe-737-web-shell` +- **Kind:** structural +- **Status:** done +- **Objective:** `brunch --mode web` serves a native Brunch React app (TanStack Router + Query) over one WebSocket-backed JSON-RPC client; no second backend API, REST read model, or browser-owned product runtime is invented; `pi-web-ui` is not used. The web surface is initially a read-only visual dashboard/client attachment over explicit spec/session resources, so a TUI can remain the interactive writer while the browser renders richer projections. +- **Why now / unlocks:** Proves D10-L. Unlocks parallel UI work and visualises graph + coherence state. Sequenced after M2 so the transcript substrate is pinned before clients depend on it. +- **Acceptance:** Web client connects via one persistent WebSocket RPC client, lists specs and workspace state through `session.*` / `workspace.*` projection handlers, can attach read-only views to explicit spec/session resources, renders a transcript and the persistent chrome region, and does not treat the WebSocket connection or `.brunch/state.json` default as the durable Brunch session. Structured elicitation prompts/responses plus freeform user input remain deferred until a write-lease or equivalent concurrency policy is designed. +- **Verification:** Inner gate plus WebSocket/handler contract tests. Middle — manual browser smoke paired with projection/query postconditions for `session.*` / `workspace.*`, linear transcript-policy guards, transcript rendering state, and structured elicitation round-trip. Outer — at least one fixture replays into the web renderer; qualitative UX remains manual checklist. +- **Cross-cutting obligations:** Preserve the single command/event substrate: the browser is a thin remote head over the same elicitation/transcript/session machinery, not a second data plane, REST-backed read client, generic read gateway, or custom interaction contract. Treat WebSocket connections as ephemeral client attachments, not Brunch sessions; session-consuming RPC methods should target explicit spec/session resources or a deliberate attachment handshake. Carry D24-L linear transcript policy forward before adding another session-consuming surface: block Brunch-controlled `/tree`/`/fork`/`/clone` branch flows where Pi hooks permit, and make transcript readers fail fast on non-linear JSONL rather than adapting it. If/when `brunch.establishment_offer` entries are present, browser chrome should project the latest offer as ambient orientation rather than inventing a browser-only strategy menu. +- **Traceability:** R4, R8, R11, R12, R16, R17 / D5-L, D10-L, D12-L, D13-L, D19-L, D24-L, D33-L / I19-L, I21-L +- **Design docs:** [prd.md §M3, §Frontend Architecture](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) +- **Current execution pointer:** complete. M3 tied off with shared JSON-RPC protocol helpers/dispatch semantics, `ws`-backed `/rpc` transport, persistent browser RPC client with protocol-failure hardening, canonical built asset serving with traversal-safe asset resolution, stable React runtime, explicit read-only session projection by durable session id through a canonical Brunch session-envelope reader with strict self-description validation, explicit transcript custom-entry classifiers, and read-only browser transcript rendering of assistant/user rows plus transcript-native prompt display rows from typed `{ sessionId, specId }` targets. Automated verification and direct HTTP/WebSocket projection postconditions pass. Accepted outer-loop deferral: qualitative browser-open smoke remains environment-blocked because `agent-browser` cannot create its socket directory under the current macOS sandbox (`Operation not permitted`); this does not block M3 tie-off because static HTML serving, absence of HTTP product reads, explicit `{ sessionId, specId }` WebSocket RPC reads, transcript-display text including custom prompt rows, and exchange projection were rechecked directly against the host. diff --git a/memory/PLAN.md b/memory/PLAN.md index c163c6f5..6a236f04 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,31 +14,31 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell. The active risk is now Pi wrapping: FE-744 must finish the structured-question / Pi-RPC JSON-editor fallback proof, then the new sealed-profile/runtime-state frontier must lock down ambient Pi isolation plus transcript-backed operational mode / role preset / strategy / lens state before graph tools and authority-gated agent work depend on those seams. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 must finish Brunch-owned structured-elicitation relay semantics and recover the branded product chrome, then `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ## Sequencing ### Active -1. `pi-ui-extension-patterns` — Continue FE-744 for the POC-critical structured elicitation loop: Pi-native structured question/tool exchange → input-replacing TUI custom UI or Pi-RPC JSON-editor fallback → self-contained `toolResult.details` / linked structured response → elicitation-exchange projection through Brunch's single public RPC surface. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof: expose public Brunch product-surface relay semantics for pending structured elicitation, and recover the branded/themed persistent Brunch chrome rather than the current diagnostic dump. ### Next 1. `sealed-pi-profile-runtime-state` — Seal Brunch's embedded Pi profile and transcript-backed runtime-bundle state before future agent-loop work depends on ambient-safe settings, prompt composition, or tool gating. 2. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions and the sealed-profile/runtime-state follow-up is scoped. -3. `agent-graph-integration` — M5. Graph tools and observer extraction through pi extension seams; all writes via the shared command layer. +3. `agent-graph-integration` — M5. Graph tools, synchronous elicitor capture, review-set acceptance, and reviewer advisory writes through pi extension seams; all writes via the shared command layer. ### Parallel / Low-conflict -- `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier; can proceed independently once `walking-skeleton` exists. Briefs are text, no code dependency. +- `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier. Briefs are text and can proceed independently of current Pi-wrapping work. - `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. -- `subagents-for-proposal-diversity` — Optional enhancement to candidate-proposal generation (D44-L). Lands when `agent-and-graph-integration` (M5) is far enough along that generative-lens proposal flow exists and would benefit from parallel data-gathering; never a blocker. +- `subagents-for-proposal-diversity` — Optional enhancement to candidate-proposal generation (D44-L). Lands when `agent-and-graph-integration` (M5) is far enough along that batch-proposal flow exists and would benefit from parallel data-gathering; never a blocker. ### Horizon - `authority-model` — M6. Three-tier policy (autonomous / requires-confirmation / human-only) end-to-end across modes. - `turn-boundary-reconciliation` — M7. Graph-revision tracking, session interest sets, `worldUpdate` injection, and the mention-staleness hint synthesiser. -- `coherence-first-class` — M8. Synchronous structural legality + stored semantic coherence verdicts visible to UI and agent. +- `coherence-first-class` — M8. Clarify the product meaning of coherence, then implement synchronous structural legality plus stored semantic coherence verdicts visible to UI and agent. - `compaction-and-conflict-widening` — M9. Compaction preserves graph + coherence anchors; interest sets can widen; conflict signals remain intelligible at long horizons. - `flue-pattern-adoption` — Sandbox abstraction (SessionEnv/SandboxApi style), remote-deploy shape, MCP adapter. Post-POC. - `oracle-design-plan-graphs` — Lift oracle / design / plan planes from stub status to durable persistence + commands. Post-POC. @@ -47,70 +47,6 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th ## Frontier Definitions -### walking-skeleton - -- **Name:** Walking skeleton — `brunch` binary + TUI over pi -- **Linear:** [FE-729](https://linear.app/hash/issue/FE-729) (sub-issue of FE-702) -- **Branch:** `ln/fe-729-walking-skeleton` (off `next`) -- **Kind:** structural -- **Status:** done (bootstrap slice landed on `next` as commit `b104fc40`; coordinator/runbook and TUI boot/chrome slices landed on the frontier branch; manual M0 smoke + store-only runbook oracle passed) -- **Objective:** Prove the wrapping model works at all: a `brunch` binary launches a pi-backed TUI session through the `WorkspaceSessionCoordinator`, scopes durable state to `.brunch/`, hardcodes Brunch's prompt and curated toolset, and mounts the persistent TUI chrome and spec-selector gate. -- **Why now / unlocks:** First architectural proof of D1-L (depend on `pi-coding-agent`) and D2-L (opinionated product, not pi shell). Unlocks every subsequent milestone. Also doubles as the Phase-3 infra bootstrap (package.json, tsconfig, oxlint/oxfmt, vitest). -- **Acceptance:** `brunch` launches a TUI session in a project directory; `.brunch/` is created; boot routes through a `WorkspaceSessionCoordinator` that returns `ready | select_spec | needs_human`; the spec-selector is presented before any agent loop runs when no bound spec is ready; the selected spec is written as the session's `brunch.session_binding`; `/new` creates another session bound to the same spec rather than mutating the current session's spec; the chrome region displays cwd / spec / phase / runtime bundle at all times; `npm run verify` is green. -- **Verification:** Inner — `npm run fix` / `npm run verify` plus coordinator state/unit tests. Middle — M0 runbook oracle: manual TUI smoke against a scratch project paired with artifact/query postconditions for `.brunch/`, `brunch.session_binding`, same-spec `/new`, and chrome/workspace state (SPEC §Runbook Oracle Design). Outer — defer; first replay-regression fixture lands in M1. -- **Cross-cutting obligations:** Preserve the `cwd → spec → session` hierarchy, one-spec-per-session binding, and persistent chrome region as durable product surfaces, not temporary bootstrapping hacks. Do not let TUI, RPC, or fixture code create/open Pi sessions or write `brunch.session_binding` directly; route boot, spec selection, and `/new` through the workspace-session seam. -- **Traceability:** R1, R2, R3, R4, R19 / D1-L, D2-L, D6-L, D11-L, D21-L / I8-L, I13-L / A1-L, A10-L -- **Design docs:** [prd.md §M0](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §3](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) -- **Current execution pointer:** complete; proceed to `mode-shell-and-fixture-driver`. - -### mode-shell-and-fixture-driver - -- **Name:** Mode shell (print + rpc) and first fixture driver -- **Linear:** [FE-735](https://linear.app/hash/issue/FE-735/mode-shell-and-fixture-driver-m1) (sub-issue of FE-702) -- **Branch:** `ln/fe-735-mode-shell-fixture-driver` (stacked on `ln/fe-729-walking-skeleton`) -- **Kind:** structural -- **Status:** done -- **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression fixtures for at least briefs #1–#3. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. -- **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. -- **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.brunch-fixtures/`; the first three curated briefs are captured. -- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./runbooks/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the runbook. -- **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. -- **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L -- **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) -- **Current execution pointer:** complete after M1 review fixes; proceed to `jsonl-session-viability`. - -### jsonl-session-viability - -- **Name:** JSONL session viability proof -- **Linear:** [FE-736](https://linear.app/hash/issue/FE-736/jsonl-session-viability-proof) -- **Branch:** `ln/fe-736-jsonl-session-viability` (stacked on `ln/fe-735-mode-shell-fixture-driver`) -- **Kind:** structural -- **Status:** done -- **Objective:** Prove whether pi `SessionManager` JSONL in `.brunch/sessions/` is rich enough to carry raw assistant/user payloads, Brunch session binding (`brunch.session_binding`), structured elicitation prompt/response entries when needed, other custom entries (`brunch.lens_switch`, `brunch.side_task_result`, `worldUpdate`, `brunch.mention`, `brunch.mention_staleness_hint`), and session-scoped continuity metadata (`lastSeenLsn`, interest sets, compaction anchors) through reload. -- **Why now / unlocks:** Validated the JSONL-first transcript strategy and pinned D6-L for Brunch-supported linear sessions. If JSONL had been insufficient, M2 would have produced a sharply scoped fallback proposal that all later milestones could plan against. -- **Acceptance:** Round-trip reload of a captured linear session preserves raw payloads byte-equivalent (modulo timestamps); session binding and structured elicitation entries survive; elicitation exchanges can be re-projected after reload; all named Brunch custom entries survive, including side-task-result delivery entries when present; continuity metadata survives. Defensive branch-shape tests document Pi substrate behavior, but branch-aware Brunch sessions are not product-supported per D24-L. If core linear-session viability fails, the failure is sharply documented and a fallback path is proposed (project richer substrate / mirror JSONL into richer records / propose pi upstream change). -- **Verification:** Inner — verify gate plus synthetic JSONL projection tests. Middle — JSONL round-trip/property tests for raw payloads, `brunch.session_binding`, structured elicitation entries, defensive branch-shape projection behavior, coordinator-created `/new` sessions, and M1 fixture replay parity. Outer — fixture replay parity across the transcript-first run bundle; no new human review was required because brief content and scripted user notes did not change. -- **Cross-cutting obligations:** This frontier is the transcript-side proof for the shared event substrate that later carries structured elicitation entries, session binding, lens switches, side-task results, mentions, and `worldUpdate` without inventing a parallel channel or canonical chat/turn store. JSONL viability must validate sessions created through the `WorkspaceSessionCoordinator`, including the first-entry binding and `/new` same-spec behavior. -- **Traceability:** R7, R8, R16, R17, R19 / D6-L, D11-L, D12-L, D13-L, D18-L, D24-L / I3-L, I8-L, I10-L, I19-L -- **Design docs:** archived [jsonl-session-viability-note](file:///Users/lunelson/Code/hashintel/brunch-next/archive/archive/docs/architecture/jsonl-session-viability-note.md) -- **Current execution pointer:** complete; proceed to `web-shell`. - -### web-shell - -- **Name:** Web shell over the same host (M3) -- **Linear:** [FE-737](https://linear.app/hash/issue/FE-737/web-shell-over-the-same-host-m3) -- **Branch:** `ln/fe-737-web-shell` -- **Kind:** structural -- **Status:** done -- **Objective:** `brunch --mode web` serves a native Brunch React app (TanStack Router + Query) over one WebSocket-backed JSON-RPC client; no second backend API, REST read model, or browser-owned product runtime is invented; `pi-web-ui` is not used. The web surface is initially a read-only visual dashboard/client attachment over explicit spec/session resources, so a TUI can remain the interactive writer while the browser renders richer projections. -- **Why now / unlocks:** Proves D10-L. Unlocks parallel UI work and visualises graph + coherence state. Sequenced after M2 so the transcript substrate is pinned before clients depend on it. -- **Acceptance:** Web client connects via one persistent WebSocket RPC client, lists specs and workspace state through `session.*` / `workspace.*` projection handlers, can attach read-only views to explicit spec/session resources, renders a transcript and the persistent chrome region, and does not treat the WebSocket connection or `.brunch/state.json` default as the durable Brunch session. Structured elicitation prompts/responses plus freeform user input remain deferred until a write-lease or equivalent concurrency policy is designed. -- **Verification:** Inner gate plus WebSocket/handler contract tests. Middle — manual browser smoke paired with projection/query postconditions for `session.*` / `workspace.*`, linear transcript-policy guards, transcript rendering state, and structured elicitation round-trip. Outer — at least one fixture replays into the web renderer; qualitative UX remains manual checklist. -- **Cross-cutting obligations:** Preserve the single command/event substrate: the browser is a thin remote head over the same elicitation/transcript/session machinery, not a second data plane, REST-backed read client, generic read gateway, or custom interaction contract. Treat WebSocket connections as ephemeral client attachments, not Brunch sessions; session-consuming RPC methods should target explicit spec/session resources or a deliberate attachment handshake. Carry D24-L linear transcript policy forward before adding another session-consuming surface: block Brunch-controlled `/tree`/`/fork`/`/clone` branch flows where Pi hooks permit, and make transcript readers fail fast on non-linear JSONL rather than adapting it. If/when `brunch.establishment_offer` entries are present, browser chrome should project the latest offer as ambient orientation rather than inventing a browser-only strategy menu. -- **Traceability:** R4, R8, R11, R12, R16, R17 / D5-L, D10-L, D12-L, D13-L, D19-L, D24-L, D33-L / I19-L, I21-L -- **Design docs:** [prd.md §M3, §Frontend Architecture](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) -- **Current execution pointer:** complete. M3 tied off with shared JSON-RPC protocol helpers/dispatch semantics, `ws`-backed `/rpc` transport, persistent browser RPC client with protocol-failure hardening, canonical built asset serving with traversal-safe asset resolution, stable React runtime, explicit read-only session projection by durable session id through a canonical Brunch session-envelope reader with strict self-description validation, explicit transcript custom-entry classifiers, and read-only browser transcript rendering of assistant/user rows plus transcript-native prompt display rows from typed `{ sessionId, specId }` targets. Automated verification and direct HTTP/WebSocket projection postconditions pass. Accepted outer-loop deferral: qualitative browser-open smoke remains environment-blocked because `agent-browser` cannot create its socket directory under the current macOS sandbox (`Operation not permitted`); this does not block M3 tie-off because static HTML serving, absence of HTTP product reads, explicit `{ sessionId, specId }` WebSocket RPC reads, transcript-display text including custom prompt rows, and exchange projection were rechecked directly against the host. - ### sealed-pi-profile-runtime-state - **Name:** Sealed Pi profile and transcript-backed runtime state @@ -118,13 +54,13 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** structural hardening - **Status:** not-started - **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into flat product modules under `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. -- **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, observer/reviewer jobs, and authority gating depend on the embedded harness. -- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, capture/reviewer jobs, and authority gating depend on the embedded harness. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. - **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md) -- **Current execution pointer:** product extension/component port queue and runtime-state card queue complete: `src/pi-extensions.ts` now aggregates flat product modules for command policy, session lifecycle, chrome, workspace dialog, operational-mode tool policy, mention autocomplete, and alternatives; reusable TUI components live under `src/pi-components`; operational-mode owns `brunch.agent_runtime_state` projection, prompt/tool posture, init snapshots, and validated switch snapshots. Immediate UI correction before continuing profile audit: rename/reframe the current workspace dialog around SPEC D11-L/D36-L terminology (`workspace(cwd) → spec → session`) and reshape it into the hierarchical spec/session selection model: optional continue-last fast path; create spec → name it → implicit first session; resume existing spec → choose spec from a scrollable selector → create new session or resume existing session → choose session. Preserve RPC/headless startup as structured initial-selection state/results, not a TUI picker. Follow-up in the same frontier: add best-effort lifecycle-generated session display names over Pi `session_info`, likely triggered from `session_shutdown` and modeled after the local `summarize.ts` extension's cheap-model summarization pattern, so picker lists can distinguish sessions by meaning rather than UUID alone. Then scope the settings/resource audit: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, then seal or document the remaining `SettingsManager` leakage. +- **Current execution pointer:** do not start this frontier until FE-744 closes the remaining product-surface relay and chrome-recovery seams. Then scope the profile audit first: preserve current `noContextFiles`/`noExtensions`/`noPromptTemplates`/`noSkills`/`noThemes` posture, prove extension-factory resource injection is intentional, and seal or document the remaining `SettingsManager` leakage. Follow-up slices should add any best-effort lifecycle-generated session display names over Pi `session_info` and tighten prompt/tool policy around transcript-backed runtime bundles. ### graph-data-plane @@ -132,12 +68,12 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** [FE-741](https://linear.app/hash/issue/FE-741/graph-data-plane-intent-first-workspace-graph-ready-m4) - **Branch:** `ln/fe-741-graph-data-plane` (stacked on `ln/fe-737-web-shell`) - **Kind:** structural -- **Status:** next / paused until FE-744 structured-question proof and the sealed-profile/runtime-state follow-up are scoped +- **Status:** next / paused until FE-744 product relay/chrome recovery closes and the sealed-profile/runtime-state follow-up is scoped - **Objective:** Stand up SQLite-backed graph persistence; durable intent-plane nodes and edges; a single global LSN per commit; the change log; the reconciliation-need substrate; named homes for coherence state (verdicts and violations) — all forward-compatible with oracle, design, and plan planes. - **Why now / unlocks:** Pins I1-L, I6-L. Unlocks all agent ↔ graph work (M5+) and lets oracle / design / plan planes be added later without re-foundation. - **Acceptance:** Graph CRUD + change-log replay tests pass through the `CommandExecutor` public mutation boundary; command results already include success, `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` shapes even if pre-M6 policy classification is minimal; reconciliation-need substrate accepts inserts/updates/resolutions with LSN invariants enforced; oracle-plane stub tables exist (Check, Validation Method, Evidence, Obligation) even if unused; the persistence layer proves the one-transaction protocol that couples authority/result classification, version checks, structural validation, LSN allocation, change-log append, and any coherence updates. - **Verification:** Inner gate plus command/result schema/type tests. Middle — property/model-based tests on LSN monotonicity, graph replay, reconciliation invariants, framing matrix, and `CommandExecutor` transaction/result behavior; architectural no-bypass tests. Outer — fixture property invariants on reconciliation-substrate begin running. -- **Cross-cutting obligations:** Establish the Drizzle + `better-sqlite3` persistence shape, `CommandExecutor` result contract, and no-bypass transaction rule as shared infrastructure for later direct-agent, observer-job, side-task, migration, and UI-attributed writes. Derive row/insert/update runtime schemas from Drizzle table definitions via TypeBox (`drizzle-orm/typebox` if A20-L resolves to the Drizzle 1.0 beta line; standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` otherwise) — do not hand-author parallel row schemas. Land the I26-L grep-based architectural test alongside the first Drizzle import so the single-schema-vocabulary boundary stays enforced. +- **Cross-cutting obligations:** Establish the Drizzle + `better-sqlite3` persistence shape, `CommandExecutor` result contract, and no-bypass transaction rule as shared infrastructure for later direct-agent, elicitor-capture, deferred observer/auditor, side-task, migration, and UI-attributed writes. Derive row/insert/update runtime schemas from Drizzle table definitions via TypeBox (`drizzle-orm/typebox` if A20-L resolves to the Drizzle 1.0 beta line; standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` otherwise) — do not hand-author parallel row schemas. Land the I26-L grep-based architectural test alongside the first Drizzle import so the single-schema-vocabulary boundary stays enforced. - **Traceability:** R7, R9, R13 / D3-L, D4-L, D6-L, D8-L, D9-L, D16-L, D20-L, D41-L / I1-L, I6-L, I7-L, I11-L, I26-L / A3-L, A4-L, A20-L - **Design docs:** [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [pi-seam-extensions.md §Graph clock, §Reconciliation-need substrate, §Oracle plane](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) - **Current execution pointer:** start by scoping the narrow `CommandExecutor` result contract and one-transaction LSN/change-log skeleton before widening CRUD or coherence homes. Pair the first slice with an A20-L spike (Drizzle 1.0 beta + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` round-trip) so the version pin and schema-derivation path are settled before later slices import them broadly. @@ -148,11 +84,11 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** unassigned - **Kind:** structural - **Status:** not-started -- **Objective:** Brunch installs graph tools through pi's extension seams; agent graph operations, observer-extraction writes, reviewer-attributed advisory writes, generative-lens batch acceptances, and the transcript-native establishment/intent-hint surfaces all route exclusively through the Brunch-owned command layer and shared event substrate; web, TUI, and agent all observe the same changes. -- **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; generative-lens proposals carry explicit grounding-bundle coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; an observer job can process a projected elicitation exchange and either write high-confidence graph changes or surface low-confidence suggestions/reconciliation work through the same executor; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a generative-lens batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and (if M3 lands) web client; if the registry lands here, side-task-attributed writes follow the same command-executor path. -- **Verification:** Inner — verify gate plus graph-tool/observer/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, observer-job idempotence/restart tests keyed by exchange range, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first generative-lens fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / observer / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. -- **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, observer, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; observer and reviewer jobs are durable operational queue entries keyed to transcript anchors, not a revived chat/turn store or privileged write path for background work; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes observer vs reviewer consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. -- **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L / A3-L, A11-L, A13-L, A14-L, A16-L +- **Objective:** Brunch installs graph tools through pi's extension seams; agent graph operations, elicitor post-exchange capture writes, reviewer-attributed advisory writes, review-set batch acceptances, spec readiness grade/posture updates, and the transcript-native establishment/intent-hint surfaces all route exclusively through the Brunch-owned command layer and shared event substrate; web, TUI, and agent all observe the same changes. +- **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-question preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. +- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-question `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first batch-proposal fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. +- **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. +- **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) ### subagents-for-proposal-diversity @@ -162,7 +98,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** optional enhancement - **Status:** deferred (lands when `agent-and-graph-integration` is far enough along to benefit; never a blocker for M0–M9) - **Objective:** Register a single `subagent` Pi tool per D44-L so the main agent can (a) fan out blocking data-gathering calls (scout / researcher / graph-reader) in parallel to ground proposals, then (b) fan out parallel `proposer` invocations to generate diverse candidate variants — the subagent realization of `ln-design`'s "design it twice" pattern and `ln-oracles`'s parallel-fan-out — and finally compose `brunch.review_set_proposal` entries from those variants via the D31-L meta-rubric. Subagent results return as tool content; no `CommandExecutor` access; no Brunch RPC access; isolated `pi --no-session --no-skills --no-extensions` subprocesses inheriting Brunch Pi Profile sealing. -- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/pi-extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one generative-lens fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. +- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/pi-extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one batch-proposal fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. - **Verification:** Inner — `subagent` tool argv-shape tests; TypeBox schema validation of agent frontmatter and `config.json`; per-starter-agent tool-allowlist conformance (proposer must have an empty tool set). Middle — isolation audit (no ambient `.pi/` resources reachable; parent `CommandExecutor` / Brunch RPC handlers absent from subprocess environment); subprocess streaming / abort propagation tests; parallel-fan-out independence test (two `proposer` invocations with distinct framings produce structurally distinct outputs). Outer — proposal-generation fixture invokes scout/researcher/graph-reader to ground, then parallel `proposer` variants, and surfaces the composed review-set proposal with grounding-bundle coverage and `epistemic_status` consistent with the gathered evidence; meta-rubric application visible in the comparison rendering. - **Cross-cutting obligations:** Preserve the single-authority mutation rule (`CommandExecutor` only — subagents never bypass it) and the sealed Pi Profile (no ambient `.pi/` leakage through the subprocess boundary). Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. Worker-style write-capable subagents are deferred until an execute operational mode exists. - **Traceability:** R20 / D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L, D44-L / I2-L, I11-L, I24-L, I29-L @@ -202,7 +138,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Objective:** Structural legality enforced synchronously; semantic coherence stored as explicit product state; UI and agent read the same coherence verdict; before-images available where needed. - **Acceptance:** "Contradictory requirements" adversarial brief produces an `incoherent` verdict with a backing open reconciliation need; coherence verdict surfaces in the TUI chrome and in `graph.*` reads. - **Verification:** Inner gate plus structural validator tests. Middle — coherence-emission property tests proving backing reconciliation needs and projection/query visibility. Outer — adversarial fixture for contradictory requirements plus manual UI checklist for visible coherence verdict. -- **Cross-cutting obligations:** Coherence verdicts must remain visible through the same transcript/graph authority model that side tasks, elicitation exchanges, observer jobs, and reconciliation needs already use; this frontier must not hide coherence behind a private subsystem. +- **Cross-cutting obligations:** Coherence verdicts must remain visible through the same transcript/graph authority model that side tasks, elicitation exchanges, deferred audit/reviewer jobs, and reconciliation needs already use; this frontier must not hide coherence behind a private subsystem. - **Traceability:** R12, R14 / D8-L / I6-L - **Design docs:** [pi-seam-extensions.md §Reconciliation-need substrate](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) @@ -213,9 +149,9 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Kind:** structural - **Status:** not-started - **Objective:** Compaction preserves graph, coherence, and continuity anchors per D43-L; interest sets can widen beyond direct reads when needed; conflict signaling remains intelligible at long horizons. -- **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; the auto-compaction extension renders the configured preserved-anchor set byte-stable so active spec, in-flight side-task / observer-job / reviewer-job bookkeeping, latest `brunch.agent_runtime_state`, latest `brunch.establishment_offer`, latest `brunch.lens_switch`, unresolved staleness hints, and active review-set leaves remain intelligible after compaction; ambient-affordance chrome continues to render the current offer; auto-compaction failure falls through to Pi default compaction rather than dropping anchors silently. -- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/observer/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. -- **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/observer/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. The auto-compaction extension is the canonical owner of `session_before_compact`; product code paths that touch compaction must compose with it rather than register a parallel hook. +- **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; the auto-compaction extension renders the configured preserved-anchor set byte-stable so active spec, in-flight side-task / deferred-auditor-job / reviewer-job bookkeeping, latest `brunch.agent_runtime_state`, latest `brunch.establishment_offer`, latest `brunch.lens_switch`, unresolved staleness hints, and active review-set leaves remain intelligible after compaction; ambient-affordance chrome continues to render the current offer; auto-compaction failure falls through to Pi default compaction rather than dropping anchors silently. +- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/deferred-auditor/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. +- **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/deferred-auditor/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. The auto-compaction extension is the canonical owner of `session_before_compact`; product code paths that touch compaction must compose with it rather than register a parallel hook. - **Traceability:** R15 / D6-L, D15-L, D43-L / I12-L, I28-L - **Design docs:** [prd.md §Continuity, Divergence, and Coherence](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) @@ -319,36 +255,26 @@ Older history: `docs/archive/PLAN_HISTORY.md` ## Dependencies ```text -walking-skeleton +pi-ui-extension-patterns (active FE-744) │ - ├── mode-shell-and-fixture-driver - │ │ - │ ├── jsonl-session-viability - │ │ │ - │ │ ├── web-shell (M3, can run parallel after M2) - │ │ │ - │ │ ├── pi-ui-extension-patterns (parallel after M2; informs profile/M5/M6/M7) - │ │ │ │ - │ │ │ └── sealed-pi-profile-runtime-state - │ │ │ │ - │ │ │ ├── graph-data-plane - │ │ │ │ │ - │ │ │ │ ├── agent-graph-integration - │ │ │ │ │ │ - │ │ │ │ │ ├── authority-model - │ │ │ │ │ │ - │ │ │ │ │ └── turn-boundary-reconciliation - │ │ │ │ │ │ - │ │ │ │ │ └── coherence-first-class - │ │ │ │ │ │ - │ │ │ │ │ └── compaction-and-conflict-widening - │ │ │ │ │ - │ │ │ │ └── (oracle-design-plan-graphs — horizon) - │ │ │ - │ │ └── brief-library-curation (parallel after M0) - │ - └── fixture-strategy-evolution (continuous, doc-only) - -(flue-pattern-adoption, framework-direction-stubs, geolog-and-petri-execution - are horizon items; not on the active dependency spine.) + └── sealed-pi-profile-runtime-state + │ + ├── graph-data-plane + │ │ + │ ├── agent-graph-integration + │ │ │ + │ │ ├── authority-model + │ │ │ + │ │ └── turn-boundary-reconciliation + │ │ │ + │ │ └── coherence-first-class + │ │ │ + │ │ └── compaction-and-conflict-widening + │ │ + │ └── (oracle-design-plan-graphs — horizon) + │ + └── subagents-for-proposal-diversity (optional after M5 pressure) + +brief-library-curation and fixture-strategy-evolution remain parallel/continuous. +flue-pattern-adoption, framework-direction-stubs, and geolog-and-petri-execution are horizon items, not on the active dependency spine. ``` diff --git a/memory/SPEC.md b/memory/SPEC.md index c875d050..3f6b9473 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -20,7 +20,7 @@ Brunch is an opinionated local product that helps a human and an agent co-author a **specification** as a graph-native artifact inside the current working directory. It runs as a single installable CLI over the `pi-coding-agent` harness and exposes one host through four presentation modes (TUI, web, RPC, print). The intent graph is canonical specification meaning; oracle, design, and plan graphs are accountable downstream planes. Coherence is shared product state, not an implicit hope. -The POC's purpose is to prove three things: (a) that pi's coding-agent harness can be the substrate without forking it; (b) that a graph-native spec workspace plus a JSONL-first transcript can coexist coherently under one mutation authority; (c) that elicitation-first sessions can project inspectable prompt/response exchanges for observer extraction, replay, and fixture pressure without reintroducing a parallel chat/turn store. +The POC's purpose is to prove three things: (a) that pi's coding-agent harness can be the substrate without forking it; (b) that a graph-native spec workspace plus a JSONL-first transcript can coexist coherently under one mutation authority; (c) that elicitation-first sessions can project inspectable prompt/response exchanges for synchronous capture, replay, and fixture pressure without reintroducing a parallel chat/turn store. ### Constraints & Non-goals @@ -72,13 +72,13 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for observer extraction, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. -21. Brunch must distinguish *extractive* lenses (single-exchange, observer-extracted) from *generative* lenses (batch-proposal, captured at proposal time as structured entity-draft payloads, reviewer-analyzed post-acceptance). -22. Brunch must establish a minimum grounding bundle (domain, protagonist, pain/pull, and constraint anchors) before generative lenses produce non-speculative output; lenses remain always-available with epistemic-status signaling honestly reflecting grounding density. -23. Brunch must support a review-cycle acceptance pattern for generative-lens proposals — approve / request changes (triggering regeneration) / reject — with batch acceptance committed atomically as one CommandExecutor call; partial acceptance is not representable. +21. Brunch must distinguish single-exchange elicitation flows from batch-proposal/review-set flows by capture and commitment mechanism: single-exchange answers are captured synchronously by the elicitor at turn boundaries, while batch proposals carry structured entity-draft payloads and are committed only through review-set approval. +22. Brunch must maintain spec-owned readiness grade and elicitation posture as forward gates inside the `elicit` operational mode. Grounding establishes the frame required for main elicitation; later grades unlock commitment and planning/export/execute posture without forbidding earlier gathering or refinement. +23. Brunch must support a review-cycle acceptance pattern for batch proposals and commitment review sets — approve / request changes (triggering regeneration) / reject — with batch acceptance committed atomically as one CommandExecutor call; partial acceptance is not representable. #### Verification & fixtures @@ -107,14 +107,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | | A10-L | A persistent TUI chrome region showing cwd / spec / phase / runtime bundle can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | -| A13-L | A durable observer-job queue keyed by session id and elicitation-exchange entry range can recover async extraction after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | M5: observer extraction tests exercise restart/idempotence once graph writes exist. | -| A14-L | LLM elicitor agents can reliably produce graph-structurally-legal intent-graph proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation) for generative lenses. | medium | open | D27-L | Fixture replay across briefs that exercise `propose-scenarios-with-tradeoffs`-shaped lenses; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | +| A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and elicitation-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | +| A14-L | LLM elicitor agents can reliably produce graph-structurally-legal review-set proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation). | medium | open | D27-L | Fixture replay across briefs that exercise batch-proposal and commitment review-set flows; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | | A15-L | Establishment hints as transcript-native custom entries (`brunch.establishment_offer`) provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms lens offers are reconstructable from transcript; chrome region renders ambient affordances from the latest such entry. | -| A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — observer/reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | -| A17-L | A user-level temperamental preference for extractive vs generative lenses meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both lens families exist in product. | +| A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | +| A17-L | A user-level temperamental preference for interrogative vs proposal-based elicitation meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both single-exchange and batch-proposal flows exist in product. | | A18-L | Hiding unsupported Pi built-ins from autocomplete plus blocking dangerous session effects is sufficient for the POC product shell even though exact interactive built-ins remain callable until Pi exposes command policy. | medium | open | D2-L, D24-L, D34-L, D35-L | `pi-ui-extension-patterns` product-shell review after command-containment and dynamic Brunch chrome evidence; strict suppression requires a Pi upstream/API change if residual exposure is unacceptable. | | A19-L | Pi's current settings/resource lifecycle can be made product-safe through a sealed Brunch Pi Profile without forking Pi: ambient discovery remains disabled, Brunch-owned extension factories may inject explicit resources, and remaining settings/keybinding leakage can be eliminated through programmatic policy or a narrow upstream seam. | medium | open | D39-L | FE-744/profile audit: source-backed resource-loader/settings audit, tests proving no ambient `.pi/` skills/prompts/themes/extensions/context files affect Brunch, and product-owned resources still load when intentionally injected. | | A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | +| A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | +| A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness/posture updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | open | D18-L, D26-L, D45-L, I30-L | M5 agent-graph-integration fixtures and review: compare elicitor-captured graph updates against transcript evidence; track over-capture, missed obvious facts, and whether preface-led disambiguation resolves low-confidence material without an async observer owning primary extraction. | ### Active Decisions @@ -123,7 +125,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. -- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. @@ -131,14 +133,14 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D3-L — Graph-native, session-native vocabulary; no generic `records.*` surface.** Commands converge on `graph.*` / `session.*` (with per-plane families `intent.*`, `oracle.*`, `design.*`, `plan.*` available when sharper semantics are useful). Depends on: A6-L. Supersedes: —. - **D7-L — `framing_as` modality, not first-class kinds, for product-intent framings.** Product framings (problem, persona, JTBD, non-goal, etc.) are an orthogonal modality on existing intent/constraint node kinds, gated by an allowed matrix. Depends on: A7-L. Supersedes: —. -- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same global LSN as the change log and follow the same mutation invariant. Depends on: A8-L. Supersedes: —. +- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and a bounded coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same global LSN as the change log and follow the same mutation invariant. For the POC, coherence is not an unbounded aesthetic or philosophical judgment; it is the product-visible verdict produced from structural legality plus surfaced contradictions/gaps/unresolved needs, with the exact rubric still open under A21-L until M8. Depends on: A8-L, A21-L. Supersedes: —. - **D9-L — Reasoning records split by shape.** `decision` is graph-native; `impasse` is a reconciliation need, not a graph node; `justification` stays compact (rendered text on the decision) until forced otherwise. Depends on: D8-L. Supersedes: —. #### Authority & mutation - **D4-L — One shared mutation surface owns graph truth.** Every semantic graph mutation routes through Brunch-owned typed command handlers responsible for validation, structural legality, optimistic concurrency, event emission, audit attribution, and coherence triggering. Agents and adapters must not touch the ORM or SQLite directly. Depends on: A3-L. Supersedes: —. -- **D20-L — Command execution owns the pre-M6 authority seam.** Callers submit product commands to a Brunch `CommandExecutor` and receive a structured result; they do not call a standalone authority service or graph persistence directly. The executor is the public mutation boundary that hides attribution, optimistic concurrency, structural validation, the minimal pre-M6 policy classifier, transaction execution, LSN allocation, change-log append, and coherence-trigger hooks. Before M6, the policy logic may be deliberately small, but the result shape must already include `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` so early RPC, print, agent-tool, observer-job, and side-task code cannot bake in permissive mode-specific shortcuts. Depends on: D4-L, D16-L. Supersedes: the separate optional `AuthorityGate` / generic policy-service mental model. -- **D27-L — Generative-lens proposals are structured entity-draft payloads; batch acceptance is one atomic `CommandExecutor` call.** The elicitor's proposal custom entry (`brunch.review_set_proposal`) contains the graph entities and edges that *would* be created on acceptance, in a form `CommandExecutor` can dry-run-validate at proposal time so `structural_illegal` / `policy_blocked` discriminants surface before the user reviews. Only proposals that pass this dry-run validation are surfaced as user-reviewable review sets; invalid generations stay internal to retry/regeneration paths rather than becoming review UI state. Acceptance is one `acceptReviewSet` command that consumes one LSN, writes the entire batch in one transaction, appends one change-log entry attributed to the user, triggers coherence updates, and enqueues the reviewer job. "Accept with edits" does not exist as a primitive: the cycle is approve / request changes (triggers regeneration of a successor proposal) / reject. Depends on: A14-L, D4-L, D20-L, D26-L. Supersedes: any caller-side multi-step "patch then commit" mental model. +- **D20-L — Command execution owns the pre-M6 authority seam.** Callers submit product commands to a Brunch `CommandExecutor` and receive a structured result; they do not call a standalone authority service or graph persistence directly. The executor is the public mutation boundary that hides attribution, optimistic concurrency, structural validation, the minimal pre-M6 policy classifier, transaction execution, LSN allocation, change-log append, and coherence-trigger hooks. Before M6, the policy logic may be deliberately small, but the result shape must already include `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` so early RPC, print, agent-tool, deferred observer/auditor, and side-task code cannot bake in permissive mode-specific shortcuts. Depends on: D4-L, D16-L. Supersedes: the separate optional `AuthorityGate` / generic policy-service mental model. +- **D27-L — Review-set proposals are structured entity-draft payloads; batch acceptance is one atomic `CommandExecutor` call.** The elicitor's proposal custom entry (`brunch.review_set_proposal`) contains the graph entities and edges that *would* be created on acceptance, in a form `CommandExecutor` can dry-run-validate at proposal time so `structural_illegal` / `policy_blocked` discriminants surface before the user reviews. Only proposals that pass this dry-run validation are surfaced as user-reviewable review sets; invalid generations stay internal to retry/regeneration paths rather than becoming review UI state. Acceptance is one `acceptReviewSet` command that consumes one LSN, writes the entire batch in one transaction, appends one change-log entry attributed to the user, triggers coherence updates, and enqueues any reviewer job. "Accept with edits" does not exist as a primitive: the cycle is approve / request changes (triggers regeneration of a successor proposal) / reject. Applies to batch-proposal flows and commitment review sets. Depends on: A14-L, D4-L, D20-L, D26-L. Supersedes: any caller-side multi-step "patch then commit" mental model. #### Transport & client @@ -146,7 +148,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. - **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. - **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, and later `elicitation.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. -- **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `observer`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. +- **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/state.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. Product RPC / Pi relay model: @@ -193,15 +195,15 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. - **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Side tasks are main-agent-invoked, non-blocking work items: the main agent fires them and continues without awaiting a return value. A Brunch-owned `SideTaskRegistry` tracks status; the only path a side task influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary through the existing `prepareNextTurn` path — never mid-turn. Side-task writes remain subject to the same command-layer authority as primary-agent writes. This is distinct from D44-L Subagent (main-agent-invoked **blocking** tool call whose result is returned directly as tool content). Depends on: A11-L, D4-L. Supersedes: —. - **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions via TypeBox per D41-L; the Drizzle version pin is open per A20-L. Depends on: A3-L, A4-L. Refined by: D41-L. Supersedes: —. -- **D18-L — Observer extraction is exchange-keyed durable work, not a chat/turn store.** After a user response closes an elicitation exchange, Brunch may enqueue an observer job keyed by session id plus exchange entry ids; jobs survive process restart and graph writes still route through the command layer. Routine observer jobs are operational queue state, not reconciliation needs by default; low-confidence or conflicting findings may create reconciliation needs. Depends on: A13-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model. +- **D18-L — Post-exchange capture is synchronous elicitor work for the POC; observer/auditor queues are deferred backstops, not primary extraction authority.** After a user response closes an elicitation exchange, the elicitor may run a post-exchange capture step in the same turn-boundary flow: commit high-confidence extractive facts, concrete reconciliation needs, and spec readiness/posture updates through the `CommandExecutor`; fold low-confidence implications into later questions rather than graph truth. Brunch may still introduce durable observer/auditor jobs keyed by session id plus exchange entry ids for restartable audit, quality checks, or later backfill, but those jobs are not the load-bearing path for keeping the next turn's world fresh. Any async job writes still route through the command layer and remain operational queue state unless they surface semantic work as reconciliation needs. Depends on: A13-L, A22-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model and the earlier observer-owned primary extraction path. - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. -- **D29-L — Reviewer is an `observer`-shaped agent role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. +- **D29-L — Reviewer is an async advisory role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. - **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a TypeBox schema per D41-L when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. #### Schema & validation -- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/pi-extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, observer/reviewer-job result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. +- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/pi-extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, capture/reviewer/deferred-auditor result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. #### Interaction & UI shape @@ -211,17 +213,20 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. - **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured questions and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies causal/positional context, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured question. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries or as ephemeral dialog results detached from transcript truth. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-question tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. -- **D13-L — Capture-aware elicitation exchange projection.** Observer extraction consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-question toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-question results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. +- **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-question toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-question results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. -- **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent role, not separate roles or operational modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); roles (`elicitor`, `observer`, `reviewer`, `reconciler`) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Observer-job and reviewer-job routing filters on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent role into one vocabulary axis. -- **D26-L — Lenses split into *extractive* and *generative* families by capture mechanism.** Extractive lenses produce single-exchange interactions whose implicit content is captured by the `observer` role post-exchange (e.g. `step-by-step`, `disambiguate-via-examples`). Generative lenses produce batch proposals whose entity-draft payloads are captured by the elicitor *at proposal time*, with the `reviewer` role running advisory analysis post-acceptance (e.g. `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`). The family distinction is durable; the specific lens list is expected to evolve. Depends on: D18-L, D25-L. Supersedes: a single uniform "agent asks questions" mental model. -- **D30-L — Grounding is a precondition gate for generative-lens output, with epistemic-status signaling honestly tracking grounding density; lenses themselves are always available.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — must be established before generative lenses produce non-speculative output. Generative-lens proposals declare `epistemic_status` (`inferred | assumed | asserted | observed`) consistent with grounding density at proposal time, and proposal/offer payloads carry explicit grounding-bundle coverage for those four anchors so UI copy, fixture assertions, and reviewer/debug tooling can justify that status rather than infer it from free text. UI renderings reflect this status so low-status proposals *feel* speculative (visible hedging, lower visual weight, explicit "speculative — based on N anchors so far" footers). The lens is never refused: the agent always produces *some form* of what was asked for, but its output resolution and epistemic load honestly reflect what grounding supports. Rendering mode scales with density: empty/thin → framing proposals (Shape Up pitches); moderate → scenario sketches; rich → completion proposals; mature → refactor proposals. Depends on: D26-L. Supersedes: gating-by-refusal as a UX move. +- **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent role, not separate roles or operational modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); roles (`elicitor`, `reviewer`, `reconciler`, and any deferred observer/auditor roles) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Capture, review, and future audit routing may filter on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent role into one vocabulary axis. +- **D26-L — Elicitation flows split by capture and commitment mechanism, not by a hard extractive/generative phase boundary.** Single-exchange flows (`step-by-step`, many `disambiguate-via-examples` prompts, and ordinary structured questions) are captured synchronously by the elicitor post-exchange per D18-L. Batch-proposal flows (`propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`) carry structured entity-draft payloads at proposal time and become durable only through review-set approval. Design/oracle lenses may appear during ordinary elicitation before any commitment posture; later commitment posture changes what can be pinned, not what topics may be explored. Depends on: D18-L, D25-L, D45-L. Supersedes: a single uniform "agent asks questions" mental model and the observer-owned extractive vs elicitor-owned generative split as the primary architecture. +- **D30-L — Grounding advances readiness for main elicitation; strategies remain available with honest epistemic signaling.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — establishes the frame required to move the spec from `grounding_onboarding` toward `elicitation_ready`. Lenses and strategies are not refused merely because grounding is thin, but their output resolution and epistemic load must honestly reflect what grounding supports: speculative outputs are visibly hedged and lower-authority, while grounded outputs may drive capture and later review-set projection. Grounding coverage should be explicit in offers/proposals where it affects confidence or gate transitions. Depends on: D26-L, D45-L. Supersedes: gating-by-refusal as a UX move and over-focusing readiness on generative lenses alone. - **D32-L — Establishment offers are orientation artifacts, not a default next-action menu.** `brunch.establishment_offer` records the agent's current offer tree and recommended next move as durable transcript state. Ambient chrome or web affordances may render the latest offer, and Brunch may expose a user-invoked orientation view summarizing what is established vs open, but Brunch does not surface an exhaustive lens/offer chooser by default; the agent still owns next-move selection unless the user explicitly asks to inspect alternatives. Depends on: D25-L, D30-L, A15-L. Supersedes: UI interpretations that turn establishment offers into a persistent strategy menu. -- **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. +- **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows is more useful than per-flow improvisation) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. +- **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. +- **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. +- **D47-L — Structured-question `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-question payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. - **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/pi-extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a generative-lens-shaped frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. - This division mirrors the generative-lens family in D26-L: `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, and `propose-oracle-ensembles` are the natural lenses that delegate to fan-out `proposer` invocations; `project-requirements-from-upstream` may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. + - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. + This division mirrors the batch-proposal flow in D26-L: `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, and `propose-oracle-ensembles` are the natural lenses that delegate to fan-out `proposer` invocations; `project-requirements-from-upstream` may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. - **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. - **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. @@ -239,16 +244,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only runbook checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | | I9-L | Every `brunch.mention` payload resolves a transcript `#` handle to a stable graph entity id; the ledger never stores title-anchored references or relies on autocomplete popup metadata. | planned (M7 invariant) | D14-L | | I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported elicitation exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | -| I11-L | No durable graph mutation path — including migrations, maintenance scripts, observer-job writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | +| I11-L | No durable graph mutation path — including migrations, maintenance scripts, elicitor-capture writes, deferred observer/auditor writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | | I12-L | Side-task results are delivered only at turn boundaries; no side-task result may steer or mutate the active turn outside the next-turn delivery path. | planned (M7 side-task delivery invariant) | D15-L | | I13-L | At any idle linear session leaf, the latest unresolved interaction state is system/assistant-originated: user input is a response to an elicitation prompt, not ambient chat. | planned (M1 fixture + transcript projection tests) | D12-L, D24-L | -| I14-L | Observer jobs are keyed by session id plus elicitation-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate observer jobs for the same exchange. | planned (M5 observer queue tests) | D18-L, D4-L | +| I14-L | If Brunch introduces deferred observer/auditor jobs, they are keyed by session id plus elicitation-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate jobs for the same exchange, and job writes never become the primary freshness path for the next elicitor turn. | deferred/planned only if observer-audit queue lands (M5+ restart/idempotence tests) | D18-L, D4-L | | I15-L | Every review-set acceptance routes through `CommandExecutor` as one atomic `acceptReviewSet` command producing one LSN, one change-log entry, and one transaction over the entire batch. Partial acceptance is not representable through any product API. | planned (M5+ batch-acceptance command tests; review-set fixture parity) | D20-L, D27-L; I1-L, I11-L | | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | -| I17-L | Every generative-lens proposal entry (`brunch.review_set_proposal`) declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and explicit grounding-bundle coverage for the four grounding anchors, with the status consistent with that coverage at proposal time; UI renderings honor this status as a presentation contract. | planned (M5+ proposal-entry schema test; fixture asserts status under thin and rich grounding) | D30-L; A14-L | -| I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; observer-job and reviewer-job routing filters on this field. | planned (M5+ observer/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | +| I17-L | Every batch-proposal or commitment review-set entry (`brunch.review_set_proposal`) declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and enough grounding/support coverage to justify that status at proposal time; UI renderings honor this status as a presentation contract. | planned (M5+ proposal-entry schema test; fixture asserts status under thin and rich grounding) | D30-L, D46-L; A14-L | +| I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; capture, reviewer, and future observer/auditor routing filters on this field. | planned (M5+ capture/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | -| I20-L | Every user-reviewable generative-lens proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | +| I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor helper tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; `npm run test:structured-question-rpc-proof` live-proves Pi RPC `extension_ui_request(editor)` / `extension_ui_response(value)` at the adapter layer; elicitation-exchange projection tests cover terminal structured-question tool results as response-side JSONL entries while ordinary tool results remain prompt-side. Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | @@ -258,6 +263,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | +| I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-question preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | +| I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | ## Future Direction Register @@ -271,7 +278,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **Plan execution & Petri-net compatibility.** Plan-graph compiled alongside an execution petri-net carrying colored tokens that refer back to plan nodes by ID. Currently exploratory; not part of POC scope. - **Context subsystem.** Acknowledged as large-scope; deferred. Brunch may stub minimal structure (e.g. an explicit per-turn `Context` namespace under `prepareNextTurn`) without implementing the full subsystem. - **Capability tiers** (distinct from authority tiers). A future second axis classifying what an agent *can* do versus what it *may* do. Stub deferred. -- **Candidate artefacts.** Pre-graph, agent-proposed or observer-proposed nodes/edges awaiting user adjudication. Low-confidence observer findings may flow here or into reconciliation needs; routine observer jobs themselves remain operational queue state unless future pressure justifies a more generic work-item substrate. +- **Candidate artefacts.** Pre-graph, agent-proposed nodes/edges awaiting user adjudication. Low-confidence elicitor or future auditor findings may eventually flow here or into reconciliation needs, but the POC keeps ordinary low-confidence implications in structured-question preface/question material until pressure justifies a more generic candidate/work-item substrate. ### Adoption patterns from Flue @@ -281,10 +288,15 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Prompt/runtime profile architecture -- Brunch prompt composition should be explicit and layered: base Brunch product prompt + operational-mode prompt pack + top-level role preset + strategy prompt pack + lens prompt pack + spec phase/maturity/gates + current graph/coherence/world state + pending structured-interaction rules. -- Spec phase/maturity is provisionally elicitor-assigned with heuristic assistance rather than purely hidden state or purely derived inference; later validators may warn when transcript/graph evidence and assigned maturity diverge. +- Brunch prompt composition should be explicit and layered: base Brunch product prompt + operational-mode prompt pack + top-level role preset + strategy prompt pack + lens prompt pack + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules. +- Readiness is an internal forward gate, not a user-facing workflow stepper or session-local phase. `readiness_grade` and `elicitation_posture` live on the spec row per D45-L; validators may warn when graph/transcript evidence and assigned grade/posture diverge. Before these fields drive hard tool/agent authority beyond the POC, Brunch needs explicit rubrics for what evidence advances, blocks, or regresses grade/posture. - Core role/lens prompting should usually be product prompt packs rather than Pi skills. Pi skills remain available as Brunch-owned explicit resources when progressive disclosure is the right mechanism, but they are not the primary authority for operational mode/tool policy. +### Coherence and readiness semantics + +- Coherence must remain bounded for the POC: a visible verdict tied to structural legality and actionable reconciliation needs, not a vague promise that the specification “makes sense.” M8 owns the sharper rubric and adversarial examples. +- Avoid phase/stage/maturity language for the elicit lifecycle except when referring to legacy docs. The canonical internal model is readiness grade plus elicitation posture. PLAN/frontier text should describe concrete grade/posture gates rather than imply a user-facing phase machine. + ### Vocabulary evolution - Whether public graph commands eventually split from one `graph.*` umbrella into `intent.*` / `oracle.*` / `design.*` / `plan.*` namespaces is deferred; current posture is unified `graph.*` for the POC. @@ -309,7 +321,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Durable state framing -- Brunch's durable state is intentionally split across four semantic substrates: graph truth (nodes/edges), `change_log` audit/history, `coherence_state` verdict, and `reconciliation_need` actionable semantic queue. Routine async work such as observer jobs may use a separate operational queue; if later generalized, table naming may become `work_item` with subtypes, but the POC should not make every observer job a reconciliation need. +- Brunch's durable state is intentionally split across four semantic substrates: graph truth (nodes/edges), `change_log` audit/history, `coherence_state` verdict, and `reconciliation_need` actionable semantic queue. Routine async work such as deferred auditor/reviewer jobs may use a separate operational queue; if later generalized, table naming may become `work_item` with subtypes, but the POC should not make every async job a reconciliation need. ### Chrome surface evolution @@ -323,12 +335,17 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Brunch host** | The local process-level authority. Owns `.brunch/` resolution, agent session lifecycle, mode dispatch, and event fanout. | | **Transport mode** | One of TUI, web, RPC, print. All four drive the same host; they are presentation/protocol surfaces, not separate products or agent strategies. | | **Operational mode** | A top-level Brunch authority/tooling posture such as `elicit` or future `execute`. It determines what kind of work is allowed and which tools/prompt posture are available. Distinct from Pi's transport mode concept. | -| **Agent role** | A worker identity within an operational mode. Top-level roles drive the main turn (`elicitor`, future `executor/orchestrator`); side roles run async or advisory work (`observer`, `reviewer`, `reconciler`, future `scout` / `researcher`). | +| **Agent role** | A worker identity within an operational mode. Top-level roles drive the main turn (`elicitor`, future `executor/orchestrator`); side roles run async/advisory or delegated work (`reviewer`, `reconciler`, deferred observer/auditor, future `scout` / `researcher`). | | **Runtime bundle / role preset** | The transcript-backed Brunch selection that derives active operational mode, top-level role, model, thinking level, prompt packs, allowed strategies/lenses, and tool policy. Commands switch bundles instead of mutating hidden extension memory. | | **Strategy** | A conversation or work tactic selected within the active runtime bundle. Strategies control interaction plan; lenses control interpretive/extraction/review framing. | | **Lens** | A narrower interpretive, extraction, or review framing applied within a role/strategy, such as technical-design, verification-design, or disambiguation. Lenses may eventually be driven by Brunch-owned prompt packs or skills. | | **Brunch Pi Profile** | The sealed programmatic wrapper around embedded Pi: settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. It allows Brunch-owned resources while suppressing ambient `.pi/` behavior. | -| **Prompt pack** | A Brunch-owned prompt fragment selected by operational mode, role preset, strategy, lens, or spec phase/maturity. Prompt packs compose at turn boundaries; they are product control-plane state, not ambient Pi prompt templates. | +| **Prompt pack** | A Brunch-owned prompt fragment selected by operational mode, role preset, strategy, lens, readiness grade, or elicitation posture. Prompt packs compose at turn boundaries; they are product control-plane state, not ambient Pi prompt templates. | +| **Readiness grade** | Spec-owned forward gate stored on the spec row: `grounding_onboarding | elicitation_ready | commitments_ready | planning_ready`. It unlocks later strategies, commitment review sets, and eventual export/plan/execute posture, but never forbids earlier gathering or refinement. | +| **Elicitation posture** | Spec-owned current stance inside `elicit`: `gathering | refining | pinning`. Semi-independent from readiness grade; a high-grade spec may still return to gathering/refining when new ambiguity appears. | +| **Commitment focus** | Optional/deferred target for `pinning` posture (`design | oracle`) if active review-set state and missing-commitment analysis cannot make the focus obvious. Not required as canonical state now. | +| **Coherence** | Bounded product-visible verdict over whether the current spec graph is structurally legal and free of known unresolved contradictions/gaps at the current maturity. It is backed by reconciliation needs and remains intentionally narrower than a general judgment that the whole idea is good or complete. | +| **Structural legality** | Synchronous schema/ontology validity of graph mutations: allowed node/edge shapes, required fields, framing matrix, and transaction invariants. Structural legality can fail even before semantic coherence is evaluated. | | **Print snapshot** | The M1 meaning of the print transport mode: boot the Brunch host, resolve workspace/spec/session state through the coordinator, render product-shaped state, and exit without running an agent turn. | | **Workspace** | The current working directory where the Brunch CLI was invoked. It scopes `.brunch/` state for the launch context. It is not user-created, not selectable within the dialog, and there is only one active workspace per Brunch process. The UI may display a project identity/name derived from cwd-local manifests or directory basename, but that name labels the cwd; it does not create a separate workspace object. | | **Spec / specification** | The user-created specification container within a workspace, identified by its intent-graph root. Multiple specs may coexist under one workspace. A spec contains sessions and the graph data gathered through those sessions (intent nodes, design nodes, oracle/plan data as they land). Future plan-execution mode operates on a selected spec. | @@ -346,7 +363,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Plan graph** | Milestone/frontier/slice delivery claims accountable to intent, oracle, and design. Stubbed in POC. | | **LSN** | Log Sequence Number. A single monotonic counter, one-LSN-per-commit, shared by the change log, graph-node versions, and reconciliation needs. | | **Change log** | The audit trail of graph mutations. Authoritative for replay, `worldUpdate` synthesis, and reconciliation-need ordering. | -| **Reconciliation need** | First-class record of an open impasse, gap, contradiction, or process debt; carries `created_at_lsn`, optional `resolved_at_lsn`, `concerns` edges to graph nodes. Routine observer jobs are not reconciliation needs unless they surface semantic work to resolve. | +| **Reconciliation need** | First-class record of an open impasse, gap, contradiction, or process debt; carries `created_at_lsn`, optional `resolved_at_lsn`, `concerns` edges to graph nodes. Routine async jobs are not reconciliation needs unless they surface semantic work to resolve. | | **Coherence verdict** | Per-spec product state (`coherent` / `incoherent`) emitted by validators and visible to both UI and agent. | | **Command layer** | The single Brunch-owned mutation surface. Validates, gates concurrency, audits, emits events, triggers coherence. Its public mutation entry point is the `CommandExecutor`, not direct ORM calls or caller-side authority gates. | | **Command executor** | The deep module that accepts Brunch product commands plus execution context and returns structured command results (`ok`, `needs_human`, `policy_blocked`, `version_conflict`, `structural_illegal`). It hides attribution, minimal pre-M6 authority classification, validation, transaction, LSN, change-log, and coherence-trigger mechanics from callers. | @@ -359,19 +376,20 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Canonical store** | The persistence surface that owns a fact: Pi JSONL for session transcript truth, `.brunch/state.json` for lightweight workspace binding state, SQLite graph/change log for graph truth and coherence substrates. | | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | -| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-question results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-question toolResult details). This is the observer's default extraction unit. | +| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-question results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-question toolResult details). This is the default post-exchange capture unit. | | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | +| **Structured-question preface** | Plain prose in a structured-question payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | | **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-question tools. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | -| **Observer job** | Durable async work item keyed by session id and elicitation-exchange entry-range ids. It analyzes an exchange for graph mutations or low-confidence suggestions, and survives process restart. | +| **Deferred observer/auditor job** | Optional durable async work item keyed by session id and elicitation-exchange entry-range ids. If introduced, it audits or backfills exchange analysis and survives process restart, but it is not the primary path for next-turn graph freshness. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | Main-agent-invoked, non-blocking work item tracked by the Brunch `SideTaskRegistry`. The main agent fires it and does not await a return value; the only path it influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary via `prepareNextTurn`. Side-task writes route through the `CommandExecutor`. Distinct from Subagent (blocking) and Side chat (user-invoked). | | **Subagent** | Main-agent-invoked, **blocking** Pi tool call (`subagent`) that runs an isolated `pi` subprocess with a per-agent tool allowlist and per-agent model. Has no inherited conversation context, no `CommandExecutor` access, and no Brunch RPC access. Result text returns directly as tool result content. POC starter agents split into **data gatherers** (scout / researcher / graph-reader — read-only context fetchers that ground proposals) and a **variant proposer** (proposer — system-prompt-only; one variant per invocation, fan-out via parallel mode realizes the "design it twice" pattern). | -| **Proposer subagent** | The system-prompt-only starter subagent that emits exactly one well-formed candidate-proposal variant per invocation given a grounding bundle plus a generative-lens-shaped frame. Diversity arises from parallel `tasks: []` invocations with intentionally distinct framings; the main agent assembles outputs into a `brunch.review_set_proposal` via the D31-L meta-rubric. Realizes the "design it twice" / parallel-fan-out pattern from `ln-design` and `ln-oracles` skills in subagent form. | +| **Proposer subagent** | The system-prompt-only starter subagent that emits exactly one well-formed candidate-proposal variant per invocation given a grounding bundle plus a batch-proposal lens frame. Diversity arises from parallel `tasks: []` invocations with intentionally distinct framings; the main agent assembles outputs into a `brunch.review_set_proposal` via the D31-L meta-rubric. Realizes the "design it twice" / parallel-fan-out pattern from `ln-design` and `ln-oracles` skills in subagent form. | | **Subagent registry** | The set of registered subagent definitions loaded from `src/pi-extensions/subagents/agents/*.md` at extension activation. Brunch-owned only for the POC; cross-extension agent registration is deferred. | | **Subagent agent definition** | A markdown file with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. The frontmatter is the registry contract; the body is the subagent's standing instructions. | | **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/pi-extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active runtime bundle; falls through to Pi default compaction on auth/empty-output/unexpected errors. | @@ -385,16 +403,17 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | | **Brief** | A short curated product brief in `.brunch-fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | | **Capture / Run / Fixture** | A captured agent-as-user run produces a `.jsonl` transcript, `.graph.json`, `.coherence.json`, and `.meta.json` bundle under `.brunch-fixtures/<brief-id>/<run-id>/`. | -| **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent-modes (`elicitor` / `observer` / `reviewer` / `reconciler`) remain orthogonal. | -| **Extractive lens** | A lens producing single-question / single-answer exchanges; implicit content is captured post-exchange by the `observer` role. Low cognitive load per move; small graph mutations. | -| **Generative lens** | A lens producing batch proposals (structured entity-draft payloads in `brunch.review_set_proposal` entries); proposals are captured by the elicitor at proposal time, with the `reviewer` role running advisory analysis post-acceptance. Higher cognitive load per move; large graph mutations on acceptance. | -| **Grounding bundle** | The minimum set of session-level anchors required before generative lenses produce non-speculative output: a *domain anchor*, a *protagonist anchor*, a *pain/pull anchor*, and a *constraint anchor*. Captured technical constraints land in the constraint anchor and bound subsequent technical-design fan-outs. | +| **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent roles (`elicitor` / `reviewer` / `reconciler` / deferred observer-auditor roles) remain orthogonal. | +| **Single-exchange elicitation flow** | A prompt/answer exchange such as step-by-step questioning or contrastive disambiguation. The elicitor captures high-confidence extractive content synchronously post-exchange; low-confidence implications stay in preface/question material. | +| **Batch-proposal flow** | A proposal/review flow with structured entity-draft payloads in `brunch.review_set_proposal` entries. Durable graph changes land only through review-set approval. | +| **Grounding bundle** | The minimum set of anchors required to establish the frame for main elicitation: a *domain anchor*, a *protagonist anchor*, a *pain/pull anchor*, and a *constraint anchor*. Captured technical constraints land in the constraint anchor and bound subsequent technical-design fan-outs. | | **Grounding anchor** | One sentence-scale fact captured during early elicitation that contributes to the grounding bundle. | | **Establishment offer** | A `brunch.establishment_offer` custom transcript entry summarising the elicitor's perceived gaps, the available lens strategies for the next move, the recommended lens, and the agent's confidence. Source of ambient affordances rendered in the chrome region; inspectable post-hoc and fixture-able. Orientation artifact, not a default exhaustive strategy menu. | -| **Elicitor intent hint** | A `brunch.elicitor_intent_hint` custom transcript entry emitted alongside a prompt or proposal, declaring `lens` and semantic targets (e.g. expected ontological sub-type) for downstream observer/reviewer routing and extraction guidance. | -| **Review set** | A batch proposal generated by a generative lens, presented to the user for review-cycle acceptance (approve / request changes / reject), modeled on the GitHub PR-review-cycle. | -| **Batch acceptance** | The single `CommandExecutor` call (`acceptReviewSet`) that commits an entire review set atomically as one LSN and one change-log entry, attributed to the user. The only mutation a generative-lens acceptance produces. | -| **Reviewer** | An agent role that runs async after batch acceptance, scoped to the accepted batch plus graph neighborhood, analyzing for coherence / completeness / gaps. Authority is narrow: writes only `reconciliation_need` records via `CommandExecutor`. Architecturally a mirror of `observer`. | +| **Elicitor intent hint** | A `brunch.elicitor_intent_hint` custom transcript entry emitted alongside a prompt or proposal, declaring `lens` and semantic targets (e.g. expected ontological sub-type) for downstream capture/reviewer/future-auditor routing and extraction guidance. | +| **Review set** | A cohesive batch proposal presented to the user for review-cycle acceptance (approve / request changes / reject), modeled on the GitHub PR-review-cycle. Used for batch-proposal flows and for design/oracle commitment pinning. | +| **Commitment review set** | A focus-primary review set in `pinning` posture: design-oriented sets primarily pin requirement/invariant-like intent claims; oracle-oriented sets primarily pin criterion/check/example-like verification claims. Support/provenance edges are part of the accepted batch. | +| **Batch acceptance** | The single `CommandExecutor` call (`acceptReviewSet`) that commits an entire review set atomically as one LSN and one change-log entry, attributed to the user. Partial acceptance and accept-with-edits are not product operations. | +| **Reviewer** | An agent role that runs async after batch acceptance, scoped to the accepted batch plus graph neighborhood, analyzing for coherence / completeness / gaps. Authority is narrow: writes only `reconciliation_need` records via `CommandExecutor`. | | **Anchor scenario** | A particular vignette embedded inside one alternative pitch to ground its framing. Transcript-rendered; not persisted as a graph entity. | | **Contrastive scenario** | A particular vignette distinguishing two alternatives, surfaced in comparison UI. Transcript-rendered. | | **Probing scenario** | A particular vignette posed by the elicitor to force a user response that disambiguates intent. Transcript-rendered; user response persists per existing elicitation mechanics. | @@ -427,8 +446,8 @@ The structural/behavioral split is the key discipline: never let a behavioral fi | Dimension | Score | Notes | Raised by | | --- | --- | --- | --- | | Observability | partial, improving to high by M4/M5 | Text-native artifacts are planned (`.brunch/state.json`, Pi JSONL, command results, graph exports, coherence exports, fixture bundles). Generative-lens material adds further text-native surfaces: `brunch.review_set_proposal`, `brunch.establishment_offer`, `brunch.elicitor_intent_hint` entries plus reviewer-finding `reconciliation_need` records. *Structural* observability is high; *behavioral* observability (proposal quality, lens-recommendation appropriateness, reviewer precision) remains low and outer-loop only. M0 TUI chrome and M3 browser UX remain partly visual unless paired with artifact/query checks. | Runbook oracles; projection handlers; graph/coherence exports; transcript projection of lens/establishment/proposal entries. | -| Reproducibility | partial | Fixture briefs and captured runs create a repeatable path. M1/M2 proved the agent-as-user harness and JSONL projection/reload discipline. LLM runs remain variable, so deterministic postcondition checks and property assertions are required; generative-lens flows additionally need seeded multi-run probes to characterize structural-legality rate at all. Driver extension for review-cycle flows (approve / request-changes / reject) is conditional on cost being worth the controllability gain. | Deterministic runbook checks; captured-run metadata; replay/property fixtures; (planned) review-cycle driver extension. | -| Controllability | partial → high (conditional) | `npm run fix` / `npm run verify` are agent-controllable. The agent-as-user stdio RPC driver covers extractive-lens flows end-to-end; extending it to drive review-cycle acceptance/regeneration would lift generative-lens controllability to "high" but carries implementation cost. TUI/browser/manual flows for ambient affordances, in-flight reviewer signals, and chrome rendering remain runbook-oracle territory. | Store/projection postcondition checkers; stdio/WebSocket drivers; (planned) review-cycle driver extension; runbook oracles for chrome surfaces. | +| Reproducibility | partial | Fixture briefs and captured runs create a repeatable path. M1/M2 proved the agent-as-user harness and JSONL projection/reload discipline. LLM runs remain variable, so deterministic postcondition checks and property assertions are required; batch-proposal/review-set flows additionally need seeded multi-run probes to characterize structural-legality rate at all. Driver extension for review-cycle flows (approve / request-changes / reject) is conditional on cost being worth the controllability gain. | Deterministic runbook checks; captured-run metadata; replay/property fixtures; (planned) review-cycle driver extension. | +| Controllability | partial → high (conditional) | `npm run fix` / `npm run verify` are agent-controllable. The agent-as-user stdio RPC driver covers single-exchange flows end-to-end; extending it to drive review-cycle acceptance/regeneration would lift batch-proposal/review-set controllability to "high" but carries implementation cost. TUI/browser/manual flows for ambient affordances, in-flight reviewer signals, and chrome rendering remain runbook-oracle territory. | Store/projection postcondition checkers; stdio/WebSocket drivers; (planned) review-cycle driver extension; runbook oracles for chrome surfaces. | ### Verification Commands @@ -460,9 +479,9 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | -| Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For generative lenses: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | +| Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -494,11 +513,11 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I11-L | M4/M5 no-bypass architectural test plus command transaction integration tests. | | I12-L | M7 side-task delivery invariant tests and adversarial fixture when side tasks are active. | | I13-L | M1 fixture/projection checks for idle linear-session leaf state. | -| I14-L | M5 observer-job restart/idempotence tests. | +| I14-L | Deferred unless observer/auditor queue lands: restart/idempotence tests over exchange-keyed jobs, plus proof that next-turn freshness does not depend on the async job completing. | | I15-L | M5+ middle-loop property tests for batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible under mid-batch validation failure) paired with `acceptReviewSet` contract tests; review-set fixture parity in replay. | | I16-L | M5+ middle-loop architectural boundary test on reviewer-attributed `CommandExecutor` writers (rejects any non-`reconciliation_need` target); paired with reviewer-attributed command-result audit fixture. | | I17-L | M5+ inner-loop schema validation on `brunch.review_set_proposal` entries (must declare `epistemic_status`); paired with outer-loop fixture assertion that status varies appropriately with grounding density (POC-phase fitness, not gate). | -| I18-L | M5+ inner-loop schema validation on elicitor-emitted custom entries (must declare `lens`); paired with middle-loop property test that generated entries route to the correct observer/reviewer consumer. | +| I18-L | M5+ inner-loop schema validation on elicitor-emitted custom entries (must declare `lens`); paired with middle-loop property test that generated entries route to the correct capture/reviewer/future-auditor consumer. | | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | @@ -508,6 +527,8 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I28-L | Inner — TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/pi-extensions/subagents/agents/*.md` frontmatter and `src/pi-extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | +| I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | +| I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | ### Design Notes From 8bafc0530e861005db8cbf510f9741547bdeea19 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 11:32:46 +0200 Subject: [PATCH 095/164] plan adjustment re priority of falsification of assumptions --- memory/PLAN.md | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 6a236f04..873ed2b9 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -16,6 +16,34 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 must finish Brunch-owned structured-elicitation relay semantics and recover the branded product chrome, then `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +### POC assumption pressure + +The POC should maximize assumption falsification rather than merely implement milestone labels. Treat the table below as the live consequence map from SPEC assumptions to frontier pressure; when scoping a frontier, prefer the thinnest slice that can validate or falsify its assigned assumptions. + +| Assumption | Pressure / what could falsify it | Plan consequence | +| --- | --- | --- | +| A1-L Pi substrate seams | A needed host/session/RPC/extension seam cannot be expressed without forking Pi. | Mostly exercised by M0-M3; FE-744 and `sealed-pi-profile-runtime-state` close the remaining UI/profile seams before graph-agent work depends on them. | +| A3-L command layer sufficiency | Agent, UI, reviewer, or capture writes need shortcuts around one `CommandExecutor`. | `graph-data-plane`, `agent-graph-integration`, and `authority-model` must prove one command boundary for every write path. | +| A4-L global LSN adequacy | Replay, staleness, or reconciliation ordering needs per-entity/vector clocks. | `graph-data-plane` establishes one-LSN-per-transaction; `turn-boundary-reconciliation` tries to break it with cross-session traces. | +| A5-L fixture driver quality | Agent-as-user captures fail to catch regressions or cannot represent realistic briefs. | `brief-library-curation` and `fixture-strategy-evolution` stay live; every assumption-heavy frontier should add or update a fixture/probe. | +| A6-L unified `graph.*` namespace | Intent/oracle/design/plan semantics become confusing or unsafe under one umbrella. | `graph-data-plane` and `agent-graph-integration` should start unified but watch for namespace pressure. | +| A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus briefs #1-#7 exercise framing; promote only if fixture pressure demands it. | +| A8-L reconciliation substrate | Gaps, contradictions, process debt, and conflicts need separate substrates immediately. | `graph-data-plane` builds the shared substrate; `coherence-first-class` and known-bad briefs test subtype pressure. | +| A9-L mention ledger granularity | Session-scoped snapshots miss necessary staleness or create noisy hints. | Defer until `turn-boundary-reconciliation`, after graph ids/LSNs exist. | +| A10-L TUI chrome seam | Branded persistent chrome cannot be recovered through Pi UI primitives. | FE-744 must re-prove chrome visually/thematically, not just semantically, before closeout. | +| A11-L next-turn delivery | Side-task/reviewer results require mid-turn delivery or another event plane. | Keep deferred until M5/M7 side-task/reviewer paths exist; test at turn-boundary rendezvous. | +| A13-L deferred observer/auditor queue | Async audit/backfill needs canonical chat/turn tables or privileged writes. | Not load-bearing after D18-L; defer until a backstop queue is actually introduced. | +| A14-L review-set structural legality | LLMs cannot produce dry-run-valid entity/edge drafts reliably enough. | M5 must measure structural-legality rate and retry/fallback behavior before depending on proposal-heavy UX. | +| A15-L establishment hints | Offers are not reconstructable or useful from transcript entries alone. | M5 establishment-offer fixtures and FE-744 chrome affordances exercise this. | +| A16-L reviewer trigger/scope | Reviewer findings are too slow, noisy, or incomplete under deferred policy. | Do not overbuild early; first accepted review-set fixtures should make reviewer policy empirical. | +| A17-L elicitation temperament preference | Users do not need persistent interrogative/proposal preference. | Outer-loop adoption signal only; do not block POC. | +| A18-L command containment | Hiding suggestions + lifecycle blocking leaves unsafe Pi built-ins reachable. | FE-744 product-shell evidence must name any Pi upstream seam before M5/M6 authority work relies on it. | +| A19-L sealed Pi profile | Ambient `.pi` settings/resources still shape Brunch product behavior. | `sealed-pi-profile-runtime-state` is a gate before graph tools and authority-sensitive agent work. | +| A20-L Drizzle 1.0 beta | Beta blocks migrations, SQLite fidelity, or TypeBox derivation. | `graph-data-plane` starts with a version/schema spike before broad imports. | +| A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad fixtures earlier so the rubric is falsifiable. | +| A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture fixtures before async observer backstops are reconsidered. | + + ## Sequencing ### Active @@ -31,7 +59,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th ### Parallel / Low-conflict - `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier. Briefs are text and can proceed independently of current Pi-wrapping work. -- `fixture-strategy-evolution` — Iterate `fixture-strategy.md` (property invariants, brief expectations) as fixtures are captured. Doc-only. +- `fixture-strategy-evolution` — Keep the assumption-proof matrix honest as captures land: property invariants, brief expectations, harness notes, and known-bad probes. Doc-only, but assumption-critical. - `subagents-for-proposal-diversity` — Optional enhancement to candidate-proposal generation (D44-L). Lands when `agent-and-graph-integration` (M5) is far enough along that batch-proposal flow exists and would benefit from parallel data-gathering; never a blocker. ### Horizon @@ -76,7 +104,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Cross-cutting obligations:** Establish the Drizzle + `better-sqlite3` persistence shape, `CommandExecutor` result contract, and no-bypass transaction rule as shared infrastructure for later direct-agent, elicitor-capture, deferred observer/auditor, side-task, migration, and UI-attributed writes. Derive row/insert/update runtime schemas from Drizzle table definitions via TypeBox (`drizzle-orm/typebox` if A20-L resolves to the Drizzle 1.0 beta line; standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` otherwise) — do not hand-author parallel row schemas. Land the I26-L grep-based architectural test alongside the first Drizzle import so the single-schema-vocabulary boundary stays enforced. - **Traceability:** R7, R9, R13 / D3-L, D4-L, D6-L, D8-L, D9-L, D16-L, D20-L, D41-L / I1-L, I6-L, I7-L, I11-L, I26-L / A3-L, A4-L, A20-L - **Design docs:** [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [pi-seam-extensions.md §Graph clock, §Reconciliation-need substrate, §Oracle plane](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) -- **Current execution pointer:** start by scoping the narrow `CommandExecutor` result contract and one-transaction LSN/change-log skeleton before widening CRUD or coherence homes. Pair the first slice with an A20-L spike (Drizzle 1.0 beta + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` round-trip) so the version pin and schema-derivation path are settled before later slices import them broadly. +- **Current execution pointer:** start by scoping the narrow `CommandExecutor` result contract and one-transaction LSN/change-log skeleton before widening CRUD or coherence homes. Pair the first slice with an A20-L spike (Drizzle 1.0 beta + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` round-trip) so the version pin and schema-derivation path are settled before later slices import them broadly. Keep M4 thin enough to falsify A3-L/A4-L/A6-L/A8-L/A20-L before widening CRUD or coherence homes. ### agent-graph-integration @@ -90,6 +118,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) +- **Current execution pointer:** before implementation, run oracle/scoping pressure on A14-L and A22-L: define the smallest replay/probe set that can reveal over-capture, missed obvious facts, dry-run-invalid review-set drafts, and whether plain-prose `preface` is sufficient for low-confidence implications. ### subagents-for-proposal-diversity @@ -174,10 +203,10 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Linear:** unassigned - **Kind:** hardening - **Status:** not-started -- **Objective:** Iterate `fixture-strategy.md` — property invariants, brief expectations, harness CLI shape — as real fixtures expose gaps. -- **Acceptance:** Each milestone landing adds at least one new fixture-strategy entry (invariant, brief expectation, or harness note) or explicitly records "no change needed." -- **Verification:** PR review on the doc plus cross-check that new/changed fixture assertions map to SPEC invariants or acknowledged blind spots; downstream fixture runs catch regressions. -- **Cross-cutting obligations:** Treat fixture strategy as canonical verification architecture that must stay in sync with SPEC/PLAN, not as optional commentary. +- **Objective:** Iterate `fixture-strategy.md` as the POC assumption-proof plan: property invariants, brief expectations, harness CLI shape, known-bad probes, and per-assumption fitness notes as real captures expose gaps. +- **Acceptance:** Each assumption-heavy milestone landing adds at least one new fixture-strategy entry (invariant, brief expectation, harness note, known-bad probe, or fitness metric) or explicitly records "no change needed" for the assumptions it touched. +- **Verification:** PR review on the doc plus cross-check that new/changed fixture assertions map to SPEC assumptions/invariants or acknowledged blind spots; downstream fixture runs catch regressions and surface assumption fitness rather than only pass/fail. +- **Cross-cutting obligations:** Treat fixture strategy as canonical verification architecture that must stay in sync with SPEC/PLAN, not as optional commentary. If an assumption is not being tested by its assigned frontier, PLAN should say whether it is deferred, accepted as risk, or needs a spike/oracle pass. - **Traceability:** A5-L - **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) @@ -195,7 +224,7 @@ Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a th - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses, live Pi RPC editor fallback at the adapter layer (`npm run test:structured-question-rpc-proof`), and response-side elicitation-exchange projection for terminal structured-question results. Continue the structured-question proof with Brunch product-surface relay semantics before returning to `graph-data-plane`. +- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses, live Pi RPC editor fallback at the adapter layer (`npm run test:structured-question-rpc-proof`), and response-side elicitation-exchange projection for terminal structured-question results. Continue the structured-question proof with Brunch product-surface relay semantics and branded chrome recovery before returning to `graph-data-plane`; this is the active proof point for A10-L, A18-L, and the remaining UI/RPC slice of A19-L. ### flue-pattern-adoption From 61b67a0225f883cda38f4bda5a3674b4550bc096 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 11:38:58 +0200 Subject: [PATCH 096/164] focus the next plan on proving structured exchanges --- HANDOFF.md | 184 ------------------------------------------------- memory/PLAN.md | 12 ++-- 2 files changed, 6 insertions(+), 190 deletions(-) delete mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 859e9b40..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,184 +0,0 @@ -# Handoff - -> Generated by `ln-handoff` at 2026-05-27T18:06:40Z. Read this file to resume work. -> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. - -## Goal - -Finish FE-744's Pi UI extension-pattern proof with enough evidence to trust structured-question RPC fallback and projection, then decide whether structured-question refinements should become a new plan item before returning to graph-data-plane work. - -## Session State - -- **Last completed skill**: `ln-refactor` — planned the proof/refactor sequence for structured-question RPC sufficiency and projection findings; the builder reports completing all refactor commits and deleting `memory/REFACTOR.md`. -- **Current skill**: `ln-handoff` — capturing state for a new thread. -- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → refactor → handoff` -- **Handoff trigger**: user requested a handoff after the builder completed the structured-question proof/refactor queue. - -## In-flight work - -> CRITICAL: These artifacts exist only in the prior conversation, not on disk. -> Reproduce them here with full fidelity. - -### 2026-05-27 late update — chrome recovery was misinterpreted - -After the chrome/mention refactor queue was completed, the user manually inspected the persistent Brunch chrome and found it still looks like a plain diagnostic dump, not the intended recovered Brunch chrome. Screenshot supplied by user: - -- `/Users/lunelson/Library/Application Support/CleanShot/media/media_L5qbnxixR0/CleanShot 2026-05-27 at 21.34.52@2x.png` - -Important correction: the previous “Restore rich Brunch chrome projection” work recovered a **data model / semantic rows** (`runtime`, `context`, `state`, `spec/session`, branch/status telemetry), but **not the actual visual chrome implementation** that had existed in `.pi/extensions/brunch-chrome.ts`. The recovery was incorrectly interpreted as semantic richness rather than restoring the actual code and visual treatment. - -The next agent must go back to git history for the retired probe file and recover the **actual implementation**, especially the code using Pi/TUI theme tokens (`ctx.theme` / themed colors / branded layout), not just the plain string-array projection. Relevant history: - -- `.pi/extensions/brunch-chrome.ts` existed before retirement. -- `git log --oneline -- .pi/extensions/brunch-chrome.ts` shows: - - `6c2e3823 header and footer looking reasonable` - - `68520f66 wip on brunch pi extensions` - - `b1721c22 FE-744 retire pi probe runtime` -- Start by inspecting `git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and related nearby commits, then port the real visual code into `src/pi-extensions/chrome.ts` / a private chrome submodule as appropriate. - -Do **not** repeat the mistake of only asserting data-bearing lines. Add an oracle that would fail for the current plain dump — e.g. ANSI/themed snapshot, renderer-level assertion over theme-token usage, or a manual screenshot/runbook check paired with stable structural assertions. The target is the actual Brunch chrome UX, not merely “more fields in the footer.” - -### Completed chrome/mention structural refactor queue - -The user asked to work through `memory/REFACTOR.md` in order, committing after each item. This was completed with the following commits: - -1. `e40f4530` — Move chrome behavior tests to chrome module -2. `93507ca1` — Move mention autocomplete tests to feature module -3. `68f51402` — Expose chrome footer telemetry projection -4. `c6ed3354` — Narrow aggregate chrome exports -5. `ccc9da01` — Hide fixture mention exports -6. `e1f896a7` — Prune private chrome helper exports - -Verification was run before each commit (`npm run verify` passed each time). These commits improved module ownership/export shape but did **not** fix the visual chrome regression described above. - -### Completed refactor sequence from `memory/REFACTOR.md` - -The temporary refactor plan targeted two review findings: - -1. RPC sufficiency was not witnessed against a live Pi RPC process; fake `ctx.ui.editor` tests were not enough. -2. Elicitation-exchange projection still treated typed structured-question tool results as prompt-side entries rather than response-side entries. - -Builder reports all refactor items were completed in order and committed: - -1. `a3530c9b` — Characterize structured question terminal details -2. `0a9fdd15` — Prove structured question RPC editor fallback -3. `fc797e56` — Expose structured question RPC proof test -4. `b8916d33` — Project terminal structured question responses -5. `c49f3303` — Cover structured question JSONL projection -6. `97e66b8e` — Reconcile structured question proof evidence -7. `5035485b` — Retire structured question refactor queue - -The queue is exhausted; `memory/REFACTOR.md` and `memory/CARDS.md` do not exist. - -### Remaining live FE-744 work - -The canonical `memory/PLAN.md` current pointer now says the spec/session picker correction and structured-question adapter proof are complete through: - -- hierarchical spec/session picker; -- TypeBox-validated `workspace.selectionState` / `workspace.activate`; -- startup no-resume pty oracle; -- structured-question schema/builder; -- TUI/editor adapters; -- live Pi RPC editor fallback at the adapter layer via `npm run test:structured-question-rpc-proof`; -- response-side elicitation-exchange projection for terminal structured-question results. - -Remaining planned FE-744 seam: **Brunch product-surface relay semantics** for pending structured elicitation. The raw/private Pi RPC adapter proof is now stronger, but public Brunch clients still should not coordinate raw Pi RPC and Brunch RPC as two product APIs. `memory/PLAN.md` says: “Continue the structured-question proof with Brunch product-surface relay semantics before returning to `graph-data-plane`.” - -### User-stated pending refinements - -The user said: “I have some refinements on the whole structured question front, which perhaps need to be punted to a new plan item.” These refinements have **not** yet been captured in SPEC/PLAN because the user has not enumerated them. Next thread should ask for those refinements or route through `ln-grill`/`ln-spec` before scoping further structured-question UX changes. - -Likely interpretation: do **not** immediately keep building structured-question behavior beyond the proof seam until the user has explained the refinements. They may affect whether the remaining product relay belongs inside FE-744 or a new frontier/plan item. - -### Review findings - -> ALL findings from ln-review / ln-judo-review, not just the one being acted on. - -| # | Finding | Status | Implications | -| --- | --- | --- | --- | -| 1 | Obsolete flat picker API remained exported/tested alongside hierarchical spec/session model. | addressed | Builder committed `308b3d34`; old flat API should be gone. | -| 2 | `WorkspaceSwitchDecision` lexicon was stale because it sounded like changing workspaces rather than activating spec/session. | addressed | Builder committed rename work in the judo-fix queue; check current symbols if continuing in this seam. | -| 3 | RPC activation parsing was ad-hoc/cast-heavy and violated the new TypeBox boundary direction. | addressed | Builder committed `76fadb32`; `workspace.activate` uses TypeBox-backed parsing. | -| 4 | `createRpcHandlers` accepted partial coordinator capabilities while registering methods that required them. | addressed | Builder committed `76fadb32`; handler factory now requires explicit activation capability. | -| 5 | Picker dev build tag styling regressed by folding dev metadata into the accent version line. | addressed | Builder committed `308b3d34`; verify visually if touching picker header. | -| 6 | Persistent Brunch chrome regressed to minimal cwd/spec/session dump rather than the intended branded/theme-token chrome. | **not addressed** | Earlier commits (`13464e68`, then the chrome refactor queue) restored semantic rows/data projection only. The user confirmed the live chrome still looks wrong. Recover the actual historical `.pi/extensions/brunch-chrome.ts` implementation from git (especially `6c2e3823`) and port the real themed code, not just the data model. | -| 7 | RPC sufficiency for structured-question editor fallback was not live-proven. | addressed for adapter layer | Builder committed `0a9fdd15`/`fc797e56`; `npm run test:structured-question-rpc-proof` now live-proves Pi RPC editor fallback at adapter layer. Public Brunch product relay remains pending. | -| 8 | Elicitation-exchange projection did not classify terminal structured-question tool results as response-side entries. | addressed | Builder committed `b8916d33`/`c49f3303`; SPEC I23 marks projection tests covered for terminal structured-question results while ordinary tool results remain prompt-side. | -| 9 | Structured-question interaction model likely needs user refinements. | deferred | Needs fresh user input; probably route through `ln-grill` / `ln-spec` or `ln-plan` depending on how durable the refinements are. | -| 10 | Process mismatch: builder claimed clean state, but this session observed dirty SPEC changes at least once. | still watch | Current `git status -sb` shows `memory/SPEC.md` modified. Do not overwrite/revert without checking whether this came from parallel work. | - -### Diagnostic evidence - -- `npm run check && npx vitest --run src/structured-question.test.ts src/pi-extensions/structured-question.test.ts src/elicitation-exchange.test.ts src/rpc.test.ts` passed before the refactor proof, but that only covered helper/fake RPC behavior. -- The refactor proof added `npm run test:structured-question-rpc-proof`, and final `npm run verify` passed with `src/structured-question-rpc-proof.test.ts` included. -- Final `npm run verify` output in this session: 21 test files, 194 tests passed; build passed. -- The live RPC proof test reported: `structured-question RPC proof > round-trips an editor fallback through Pi RPC extension UI` passed. -- SPEC I23 now says structured-question coverage is partial but stronger: schema/builder, TUI adapter, JSON-over-editor helper, live Pi RPC editor proof, and response-side projection are covered; **Brunch public product relay remains pending**. -- Pi examples consulted as basis for the proof seam: `examples/extensions/question.ts`, `examples/extensions/questionnaire.ts`, `examples/extensions/rpc-demo.ts`, and `examples/rpc-extension-ui.ts` from the installed `pi-coding-agent` package. - -## Decisions and assumptions - -| Item | Type | Status | Source | -| ---- | ---- | ------ | ------ | -| Workspace means cwd/root scope, not a user-created object. | decision | persisted | `memory/SPEC.md` D11-L / Lexicon | -| Spec/session selection is hierarchical and transport-specific. | decision | persisted | `memory/SPEC.md` D36-L | -| RPC/headless startup must expose structured selection/activation, not TUI picker code. | invariant/decision | persisted | `memory/SPEC.md` D36-L / I22-L | -| Structured-question details may be canonical structured response payload for basic questions. | decision | persisted | `memory/SPEC.md` D37-L / I23-L | -| JSON-over-editor is a private Pi RPC compatibility seam, not a second product API. | decision | persisted | `memory/SPEC.md` D38-L | -| Terminal structured-question tool results are response-side projection entries; ordinary tool results remain prompt-side. | decision | persisted by code/tests and SPEC I23 evidence | refactor commits + `memory/SPEC.md` I23 | -| Public Brunch product relay for pending structured elicitation is still missing. | assumption/open work | persisted in PLAN current pointer | `memory/PLAN.md` FE-744 current execution pointer | -| User has structured-question UX refinements that may need a new plan item. | assumption/open work | volatile | conversation only; ask user next | -| Dirty `memory/SPEC.md` currently contains subagent/side-task decision edits not part of this handoff's main thread. | assumption/open process state | volatile/current filesystem | current git diff; inspect before acting | - -## Repo state - -- **Branch**: `ln/fe-744-pi-ui-extension-patterns` -- **Recent commits**: - - `5035485b` Retire structured question refactor queue - - `97e66b8e` Reconcile structured question proof evidence - - `c49f3303` Cover structured question JSONL projection - - `b8916d33` Project terminal structured question responses - - `fc797e56` Expose structured question RPC proof test - - `0a9fdd15` Prove structured question RPC editor fallback - - `a3530c9b` Characterize structured question terminal details -- **Dirty files at handoff time**: - - `memory/SPEC.md` is modified relative to HEAD. The visible diff concerns side-task/subagent decisions (`D15-L`, new `D44-L`) and appears unrelated to the structured-question proof. Confirm with the user/parallel thread before committing, reverting, or editing it. - - `HANDOFF.md` will be untracked/modified after this handoff write. -- **Test status**: `npm run verify` passed in this session after the refactor commits. 21 test files, 194 tests passed, build passed. - -## Artifact status - -| Artifact | Exists | Current vs conversation | -| --- | --- | --- | -| `memory/SPEC.md` | yes | mostly current for structured-question proof; currently dirty with unrelated-looking subagent edits that need inspection | -| `memory/PLAN.md` | yes | current for FE-744 structured-question proof; current pointer says product-surface relay remains | -| `memory/CARDS.md` | no | exhausted/deleted | -| `memory/REFACTOR.md` | no | exhausted/deleted | -| `HANDOFF.md` | yes after this write | current volatile transfer state | - -## Next steps - -1. **Start with `ln-consult` or `ln-grill` on the user's structured-question refinements.** Ask the user to enumerate the refinements before scoping more structured-question work; they may change whether the remaining product relay is FE-744 continuation or a new frontier item. -2. **Inspect the dirty `memory/SPEC.md` subagent/side-task edits before touching planning docs.** Do not revert or commit them blindly; they may be from parallel work. -3. **If refinements do not change the architecture, run `ln-scope` for the remaining FE-744 product-surface relay semantics.** The scope should distinguish private Pi RPC adapter proof (now done) from public Brunch pending-elicitation methods/events (still missing). -4. **If refinements change durable product semantics, route to `ln-spec` / `ln-plan` first.** Structured-question interaction model changes should not be hidden inside a follow-up build slice. -5. **Before tying off FE-744, run an outer/manual TUI walkthrough.** Chrome richness, spec/session picker feel, structured-question TUI feel, and final session/chrome state still benefit from qualitative smoke beyond the automated proof. - -## Retirement rule - -- Delete or overwrite this file once the volatile state above is absorbed into `memory/SPEC.md`, `memory/PLAN.md`, code, or a newer `HANDOFF.md`. - -## Open questions - -- What are the user's structured-question refinements? -- Should public Brunch product-surface relay semantics stay inside FE-744, or become a separate PLAN frontier/item? -- Are the dirty `memory/SPEC.md` subagent edits intentional parallel work, and should they be committed separately? -- Should `test:structured-question-rpc-proof` remain part of full `npm run test` permanently, or become a runbook/targeted proof if it proves host-sensitive? - -## Resume prompt - -Paste this into a new session: - -> Read `HANDOFF.md` in the workspace root for this work area. It contains the full state of FE-744 structured-question proof work. -> The immediate next step is: ask me for my structured-question refinements and decide whether they require `ln-spec`/`ln-plan` before more build work. -> Start by inspecting the current dirty `memory/SPEC.md` diff, then use `ln-grill` or `ln-consult` to route the structured-question refinement discussion. diff --git a/memory/PLAN.md b/memory/PLAN.md index 873ed2b9..56274f33 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -48,7 +48,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof: expose public Brunch product-surface relay semantics for pending structured elicitation, and recover the branded/themed persistent Brunch chrome rather than the current diagnostic dump. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof by completing the structured-exchange model across interaction surfaces: every supported structured exchange is faithfully communicated and represented in interactive TUI and RPC, RPC is exercised by an agent-as-user evaluator probe, the web UI observes real-time updates caused by TUI/RPC actions, and the branded/themed persistent Brunch chrome is recovered rather than left as a diagnostic dump. ### Next @@ -203,7 +203,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** unassigned - **Kind:** hardening - **Status:** not-started -- **Objective:** Iterate `fixture-strategy.md` as the POC assumption-proof plan: property invariants, brief expectations, harness CLI shape, known-bad probes, and per-assumption fitness notes as real captures expose gaps. +- **Objective:** Iterate `fixture-strategy.md` as the POC assumption-proof plan: property invariants, brief expectations, harness CLI shape, known-bad probes, agent-as-user evaluator probe shape (mission/intention, evaluation focus, max-turn budget, blocker/friction report), and per-assumption fitness notes as real captures expose gaps. - **Acceptance:** Each assumption-heavy milestone landing adds at least one new fixture-strategy entry (invariant, brief expectation, harness note, known-bad probe, or fitness metric) or explicitly records "no change needed" for the assumptions it touched. - **Verification:** PR review on the doc plus cross-check that new/changed fixture assertions map to SPEC assumptions/invariants or acknowledged blind spots; downstream fixture runs catch regressions and surface assumption fitness rather than only pass/fail. - **Cross-cutting obligations:** Treat fixture strategy as canonical verification architecture that must stay in sync with SPEC/PLAN, not as optional commentary. If an assumption is not being tested by its assigned frontier, PLAN should say whether it is deferred, accepted as risk, or needs a spike/oracle pass. @@ -216,15 +216,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, and evidence-memo reconciliation have landed; current missing seam is the structured-question / RPC-relay loop) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, and response-side projection have landed; current missing seams are full structured-exchange proof across TUI/RPC/web plus visual chrome recovery) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-question / RPC-relay proof: a registered Pi tool can collect text, single-select, multi-select, questionnaire, and optional-freeform answers; rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes the structured tool exchange; and Brunch exposes one public product RPC surface that can wrap Pi RPC extension-UI requests for agent-as-user probes and web relay clients. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-exchange proof: a registered Pi tool can collect every supported structured exchange shape (text/freeform, action or confirmation where supported, single-select/radio, multi-select/checkbox, questionnaire, optional freeform-plus-choice, and terminal statuses such as answered/skipped/cancelled/unavailable); rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes terminal structured results as response-side; Brunch exposes one public product RPC surface that wraps Pi RPC extension-UI requests for agent-as-user probes and web relay clients; and the web UI receives real-time product updates when TUI or RPC interactions change selected session/exchange state. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol; scripted TUI demo covering all supported structured-exchange permutations; RPC agent-as-user evaluator probe where the evaluator has a mission/intention, a critical UX or feature-evaluation focus, and a maximum turn budget, then reports blockers and frictions; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Spec/session picker correction is complete: the pure model and TUI component now use the hierarchical create-spec/resume-spec/create-session/resume-session flow, RPC/headless startup exposes TypeBox-validated `workspace.selectionState` / `workspace.activate` without importing TUI picker code, the startup no-resume pty oracle passes with the new spec/session copy, and the structured-question result schema/builder plus TUI/editor adapters now prove self-contained `toolResult.details`, model-readable `content`, input-replacing TUI answer collection, schema-tagged JSON-over-`ctx.ui.editor` validation for text/single/multi/questionnaire and terminal statuses, live Pi RPC editor fallback at the adapter layer (`npm run test:structured-question-rpc-proof`), and response-side elicitation-exchange projection for terminal structured-question results. Continue the structured-question proof with Brunch product-surface relay semantics and branded chrome recovery before returning to `graph-data-plane`; this is the active proof point for A10-L, A18-L, and the remaining UI/RPC slice of A19-L. +- **Current execution pointer:** Today's focus is structured exchanges, not graph work: prove every supported exchange type through interactive TUI and public Brunch RPC, add the agent-as-user RPC evaluator probe, and show web UI real-time updates when TUI/RPC interactions change selected session/exchange state. Then recover branded chrome before FE-744 closeout: inspect the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and port the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until this structured-exchange proof and chrome recovery close the active A10-L/A18-L/UI-RPC-A19-L risk. ### flue-pattern-adoption From bb053c1e343eb56e8301d2b69701e5cfb30aa0fd Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 13:25:58 +0200 Subject: [PATCH 097/164] first pass on question tool refinements --- .pi/extensions/ask-user-question.ts | 843 ++++++++++++++++++++++++++++ memory/PLAN.md | 2 +- 2 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 .pi/extensions/ask-user-question.ts diff --git a/.pi/extensions/ask-user-question.ts b/.pi/extensions/ask-user-question.ts new file mode 100644 index 00000000..c31d90a6 --- /dev/null +++ b/.pi/extensions/ask-user-question.ts @@ -0,0 +1,843 @@ +import { + getMarkdownTheme, + type ExtensionAPI, +} from "@earendil-works/pi-coding-agent" +import { + type Component, + Editor, + type EditorTheme, + Key, + Markdown, + Text, + matchesKey, + truncateToWidth, +} from "@earendil-works/pi-tui" +import { Type } from "typebox" + +interface AskOption { + label: string + value: string + description?: string +} + +interface DisplayOption extends AskOption { + id: string + index?: number + isOther?: boolean + isSubmit?: boolean +} + +interface TextAnswer { + type: "text" + label: string + value: string +} + +interface OptionAnswer { + type: "option" + label: string + value: string + index: number +} + +interface OtherAnswer { + type: "other" + label: string + value: string +} + +type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer +type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable" +type AskUserQuestionMode = "text" | "single-select" | "multi-select" + +interface AskUserQuestionResultDetails { + status: AskUserQuestionStatus + question: string + context?: string + mode: AskUserQuestionMode + answers: AskAnswer[] + message?: string +} + +const OptionSchema = Type.Object({ + label: Type.String({ + description: + 'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.', + }), + value: Type.Optional( + Type.String({ + description: + "Optional machine-readable value returned for the option. Defaults to the label.", + }), + ), + description: Type.Optional( + Type.String({ + description: "Optional extra detail shown below the option.", + }), + ), +}) + +const AskUserQuestionParams = Type.Object({ + question: Type.String({ + description: + "The single question to ask the user. Ask exactly one question per tool call.", + }), + details: Type.Optional( + Type.String({ + description: + "Optional extra context or instructions shown under the question.", + }), + ), + options: Type.Optional( + Type.Array(OptionSchema, { + description: + "Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.", + }), + ), + multiSelect: Type.Optional( + Type.Boolean({ + description: + "Set to true to allow multiple answers to be selected for a question.", + }), + ), +}) + +function normalizeOptions( + options: Array<{ + label: string + value?: string + description?: string + }> | undefined, +): AskOption[] { + return (options || []) + .map((option) => { + const normalized: AskOption = { + label: option.label.trim(), + value: option.value?.trim() || option.label.trim(), + } + const description = option.description?.trim() + if (description) normalized.description = description + return normalized + }) + .filter((option) => option.label.length > 0) +} + +function getOtherLabel(options: AskOption[]): string { + return options.some((option) => option.label.toLowerCase() === "other") + ? "Other (custom)" + : "Other" +} + +function createEditorTheme(theme: { + fg(color: string, text: string): string +}): EditorTheme { + return { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }, + } +} + +function formatAnswerForModel(answer: AskAnswer): string { + switch (answer.type) { + case "text": + return answer.label + case "other": + return `Other: ${answer.label}` + case "option": + return `${answer.index}. ${answer.label}` + } +} + +function answerSortRank(answer: AskAnswer): number { + switch (answer.type) { + case "option": + return answer.index + case "other": + return Number.MAX_SAFE_INTEGER - 1 + case "text": + return Number.MAX_SAFE_INTEGER + } +} + +function sortAnswers(answers: AskAnswer[]): AskAnswer[] { + return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)) +} + +function buildQuestionMarkdown( + question: string, + context: string | undefined, +): string { + const sections = [ + `## Question\n\n${question}\n\n> **Purpose:** choose the response that best unblocks the next step.`, + ] + + if (context) { + sections.unshift(context) + } + + return sections.join("\n\n---\n\n") +} + +function optionMatchesAnswer(option: AskOption, answer: AskAnswer): boolean { + if (answer.type !== "option") return false + return option.label === answer.label && option.value === answer.value +} + +function pickerTopBorder(theme: any, width: number): string { + const title = " Options " + return theme.fg( + "accent", + `─${title}${"─".repeat(Math.max(0, width - title.length - 1))}`, + ) +} + +function pickerBottomBorder(theme: any, width: number): string { + return theme.fg("accent", "─".repeat(width)) +} + +class PromptQuestionComponent implements Component { + private markdown: Markdown + + constructor( + private text: string, + private theme: any, + ) { + this.markdown = new Markdown(text, 0, 0, getMarkdownTheme()) + } + + setText(text: string): void { + this.text = text + this.markdown.setText(text) + } + + invalidate(): void { + this.markdown.invalidate() + } + + render(width: number): string[] { + const label = " Question " + const border = `─${label}${"─".repeat(Math.max(0, width - label.length - 1))}` + return [ + this.theme.bg("customMessageBg", this.theme.fg("muted", border)), + "", + ...this.markdown.render(width), + ] + } +} + +function buildStructuredResult( + status: AskUserQuestionStatus, + question: string, + mode: AskUserQuestionMode, + answers: AskAnswer[], + context?: string, + message?: string, +): AskUserQuestionResultDetails { + const result: AskUserQuestionResultDetails = { + status, + question, + mode, + answers, + } + if (context !== undefined) result.context = context + if (message !== undefined) result.message = message + return result +} + +function cancelledResult( + question: string, + mode: AskUserQuestionMode, + context?: string, +) { + const message = "User cancelled the question" + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult( + "cancelled", + question, + mode, + [], + context, + message, + ), + } +} + +function unavailableResult( + question: string, + mode: AskUserQuestionMode, + message: string, + context?: string, +) { + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult( + "unavailable", + question, + mode, + [], + context, + message, + ), + } +} + +function buildResult( + question: string, + context: string | undefined, + mode: AskUserQuestionMode, + answers: AskAnswer[], +) { + let text: string + if (mode === "text") { + const answer = answers[0] + text = + answer && answer.label.trim().length > 0 + ? `User answered: ${answer.label}` + : "User submitted an empty response" + } else if (mode === "single-select") { + text = `User selected: ${formatAnswerForModel(answers[0]!)} ` + } else { + text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}` + } + + return { + content: [{ type: "text" as const, text: text.trim() }], + details: buildStructuredResult( + "answered", + question, + mode, + answers, + context, + ), + } +} + +async function askSingleChoice( + ctx: any, + _question: string, + _context: string | undefined, + options: AskOption[], +): Promise<AskAnswer | null> { + const otherLabel = getOtherLabel(options) + const allOptions: DisplayOption[] = [ + ...options.map((option, index) => ({ + ...option, + id: `option:${index}`, + index: index + 1, + })), + { id: "other", label: otherLabel, value: "__other__", isOther: true }, + ] + + return ctx.ui.custom( + ( + tui: any, + theme: any, + _kb: any, + done: (result: AskAnswer | null) => void, + ) => { + let optionIndex = 0 + let editMode = false + let cachedLines: string[] | undefined + const editor = new Editor(tui, createEditorTheme(theme)) + + editor.onSubmit = (value) => { + const trimmed = value.trim() + if (!trimmed) return + done({ type: "other", label: trimmed, value: trimmed }) + } + + function refresh() { + cachedLines = undefined + tui.requestRender() + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false + editor.setText("") + refresh() + return + } + editor.handleInput(data) + refresh() + return + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1) + refresh() + return + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allOptions.length - 1, optionIndex + 1) + refresh() + return + } + if (matchesKey(data, Key.enter)) { + const selected = allOptions[optionIndex]! + if (selected.isOther) { + editMode = true + editor.setText("") + refresh() + return + } + done({ + type: "option", + label: selected.label, + value: selected.value, + index: selected.index!, + }) + return + } + if (matchesKey(data, Key.escape)) { + done(null) + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines + + const lines: string[] = [] + const add = (text: string) => lines.push(truncateToWidth(text, width)) + + add(pickerTopBorder(theme, width)) + + for (let i = 0; i < allOptions.length; i++) { + const option = allOptions[i]! + const selected = i === optionIndex + const prefix = selected ? theme.fg("accent", "> ") : " " + const label = option.isOther + ? option.label + : `${option.index}. ${option.label}` + const styled = selected + ? theme.fg("accent", label) + : theme.fg("text", label) + add(`${prefix}${styled}`) + if (option.description) { + const descriptionPrefix = selected ? theme.fg("accent", "│ ") : " " + add(`${descriptionPrefix}${theme.fg("muted", option.description)}`) + } + } + + if (editMode) { + lines.push("") + add(theme.fg("muted", " Write your custom answer:")) + for (const line of editor.render(Math.max(1, width - 2))) { + add(` ${line}`) + } + lines.push("") + add(theme.fg("dim", " Enter to submit • Esc to go back")) + } else { + lines.push("") + add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel")) + } + + add(pickerBottomBorder(theme, width)) + cachedLines = lines + return lines + } + + return { + render, + invalidate: () => { + cachedLines = undefined + }, + handleInput, + } + }, + ) +} + +async function askMultiChoice( + ctx: any, + _question: string, + _context: string | undefined, + options: AskOption[], +): Promise<AskAnswer[] | null> { + const otherLabel = getOtherLabel(options) + const choiceItems: DisplayOption[] = options.map((option, index) => ({ + ...option, + id: `option:${index}`, + index: index + 1, + })) + const submitItem: DisplayOption = { + id: "submit", + label: "Submit", + value: "__submit__", + isSubmit: true, + } + const allItems: DisplayOption[] = [ + ...choiceItems, + { id: "other", label: otherLabel, value: "__other__", isOther: true }, + submitItem, + ] + + return ctx.ui.custom( + ( + tui: any, + theme: any, + _kb: any, + done: (result: AskAnswer[] | null) => void, + ) => { + let optionIndex = 0 + let editMode = false + let cachedLines: string[] | undefined + const selected = new Map<string, AskAnswer>() + const editor = new Editor(tui, createEditorTheme(theme)) + + editor.onSubmit = (value) => { + const trimmed = value.trim() + if (!trimmed) return + selected.set("other", { type: "other", label: trimmed, value: trimmed }) + editMode = false + refresh() + } + + function refresh() { + cachedLines = undefined + tui.requestRender() + } + + function toggleOption(item: DisplayOption) { + if (selected.has(item.id)) { + selected.delete(item.id) + } else { + selected.set(item.id, { + type: "option", + label: item.label, + value: item.value, + index: item.index!, + }) + } + refresh() + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false + editor.setText(selected.get("other")?.label || "") + refresh() + return + } + editor.handleInput(data) + refresh() + return + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1) + refresh() + return + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allItems.length - 1, optionIndex + 1) + refresh() + return + } + + const current = allItems[optionIndex]! + if (matchesKey(data, Key.space)) { + if (current.isSubmit) return + if (current.isOther) { + if (selected.has("other")) { + selected.delete("other") + refresh() + } else { + editMode = true + editor.setText("") + refresh() + } + return + } + toggleOption(current) + return + } + + if (matchesKey(data, Key.enter)) { + if (current.isSubmit) { + if (selected.size > 0) { + done(sortAnswers(Array.from(selected.values()))) + } + return + } + if (current.isOther) { + editMode = true + editor.setText(selected.get("other")?.label || "") + refresh() + return + } + toggleOption(current) + return + } + + if (matchesKey(data, Key.escape)) { + done(null) + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines + + const lines: string[] = [] + const add = (text: string) => lines.push(truncateToWidth(text, width)) + + add(pickerTopBorder(theme, width)) + + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]! + const isFocused = i === optionIndex + const prefix = isFocused ? theme.fg("accent", "> ") : " " + + if (item.isSubmit) { + const label = + selected.size > 0 + ? `✓ ${item.label} (${selected.size} selected)` + : `○ ${item.label}` + const styled = isFocused + ? theme.fg("accent", label) + : theme.fg(selected.size > 0 ? "success" : "dim", label) + add(`${prefix}${styled}`) + continue + } + + if (item.isOther) { + const other = selected.get("other") + const marker = other ? "[x]" : "[ ]" + const suffix = other ? ` — ${other.label}` : "" + const styled = isFocused + ? theme.fg("accent", `${marker} ${item.label}${suffix}`) + : theme.fg( + other ? "success" : "text", + `${marker} ${item.label}${suffix}`, + ) + add(`${prefix}${styled}`) + continue + } + + const checked = selected.has(item.id) + const marker = checked ? "[x]" : "[ ]" + const label = `${marker} ${item.index}. ${item.label}` + const styled = isFocused + ? theme.fg("accent", label) + : theme.fg(checked ? "success" : "text", label) + add(`${prefix}${styled}`) + if (item.description) { + const descriptionPrefix = isFocused + ? theme.fg("accent", "│ ") + : " " + add(`${descriptionPrefix}${theme.fg("muted", item.description)}`) + } + } + + if (editMode) { + lines.push("") + add(theme.fg("muted", " Write your custom answer:")) + for (const line of editor.render(Math.max(1, width - 2))) { + add(` ${line}`) + } + lines.push("") + add(theme.fg("dim", " Enter to save • Esc to go back")) + } else { + lines.push("") + if (selected.size === 0) { + add( + theme.fg( + "warning", + " Select at least one answer before submitting.", + ), + ) + } + add( + theme.fg( + "dim", + " ↑↓ navigate • Space toggle • Enter edit/submit • Esc cancel", + ), + ) + } + + add(pickerBottomBorder(theme, width)) + cachedLines = lines + return lines + } + + return { + render, + invalidate: () => { + cachedLines = undefined + }, + handleInput, + } + }, + ) +} + +let uiLock: Promise<void> = Promise.resolve() + +function withUILock<T>(fn: () => Promise<T>): Promise<T> { + const previous = uiLock + let release: (() => void) | undefined + uiLock = new Promise<void>((resolve) => { + release = resolve + }) + return previous.then(fn).finally(() => release?.()) +} + +export default function askUserQuestion(pi: ExtensionAPI) { + pi.registerTool({ + name: "ask_user_question", + label: "ask_user_question", + renderShell: "self", + description: + "Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.", + promptSnippet: + "Ask exactly one clarifying, preference, confirmation, or decision question before continuing.", + promptGuidelines: [ + "Use ask_user_question when a user decision would materially affect the next step.", + "Ask exactly one question per ask_user_question tool call.", + "Use ask_user_question with multiSelect: true only when multiple answers to the same question are valid.", + 'ask_user_question always lets the user select "Other" when options are provided.', + ], + parameters: AskUserQuestionParams, + + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const options = normalizeOptions(params.options) + const context = params.details?.trim() || undefined + const mode: AskUserQuestionMode = + options.length === 0 + ? "text" + : params.multiSelect + ? "multi-select" + : "single-select" + + if (signal?.aborted) { + return cancelledResult(params.question, mode, context) + } + + if (!ctx.hasUI) { + return unavailableResult( + params.question, + mode, + "ask_user_question requires interactive mode UI", + context, + ) + } + + return withUILock(async () => { + if (mode === "text") { + const answer = await ctx.ui.editor("Answer the question shown above") + if (answer === undefined) { + return cancelledResult(params.question, mode, context) + } + const trimmed = answer.trim() + return buildResult(params.question, context, mode, [ + { type: "text", label: trimmed, value: trimmed }, + ]) + } + + if (mode === "single-select") { + const answer = await askSingleChoice( + ctx, + params.question, + context, + options, + ) + if (!answer) { + return cancelledResult(params.question, mode, context) + } + return buildResult(params.question, context, mode, [answer]) + } + + const answers = await askMultiChoice( + ctx, + params.question, + context, + options, + ) + if (!answers) { + return cancelledResult(params.question, mode, context) + } + return buildResult(params.question, context, mode, answers) + }) + }, + + renderCall(args, _theme, context) { + if (!context.argsComplete) { + return new Text("", 0, 0) + } + const text = buildQuestionMarkdown( + args.question, + args.details?.trim() || undefined, + ) + const prompt = + context.lastComponent instanceof PromptQuestionComponent + ? context.lastComponent + : undefined + if (prompt) { + prompt.setText(text) + return prompt + } + return new PromptQuestionComponent(text, _theme) + }, + + renderResult(result, _options, theme, context) { + const details = result.details as AskUserQuestionResultDetails | undefined + if (!details) { + const first = result.content[0] + return new Text(first?.type === "text" ? first.text : "", 0, 0) + } + + if (details.status === "cancelled") { + return new Text( + theme.fg("warning", details.message || "Cancelled"), + 0, + 0, + ) + } + + if (details.status === "unavailable") { + return new Text( + theme.fg( + "warning", + details.message || "ask_user_question unavailable", + ), + 0, + 0, + ) + } + + const selectedLines = details.answers.map((answer) => { + switch (answer.type) { + case "text": + return `${theme.fg("success", "✓ Selected: ")}${theme.fg("accent", answer.label || "(empty response)")}` + case "other": + return `${theme.fg("success", "✓ Selected: ")}${theme.fg("muted", "Other: ")}${theme.fg("accent", answer.label)}` + case "option": + return `${theme.fg("success", "✓ Selected: ")}${theme.fg("accent", `${answer.index}. ${answer.label}`)}` + } + }) + const optionArgs = context?.args as { options?: AskOption[] } | undefined + const options = normalizeOptions(optionArgs?.options) + const rejectedLines = options + .filter( + (option) => + !details.answers.some((answer) => + optionMatchesAnswer(option, answer), + ), + ) + .map((option, index) => + theme.fg("dim", `○ Rejected: ${index + 1}. ${option.label}`), + ) + + return new Text([...selectedLines, ...rejectedLines].join("\n"), 0, 0) + }, + }) +} diff --git a/memory/PLAN.md b/memory/PLAN.md index 56274f33..9ab0a4e5 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -224,7 +224,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Today's focus is structured exchanges, not graph work: prove every supported exchange type through interactive TUI and public Brunch RPC, add the agent-as-user RPC evaluator probe, and show web UI real-time updates when TUI/RPC interactions change selected session/exchange state. Then recover branded chrome before FE-744 closeout: inspect the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and port the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until this structured-exchange proof and chrome recovery close the active A10-L/A18-L/UI-RPC-A19-L risk. +- **Current execution pointer:** Today's focus is structured exchanges, not graph work: prove every supported exchange type through interactive TUI and public Brunch RPC, add the agent-as-user RPC evaluator probe, and show web UI real-time updates when TUI/RPC interactions change selected session/exchange state. Scroll-lock spike finding for the project-local `ask_user_question` extension: non-overlay `ctx.ui.custom()` replaces the editor with a component whose rendered lines can exceed the terminal viewport, while the component captures keyboard focus and has no internal viewport, so long question/details content disappears above the visible bottom of the TUI; overlay mode can avoid editor replacement but still clips without internal scroll, so the next remediation should keep full question/context in transcript-friendly tool rendering and make the active answer control compact or explicitly internally scrollable. Then recover branded chrome before FE-744 closeout: inspect the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and port the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until this structured-exchange proof and chrome recovery close the active A10-L/A18-L/UI-RPC-A19-L risk. ### flue-pattern-adoption From c82009a7cdb0ff80005ccf7873dac11fe3f0173d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 13:33:27 +0200 Subject: [PATCH 098/164] Stabilize ask user question rendering --- .pi/extensions/ask-user-question.test.ts | 112 +++++++++++++++++++++++ .pi/extensions/ask-user-question.ts | 103 ++++++++++++++------- 2 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 .pi/extensions/ask-user-question.test.ts diff --git a/.pi/extensions/ask-user-question.test.ts b/.pi/extensions/ask-user-question.test.ts new file mode 100644 index 00000000..ebd0dbe8 --- /dev/null +++ b/.pi/extensions/ask-user-question.test.ts @@ -0,0 +1,112 @@ +import { Text } from "@earendil-works/pi-tui" +import { describe, expect, it } from "vitest" +import askUserQuestion from "./ask-user-question.js" + +const ansiPattern = new RegExp( + `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, + "g", +) + +function stripAnsi(text: string): string { + return text.replace(ansiPattern, "") +} + +function registerAskUserQuestionTool() { + let tool: any + askUserQuestion({ + registerTool(definition: any) { + tool = definition + }, + } as any) + return tool +} + +const theme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, +} + +describe("ask_user_question experimental renderer", () => { + it("renders prompt markdown before the question without duplicating options", () => { + const tool = registerAskUserQuestionTool() + + const component = tool.renderCall( + { + question: "Which path should we take?", + details: "## Preamble\n\nThis is caller-provided context.", + options: [ + { label: "First path", value: "first" }, + { label: "Second path", value: "second" }, + ], + }, + theme, + { argsComplete: true, lastComponent: new Text("stale", 0, 0) }, + ) + + const rendered = stripAnsi(component.render(80).join("\n")) + expect(rendered.indexOf("Preamble")).toBeLessThan( + rendered.indexOf("Question"), + ) + expect(rendered).toContain("This is caller-provided context.") + expect(rendered).toContain("Which path should we take?") + expect(rendered).not.toContain("First path") + expect(rendered).not.toContain("Second path") + expect(rendered).not.toContain("ask_user_question") + }) + + it("keeps renderCall component reuse type-safe across partial renders", () => { + const tool = registerAskUserQuestionTool() + const args = { question: "Proceed?" } + + const partial = tool.renderCall(args, theme, { argsComplete: false }) + expect(stripAnsi(partial.render(80).join("\n"))).toBe("") + + const first = tool.renderCall(args, theme, { + argsComplete: true, + lastComponent: partial, + }) + const second = tool.renderCall({ question: "Proceed now?" }, theme, { + argsComplete: true, + lastComponent: first, + }) + + expect(second).toBe(first) + expect(stripAnsi(second.render(80).join("\n"))).toContain("Proceed now?") + }) + + it("summarizes selected and rejected options using original option indexes", () => { + const tool = registerAskUserQuestionTool() + + const component = tool.renderResult( + { + content: [{ type: "text", text: "User selected: 2. Second" }], + details: { + status: "answered", + question: "Pick one", + mode: "single-select", + answers: [ + { type: "option", label: "Second", value: "second", index: 2 }, + ], + }, + }, + { expanded: true, isPartial: false }, + theme, + { + args: { + options: [ + { label: "First", value: "first" }, + { label: "Second", value: "second" }, + { label: "Third", value: "third" }, + ], + }, + }, + ) + + const rendered = stripAnsi(component.render(80).join("\n")) + expect(rendered).toContain("✓ Selected: 2. Second") + expect(rendered).toContain("○ Rejected: 1. First") + expect(rendered).toContain("○ Rejected: 3. Third") + expect(rendered).not.toContain("○ Rejected: 2. Third") + }) +}) diff --git a/.pi/extensions/ask-user-question.ts b/.pi/extensions/ask-user-question.ts index c31d90a6..bb783683 100644 --- a/.pi/extensions/ask-user-question.ts +++ b/.pi/extensions/ask-user-question.ts @@ -1,16 +1,15 @@ -import { - getMarkdownTheme, - type ExtensionAPI, -} from "@earendil-works/pi-coding-agent" +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { type Component, Editor, type EditorTheme, Key, Markdown, + type MarkdownTheme, Text, matchesKey, truncateToWidth, + wrapTextWithAnsi, } from "@earendil-works/pi-tui" import { Type } from "typebox" @@ -143,6 +142,34 @@ function createEditorTheme(theme: { } } +function createPromptMarkdownTheme(theme: { + fg(color: string, text: string): string + bold?: (text: string) => string + italic?: (text: string) => string + underline?: (text: string) => string + strikethrough?: (text: string) => string +}): MarkdownTheme { + const fg = (color: string) => (text: string) => theme.fg(color, text) + const identity = (text: string) => text + return { + heading: fg("mdHeading"), + link: fg("mdLink"), + linkUrl: fg("mdLinkUrl"), + code: fg("mdCode"), + codeBlock: fg("mdCodeBlock"), + codeBlockBorder: fg("mdCodeBlockBorder"), + quote: fg("mdQuote"), + quoteBorder: fg("mdQuoteBorder"), + hr: fg("mdHr"), + listBullet: fg("mdListBullet"), + bold: theme.bold ?? identity, + italic: theme.italic ?? identity, + underline: theme.underline ?? identity, + strikethrough: theme.strikethrough ?? identity, + highlightCode: (code: string) => code.split("\n").map(fg("mdCodeBlock")), + } +} + function formatAnswerForModel(answer: AskAnswer): string { switch (answer.type) { case "text": @@ -169,13 +196,23 @@ function sortAnswers(answers: AskAnswer[]): AskAnswer[] { return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)) } +function addWrapped( + lines: string[], + text: string, + width: number, + indent = "", +): void { + const contentWidth = Math.max(1, width - indent.length) + for (const line of wrapTextWithAnsi(text, contentWidth)) { + lines.push(truncateToWidth(`${indent}${line}`, width)) + } +} + function buildQuestionMarkdown( question: string, context: string | undefined, ): string { - const sections = [ - `## Question\n\n${question}\n\n> **Purpose:** choose the response that best unblocks the next step.`, - ] + const sections = [`## Question\n\n${question}`] if (context) { sections.unshift(context) @@ -190,11 +227,7 @@ function optionMatchesAnswer(option: AskOption, answer: AskAnswer): boolean { } function pickerTopBorder(theme: any, width: number): string { - const title = " Options " - return theme.fg( - "accent", - `─${title}${"─".repeat(Math.max(0, width - title.length - 1))}`, - ) + return theme.fg("accent", "─".repeat(width)) } function pickerBottomBorder(theme: any, width: number): string { @@ -206,9 +239,9 @@ class PromptQuestionComponent implements Component { constructor( private text: string, - private theme: any, + markdownTheme: MarkdownTheme, ) { - this.markdown = new Markdown(text, 0, 0, getMarkdownTheme()) + this.markdown = new Markdown(text, 0, 0, markdownTheme) } setText(text: string): void { @@ -221,13 +254,7 @@ class PromptQuestionComponent implements Component { } render(width: number): string[] { - const label = " Question " - const border = `─${label}${"─".repeat(Math.max(0, width - label.length - 1))}` - return [ - this.theme.bg("customMessageBg", this.theme.fg("muted", border)), - "", - ...this.markdown.render(width), - ] + return this.markdown.render(width) } } @@ -423,7 +450,12 @@ async function askSingleChoice( add(`${prefix}${styled}`) if (option.description) { const descriptionPrefix = selected ? theme.fg("accent", "│ ") : " " - add(`${descriptionPrefix}${theme.fg("muted", option.description)}`) + addWrapped( + lines, + theme.fg("muted", option.description), + width, + descriptionPrefix, + ) } } @@ -634,7 +666,12 @@ async function askMultiChoice( const descriptionPrefix = isFocused ? theme.fg("accent", "│ ") : " " - add(`${descriptionPrefix}${theme.fg("muted", item.description)}`) + addWrapped( + lines, + theme.fg("muted", item.description), + width, + descriptionPrefix, + ) } } @@ -785,7 +822,10 @@ export default function askUserQuestion(pi: ExtensionAPI) { prompt.setText(text) return prompt } - return new PromptQuestionComponent(text, _theme) + return new PromptQuestionComponent( + text, + createPromptMarkdownTheme(_theme), + ) }, renderResult(result, _options, theme, context) { @@ -826,16 +866,11 @@ export default function askUserQuestion(pi: ExtensionAPI) { }) const optionArgs = context?.args as { options?: AskOption[] } | undefined const options = normalizeOptions(optionArgs?.options) - const rejectedLines = options - .filter( - (option) => - !details.answers.some((answer) => - optionMatchesAnswer(option, answer), - ), - ) - .map((option, index) => - theme.fg("dim", `○ Rejected: ${index + 1}. ${option.label}`), - ) + const rejectedLines = options.flatMap((option, index) => + details.answers.some((answer) => optionMatchesAnswer(option, answer)) + ? [] + : [theme.fg("dim", `○ Rejected: ${index + 1}. ${option.label}`)], + ) return new Text([...selectedLines, ...rejectedLines].join("\n"), 0, 0) }, From 09b0ec230a0ae4193c6aa462fb5eb2c04062b2fe Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 13:36:43 +0200 Subject: [PATCH 099/164] Keep ask user question tests out of extension loading --- .../ask-user-question-extension.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .pi/extensions/ask-user-question.test.ts => src/ask-user-question-extension.test.ts (98%) diff --git a/.pi/extensions/ask-user-question.test.ts b/src/ask-user-question-extension.test.ts similarity index 98% rename from .pi/extensions/ask-user-question.test.ts rename to src/ask-user-question-extension.test.ts index ebd0dbe8..44b65cbf 100644 --- a/.pi/extensions/ask-user-question.test.ts +++ b/src/ask-user-question-extension.test.ts @@ -1,6 +1,6 @@ import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import askUserQuestion from "./ask-user-question.js" +import askUserQuestion from "../.pi/extensions/ask-user-question.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, From e238fdb0f58075089fb2d376bbb0ef491c83ae3d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 13:41:13 +0200 Subject: [PATCH 100/164] Move experimental question tool under structured exchange --- .pi/extensions/structured-exchange.ts | 1 + src/ask-user-question-extension.test.ts | 2 +- .../pi-extensions/structured-exchange.ts | 0 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .pi/extensions/structured-exchange.ts rename .pi/extensions/ask-user-question.ts => src/pi-extensions/structured-exchange.ts (100%) diff --git a/.pi/extensions/structured-exchange.ts b/.pi/extensions/structured-exchange.ts new file mode 100644 index 00000000..2ba496e9 --- /dev/null +++ b/.pi/extensions/structured-exchange.ts @@ -0,0 +1 @@ +export { default } from "../../src/pi-extensions/structured-exchange.js" diff --git a/src/ask-user-question-extension.test.ts b/src/ask-user-question-extension.test.ts index 44b65cbf..86cf7af9 100644 --- a/src/ask-user-question-extension.test.ts +++ b/src/ask-user-question-extension.test.ts @@ -1,6 +1,6 @@ import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import askUserQuestion from "../.pi/extensions/ask-user-question.js" +import askUserQuestion from "./pi-extensions/structured-exchange.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, diff --git a/.pi/extensions/ask-user-question.ts b/src/pi-extensions/structured-exchange.ts similarity index 100% rename from .pi/extensions/ask-user-question.ts rename to src/pi-extensions/structured-exchange.ts From 878cf10aec349d9ec8cd5c98112c372303134bc3 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 13:59:27 +0200 Subject: [PATCH 101/164] spec/plan/cards for minimal questionnaire logic integration --- memory/CARDS.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++ memory/PLAN.md | 4 +- memory/SPEC.md | 6 +-- 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..f1f139a9 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,123 @@ +<!-- CARDS.md — temporary execution queue for the active FE-744 frontier. + Delete when exhausted or superseded. Canonical state remains in memory/SPEC.md and memory/PLAN.md. --> + +# Cards — FE-744 structured exchange proof + +## Orientation + +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange tool surface now hosted at `src/pi-extensions/structured-exchange.ts` with a project-local Pi loader at `.pi/extensions/structured-exchange.ts`. +- **Frontier item:** `pi-ui-extension-patterns`; these cards are slices inside the existing FE-744 Linear/branch boundary, not new tracker items. +- **Volatile state:** `HANDOFF.md` is absent; the working tree was clean before this queue was written. Canonical reconciliation for the newly remembered optional-note requirement has been applied to `memory/SPEC.md` and `memory/PLAN.md`. +- **Main open risk:** the TUI note step must not make the active `ctx.ui.custom()` surface tall again, and the RPC proof must falsify semantic parity rather than merely proving a low-level Pi editor request round-trips. + +## Cross-cutting obligations for all cards + +- Preserve Pi transcript truth: terminal structured exchange results must be self-contained in `toolResult.details` (or proof custom entry details where the probe directly exercises adapter helpers). +- Preserve linear transcript policy: no Pi branching, no parallel chat/turn store, and no mid-turn state outside the established Pi transcript / Brunch handler seams. +- Keep option-selection `note` separate from `Other`/custom answers: `Other` is an answer value; `note` is additional context attached to the selected answer(s). +- Keep full question/details content out of the focused picker unless a later explicit internal viewport slice is scoped. +- Do not mutate the user-level Pi extension/config under `/Users/lunelson/.pi/agent/`. + +--- + +## Card 1 — Option-selection note step in TUI + +- **Status:** next +- **Weight:** light build card inside a now-reconciled structural frontier + +### Objective + +Option-based structured exchanges advance from answer selection to a focused optional-note editor before submitting the terminal result. + +### Acceptance Criteria + +✓ Single-select mode moves to a note step after selecting a listed option or `Other`; the note editor is focused; pressing Enter submits even when empty. +✓ Multi-select mode moves to a note step after activating Submit; the note editor is focused; pressing Enter submits even when empty. +✓ Esc from the note step returns to the answer picker with prior selections preserved rather than cancelling the whole exchange. +✓ `toolResult.details` for answered option modes includes a string `note` field, with `""` representing an intentionally empty note. +✓ `Other` remains represented as an `OtherAnswer`; it is not folded into `note`. +✓ `renderResult` shows the note only when non-empty while preserving the selected/rejected summary. +✓ Text/freeform mode behavior is unchanged by this card. + +### Verification Approach + +- **Inner:** `npm run fix`; targeted `vitest` for structured-exchange tests; `npm run check`. +- **Middle:** component/state-machine tests drive the registered tool through fake `ctx.ui.custom()` callbacks for single-select and multi-select, including empty-note and non-empty-note submissions. +- **Outer:** optional manual TUI smoke to confirm the note step feels like a compact second tab/step and does not reintroduce tall active content. + +### Promotion checklist + +- [ ] Requirement already reconciled? Yes — SPEC/PLAN now name optional notes for option-selection exchanges. +- [ ] Creates/retires/invalidates an assumption? No. +- [ ] New seam-level invariant? No; implements the existing structured-result self-containment invariant. +- [ ] More than two major seams? No — TUI tool UI + result payload/rendering. + +--- + +## Card 2 — RPC editor fallback carries option notes + +- **Status:** queued +- **Weight:** light build card, dependent on Card 1 result shape + +### Objective + +The structured-exchange tool can collect option answers plus optional notes through Pi RPC using schema-tagged JSON over `ctx.ui.editor()` instead of `ctx.ui.custom()`. + +### Acceptance Criteria + +✓ In an RPC-compatible path, single-select payloads include options, selected answer, and `note`. +✓ In an RPC-compatible path, multi-select payloads include options, selected answers, and `note`. +✓ Empty-note submissions round-trip as `note: ""`. +✓ Invalid editor JSON returns a structured terminal failure or retry/error result without producing a malformed answered payload. +✓ TUI `ctx.ui.custom()` behavior from Card 1 remains the rich path; RPC/editor fallback is an adapter over Pi-supported extension UI, not a new public Pi command family. + +### Verification Approach + +- **Inner:** `npm run fix`; targeted helper/adapter tests; `npm run check`. +- **Middle:** contract tests for JSON prefill/parse/validation prove the returned `toolResult.details` is self-contained for option answers plus notes. +- **Outer:** defer full subprocess RPC proof to Card 3. + +### Promotion checklist + +- [ ] Requirement already reconciled? Yes. +- [ ] Creates/retires/invalidates an assumption? No unless Pi RPC cannot express the fallback. +- [ ] New seam-level invariant? No; it exercises D38-L JSON-over-editor compatibility. +- [ ] More than two major seams? Borderline but acceptable: tool result model + Pi RPC editor adapter; public Brunch relay stays for later proof work. + +--- + +## Card 3 — RPC structured-exchange evaluator proof + +- **Status:** queued +- **Weight:** light build/proof card, dependent on Card 2 RPC fallback + +### Objective + +A repeatable RPC probe demonstrates that an agent-as-user can complete an option-based structured exchange with an optional note and report blocker/friction findings. + +### Acceptance Criteria + +✓ The probe runs Pi in `--mode rpc` with the project structured-exchange extension or a minimal proof extension importing the same implementation/helpers. +✓ The evaluator scenario declares mission/intention, UX or feature-evaluation focus, and max-turn budget in the probe fixture/result. +✓ The scripted agent-as-user response selects at least one option and submits a non-empty note. +✓ The captured terminal details include prompt/question, options, selected answer(s), rejected option context where applicable, `note`, mode, status, and transport/probe metadata sufficient for Brunch projection. +✓ The probe emits a blocker/friction report even when no blockers are found. +✓ A regression test fails if the RPC path silently drops `note` or only proves raw `extension_ui_request(editor)` without validating the structured result payload. + +### Verification Approach + +- **Inner:** `npm run fix`; targeted `vitest` for the RPC proof; `npm run check`. +- **Middle:** subprocess RPC proof analogous to `src/structured-question-rpc-proof.ts`, but shaped around structured exchange option selection plus note. +- **Outer:** manual review of the saved probe result/session snippet to confirm the transcript is intelligible as evidence, not just protocol noise. + +### Promotion checklist + +- [ ] Requirement already reconciled? Yes. +- [ ] Creates/retires/invalidates an assumption? No if it passes; if it fails, route to `ln-plan`/`ln-spike` because A5-L / FE-744 RPC proof pressure changes. +- [ ] New seam-level invariant? No; it adds coverage to existing structured-exchange/RPC obligations. +- [ ] More than two major seams? No for the proof harness; public web relay remains intentionally out of this queue. + +## Not queued yet + +- Web real-time update smoke should be scoped after Card 3, because its exact target should follow the proven RPC/public-surface shape rather than guessing ahead. +- Invocation-discipline tightening should be scoped separately after the transport proof, because it changes assistant-facing tool guidance rather than response semantics. diff --git a/memory/PLAN.md b/memory/PLAN.md index 9ab0a4e5..73ffb20f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -218,9 +218,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** structural (spike-flavored) - **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, and response-side projection have landed; current missing seams are full structured-exchange proof across TUI/RPC/web plus visual chrome recovery) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-exchange proof: a registered Pi tool can collect every supported structured exchange shape (text/freeform, action or confirmation where supported, single-select/radio, multi-select/checkbox, questionnaire, optional freeform-plus-choice, and terminal statuses such as answered/skipped/cancelled/unavailable); rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes terminal structured results as response-side; Brunch exposes one public product RPC surface that wraps Pi RPC extension-UI requests for agent-as-user probes and web relay clients; and the web UI receives real-time product updates when TUI or RPC interactions change selected session/exchange state. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-exchange proof: a registered Pi tool can collect every supported structured exchange shape (text/freeform, action or confirmation where supported, single-select/radio, multi-select/checkbox, questionnaire, optional freeform-plus-choice, option-selection notes, and terminal statuses such as answered/skipped/cancelled/unavailable); rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/note/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes terminal structured results as response-side; Brunch exposes one public product RPC surface that wraps Pi RPC extension-UI requests for agent-as-user probes and web relay clients; and the web UI receives real-time product updates when TUI or RPC interactions change selected session/exchange state. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol; scripted TUI demo covering all supported structured-exchange permutations; RPC agent-as-user evaluator probe where the evaluator has a mission/intention, a critical UX or feature-evaluation focus, and a maximum turn budget, then reports blockers and frictions; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). diff --git a/memory/SPEC.md b/memory/SPEC.md index 3f6b9473..8e790af0 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -72,7 +72,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -256,7 +256,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor helper tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; `npm run test:structured-question-rpc-proof` live-proves Pi RPC `extension_ui_request(editor)` / `extension_ui_response(value)` at the adapter layer; elicitation-exchange projection tests cover terminal structured-question tool results as response-side JSONL entries while ordinary tool results remain prompt-side. Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, optional note, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor helper tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; `npm run test:structured-question-rpc-proof` live-proves Pi RPC `extension_ui_request(editor)` / `extension_ui_response(value)` at the adapter layer; elicitation-exchange projection tests cover terminal structured-question tool results as response-side JSONL entries while ordinary tool results remain prompt-side. Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | @@ -381,7 +381,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | | **Structured-question preface** | Plain prose in a structured-question payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | -| **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | +| **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-question tools. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | From 37bce3a8115c89a00fbb5e2027006d3db05a9cd2 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 14:03:35 +0200 Subject: [PATCH 102/164] Add structured exchange option notes --- memory/CARDS.md | 4 +- src/pi-extensions/structured-exchange.test.ts | 201 ++++++++++++++++++ src/pi-extensions/structured-exchange.ts | 152 +++++++++++-- 3 files changed, 340 insertions(+), 17 deletions(-) create mode 100644 src/pi-extensions/structured-exchange.test.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index f1f139a9..ed8d1882 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -22,7 +22,7 @@ ## Card 1 — Option-selection note step in TUI -- **Status:** next +- **Status:** done - **Weight:** light build card inside a now-reconciled structural frontier ### Objective @@ -56,7 +56,7 @@ Option-based structured exchanges advance from answer selection to a focused opt ## Card 2 — RPC editor fallback carries option notes -- **Status:** queued +- **Status:** next - **Weight:** light build card, dependent on Card 1 result shape ### Objective diff --git a/src/pi-extensions/structured-exchange.test.ts b/src/pi-extensions/structured-exchange.test.ts new file mode 100644 index 00000000..c586f4a4 --- /dev/null +++ b/src/pi-extensions/structured-exchange.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest" + +import registerStructuredExchange from "./structured-exchange.js" + +interface ToolTextContent { + type: "text" + text: string +} + +interface ToolExecutionResult { + content: ToolTextContent[] + details: any +} + +interface RenderableText { + render?: (width: number) => string[] +} + +interface RegisteredTool { + name: string + execute: ( + toolCallId: string, + params: Record<string, unknown>, + signal: AbortSignal | undefined, + onUpdate: unknown, + ctx: unknown, + ) => Promise<ToolExecutionResult> + renderResult: ( + result: ToolExecutionResult, + options: unknown, + theme: FakeTheme, + context?: unknown, + ) => RenderableText +} + +interface FakeTheme { + fg: (_color: string, text: string) => string +} + +const enter = "\r" +const escape = "\x1b" +const down = "\x1b[B" +const space = " " +const theme: FakeTheme = { fg: (_color, text) => text } + +function registeredTool(): RegisteredTool { + let tool: RegisteredTool | undefined + registerStructuredExchange({ + registerTool: (registered: RegisteredTool) => { + tool = registered + }, + } as never) + if (!tool) throw new Error("tool was not registered") + return tool +} + +function contextDrivingCustom(inputs: string[]) { + return { + hasUI: true, + ui: { + custom: async (factory: any) => { + const component = factory( + { requestRender: () => {} }, + theme, + {}, + (result: unknown) => { + resolved = result + }, + ) + let resolved: unknown = undefined + for (const input of inputs) { + component.handleInput(input) + if (resolved !== undefined) return resolved + } + throw new Error("custom UI did not resolve") + }, + }, + } +} + +function optionParams(multiSelect = false): Record<string, unknown> { + return { + question: "Pick a path", + details: "Choose deliberately.", + options: [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + ], + multiSelect, + } +} + +describe("structured exchange option notes", () => { + it("requires a focused note submit after a single-select option answer", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-1", + optionParams(), + undefined, + undefined, + contextDrivingCustom([enter, ..."Add context", enter]), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "single-select", + note: "Add context", + answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + }) + expect(result.content[0]?.text).toContain("Add context") + }) + + it("preserves Other as an answer and records an intentionally empty single-select note", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-1", + optionParams(), + undefined, + undefined, + contextDrivingCustom([down, down, enter, ..."Custom", enter, enter]), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "single-select", + note: "", + answers: [{ type: "other", label: "Custom", value: "Custom" }], + }) + }) + + it("returns from the note step to the multi-select picker with selections preserved", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-1", + optionParams(true), + undefined, + undefined, + contextDrivingCustom([ + space, + down, + space, + down, + down, + enter, + escape, + down, + down, + enter, + enter, + ]), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "multi-select", + note: "", + answers: [ + { type: "option", label: "Alpha", value: "a", index: 1 }, + { type: "option", label: "Beta", value: "b", index: 2 }, + ], + }) + }) + + it("renders a non-empty note without rendering empty notes", async () => { + const tool = registeredTool() + const withNote = await tool.execute( + "call-1", + optionParams(), + undefined, + undefined, + contextDrivingCustom([enter, ..."Useful note", enter]), + ) + const emptyNote = await tool.execute( + "call-2", + optionParams(), + undefined, + undefined, + contextDrivingCustom([enter, enter]), + ) + + expect( + tool + .renderResult(withNote, undefined, theme, { + args: optionParams(), + }) + ?.render?.(80) + .join("\n"), + ).toContain("Note: Useful note") + expect( + tool + .renderResult(emptyNote, undefined, theme, { + args: optionParams(), + }) + ?.render?.(80) + .join("\n"), + ).not.toContain("Note:") + }) +}) diff --git a/src/pi-extensions/structured-exchange.ts b/src/pi-extensions/structured-exchange.ts index bb783683..f68c312a 100644 --- a/src/pi-extensions/structured-exchange.ts +++ b/src/pi-extensions/structured-exchange.ts @@ -55,9 +55,15 @@ interface AskUserQuestionResultDetails { context?: string mode: AskUserQuestionMode answers: AskAnswer[] + note?: string message?: string } +interface OptionAnswerResult { + answers: AskAnswer[] + note: string +} + const OptionSchema = Type.Object({ label: Type.String({ description: @@ -265,6 +271,7 @@ function buildStructuredResult( answers: AskAnswer[], context?: string, message?: string, + note?: string, ): AskUserQuestionResultDetails { const result: AskUserQuestionResultDetails = { status, @@ -273,6 +280,7 @@ function buildStructuredResult( answers, } if (context !== undefined) result.context = context + if (note !== undefined) result.note = note if (message !== undefined) result.message = message return result } @@ -320,6 +328,7 @@ function buildResult( context: string | undefined, mode: AskUserQuestionMode, answers: AskAnswer[], + note?: string, ) { let text: string if (mode === "text") { @@ -334,6 +343,10 @@ function buildResult( text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}` } + if (note) { + text = `${text.trim()}\nNote: ${note}` + } + return { content: [{ type: "text" as const, text: text.trim() }], details: buildStructuredResult( @@ -342,6 +355,8 @@ function buildResult( mode, answers, context, + undefined, + note, ), } } @@ -351,7 +366,7 @@ async function askSingleChoice( _question: string, _context: string | undefined, options: AskOption[], -): Promise<AskAnswer | null> { +): Promise<OptionAnswerResult | null> { const otherLabel = getOtherLabel(options) const allOptions: DisplayOption[] = [ ...options.map((option, index) => ({ @@ -367,17 +382,29 @@ async function askSingleChoice( tui: any, theme: any, _kb: any, - done: (result: AskAnswer | null) => void, + done: (result: OptionAnswerResult | null) => void, ) => { let optionIndex = 0 let editMode = false + let noteMode = false + let selectedAnswer: AskAnswer | undefined let cachedLines: string[] | undefined const editor = new Editor(tui, createEditorTheme(theme)) + const noteEditor = new Editor(tui, createEditorTheme(theme)) editor.onSubmit = (value) => { const trimmed = value.trim() if (!trimmed) return - done({ type: "other", label: trimmed, value: trimmed }) + selectedAnswer = { type: "other", label: trimmed, value: trimmed } + editMode = false + noteMode = true + noteEditor.setText("") + refresh() + } + + noteEditor.onSubmit = (value) => { + if (!selectedAnswer) return + done({ answers: [selectedAnswer], note: value.trim() }) } function refresh() { @@ -386,6 +413,18 @@ async function askSingleChoice( } function handleInput(data: string) { + if (noteMode) { + if (matchesKey(data, Key.escape)) { + noteMode = false + noteEditor.setText("") + refresh() + return + } + noteEditor.handleInput(data) + refresh() + return + } + if (editMode) { if (matchesKey(data, Key.escape)) { editMode = false @@ -416,12 +455,15 @@ async function askSingleChoice( refresh() return } - done({ + selectedAnswer = { type: "option", label: selected.label, value: selected.value, index: selected.index!, - }) + } + noteMode = true + noteEditor.setText("") + refresh() return } if (matchesKey(data, Key.escape)) { @@ -437,6 +479,23 @@ async function askSingleChoice( add(pickerTopBorder(theme, width)) + if (noteMode) { + add(theme.fg("success", " Answer selected")) + if (selectedAnswer) { + add(` ${formatAnswerForModel(selectedAnswer)}`) + } + lines.push("") + add(theme.fg("muted", " Optional note:")) + for (const line of noteEditor.render(Math.max(1, width - 2))) { + add(` ${line}`) + } + lines.push("") + add(theme.fg("dim", " Enter to submit • Esc to go back")) + add(pickerBottomBorder(theme, width)) + cachedLines = lines + return lines + } + for (let i = 0; i < allOptions.length; i++) { const option = allOptions[i]! const selected = i === optionIndex @@ -493,7 +552,7 @@ async function askMultiChoice( _question: string, _context: string | undefined, options: AskOption[], -): Promise<AskAnswer[] | null> { +): Promise<OptionAnswerResult | null> { const otherLabel = getOtherLabel(options) const choiceItems: DisplayOption[] = options.map((option, index) => ({ ...option, @@ -517,13 +576,15 @@ async function askMultiChoice( tui: any, theme: any, _kb: any, - done: (result: AskAnswer[] | null) => void, + done: (result: OptionAnswerResult | null) => void, ) => { let optionIndex = 0 let editMode = false + let noteMode = false let cachedLines: string[] | undefined const selected = new Map<string, AskAnswer>() const editor = new Editor(tui, createEditorTheme(theme)) + const noteEditor = new Editor(tui, createEditorTheme(theme)) editor.onSubmit = (value) => { const trimmed = value.trim() @@ -533,6 +594,13 @@ async function askMultiChoice( refresh() } + noteEditor.onSubmit = (value) => { + done({ + answers: sortAnswers(Array.from(selected.values())), + note: value.trim(), + }) + } + function refresh() { cachedLines = undefined tui.requestRender() @@ -553,6 +621,18 @@ async function askMultiChoice( } function handleInput(data: string) { + if (noteMode) { + if (matchesKey(data, Key.escape)) { + noteMode = false + noteEditor.setText("") + refresh() + return + } + noteEditor.handleInput(data) + refresh() + return + } + if (editMode) { if (matchesKey(data, Key.escape)) { editMode = false @@ -597,7 +677,9 @@ async function askMultiChoice( if (matchesKey(data, Key.enter)) { if (current.isSubmit) { if (selected.size > 0) { - done(sortAnswers(Array.from(selected.values()))) + noteMode = true + noteEditor.setText("") + refresh() } return } @@ -624,6 +706,23 @@ async function askMultiChoice( add(pickerTopBorder(theme, width)) + if (noteMode) { + add(theme.fg("success", ` ${selected.size} answer(s) selected`)) + for (const answer of sortAnswers(Array.from(selected.values()))) { + add(` ${formatAnswerForModel(answer)}`) + } + lines.push("") + add(theme.fg("muted", " Optional note:")) + for (const line of noteEditor.render(Math.max(1, width - 2))) { + add(` ${line}`) + } + lines.push("") + add(theme.fg("dim", " Enter to submit • Esc to go back")) + add(pickerBottomBorder(theme, width)) + cachedLines = lines + return lines + } + for (let i = 0; i < allItems.length; i++) { const item = allItems[i]! const isFocused = i === optionIndex @@ -781,28 +880,40 @@ export default function askUserQuestion(pi: ExtensionAPI) { } if (mode === "single-select") { - const answer = await askSingleChoice( + const result = await askSingleChoice( ctx, params.question, context, options, ) - if (!answer) { + if (!result) { return cancelledResult(params.question, mode, context) } - return buildResult(params.question, context, mode, [answer]) + return buildResult( + params.question, + context, + mode, + result.answers, + result.note, + ) } - const answers = await askMultiChoice( + const result = await askMultiChoice( ctx, params.question, context, options, ) - if (!answers) { + if (!result) { return cancelledResult(params.question, mode, context) } - return buildResult(params.question, context, mode, answers) + return buildResult( + params.question, + context, + mode, + result.answers, + result.note, + ) }) }, @@ -872,7 +983,18 @@ export default function askUserQuestion(pi: ExtensionAPI) { : [theme.fg("dim", `○ Rejected: ${index + 1}. ${option.label}`)], ) - return new Text([...selectedLines, ...rejectedLines].join("\n"), 0, 0) + const noteLines = + details.note && details.note.length > 0 + ? [ + `${theme.fg("muted", "Note: ")}${theme.fg("accent", details.note)}`, + ] + : [] + + return new Text( + [...selectedLines, ...rejectedLines, ...noteLines].join("\n"), + 0, + 0, + ) }, }) } From b18d32df6750919c87a950d1864b1d7be76816a6 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 14:05:47 +0200 Subject: [PATCH 103/164] Add structured exchange editor fallback --- memory/CARDS.md | 4 +- src/pi-extensions/structured-exchange.test.ts | 139 ++++++++++++- src/pi-extensions/structured-exchange.ts | 184 ++++++++++++++++-- 3 files changed, 312 insertions(+), 15 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index ed8d1882..5f54146c 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -56,7 +56,7 @@ Option-based structured exchanges advance from answer selection to a focused opt ## Card 2 — RPC editor fallback carries option notes -- **Status:** next +- **Status:** done - **Weight:** light build card, dependent on Card 1 result shape ### Objective @@ -88,7 +88,7 @@ The structured-exchange tool can collect option answers plus optional notes thro ## Card 3 — RPC structured-exchange evaluator proof -- **Status:** queued +- **Status:** next - **Weight:** light build/proof card, dependent on Card 2 RPC fallback ### Objective diff --git a/src/pi-extensions/structured-exchange.test.ts b/src/pi-extensions/structured-exchange.test.ts index c586f4a4..f396d4ea 100644 --- a/src/pi-extensions/structured-exchange.test.ts +++ b/src/pi-extensions/structured-exchange.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" -import registerStructuredExchange from "./structured-exchange.js" +import registerStructuredExchange, { + buildStructuredExchangeEditorPrefill, + parseStructuredExchangeEditorResponse, +} from "./structured-exchange.js" interface ToolTextContent { type: "text" @@ -78,6 +81,19 @@ function contextDrivingCustom(inputs: string[]) { } } +function contextEditingJson(edit: (payload: any) => void) { + return { + hasUI: true, + ui: { + editor: async (prefill: string) => { + const payload = JSON.parse(prefill) + edit(payload) + return JSON.stringify(payload) + }, + }, + } +} + function optionParams(multiSelect = false): Record<string, unknown> { return { question: "Pick a path", @@ -199,3 +215,124 @@ describe("structured exchange option notes", () => { ).not.toContain("Note:") }) }) + +describe("structured exchange RPC editor fallback", () => { + it("builds schema-tagged JSON with options and parses single-select notes", () => { + const prefill = JSON.parse( + buildStructuredExchangeEditorPrefill({ + question: "Pick a path", + context: "Choose deliberately.", + mode: "single-select", + options: [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b" }, + ], + }), + ) + + expect(prefill).toMatchObject({ + schema: "brunch.structured_exchange.editor", + schemaVersion: 1, + question: "Pick a path", + context: "Choose deliberately.", + mode: "single-select", + options: [ + { index: 1, label: "Alpha", value: "a" }, + { index: 2, label: "Beta", value: "b" }, + ], + response: { status: "cancelled", answers: [], note: "" }, + }) + + prefill.response = { + status: "answered", + answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], + note: "Because it matches the brief.", + } + + expect( + parseStructuredExchangeEditorResponse(JSON.stringify(prefill)), + ).toEqual({ + status: "answered", + answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], + note: "Because it matches the brief.", + }) + }) + + it("uses ctx.ui.editor for single-select fallback and keeps empty notes explicit", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-rpc-single", + optionParams(), + undefined, + undefined, + contextEditingJson((payload) => { + payload.response = { + status: "answered", + answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + note: "", + } + }), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "single-select", + answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + note: "", + }) + }) + + it("uses ctx.ui.editor for multi-select fallback with option notes", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-rpc-multi", + optionParams(true), + undefined, + undefined, + contextEditingJson((payload) => { + payload.response = { + status: "answered", + answers: [ + { type: "option", label: "Alpha", value: "a", index: 1 }, + { type: "other", label: "Custom", value: "Custom" }, + ], + note: "Carry this nuance.", + } + }), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "multi-select", + answers: [ + { type: "option", label: "Alpha", value: "a", index: 1 }, + { type: "other", label: "Custom", value: "Custom" }, + ], + note: "Carry this nuance.", + }) + }) + + it("returns a structured failure for invalid editor JSON", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-rpc-invalid", + optionParams(), + undefined, + undefined, + { + hasUI: true, + ui: { editor: async () => "not json" }, + }, + ) + + expect(result.details).toMatchObject({ + status: "unavailable", + mode: "single-select", + answers: [], + }) + expect(result.content[0]?.text).toContain("invalid JSON") + }) +}) diff --git a/src/pi-extensions/structured-exchange.ts b/src/pi-extensions/structured-exchange.ts index f68c312a..7964bde7 100644 --- a/src/pi-extensions/structured-exchange.ts +++ b/src/pi-extensions/structured-exchange.ts @@ -64,6 +64,19 @@ interface OptionAnswerResult { note: string } +interface StructuredExchangeEditorPrefillParams { + question: string + context?: string + mode: Exclude<AskUserQuestionMode, "text"> + options: AskOption[] +} + +interface StructuredExchangeEditorResponse { + status: "answered" | "cancelled" + answers: AskAnswer[] + note: string +} + const OptionSchema = Type.Object({ label: Type.String({ description: @@ -361,6 +374,129 @@ function buildResult( } } +export function buildStructuredExchangeEditorPrefill( + params: StructuredExchangeEditorPrefillParams, +): string { + const payload: Record<string, unknown> = { + schema: "brunch.structured_exchange.editor", + schemaVersion: 1, + question: params.question, + mode: params.mode, + options: params.options.map((option, index) => ({ + index: index + 1, + label: option.label, + value: option.value, + ...(option.description ? { description: option.description } : {}), + })), + instructions: [ + "Edit only response.", + 'For a selected listed option, add an answer like {"type":"option","label":"Alpha","value":"alpha","index":1}.', + 'For Other, add an answer like {"type":"other","label":"Custom answer","value":"Custom answer"}.', + 'Set response.note to a string. Use "" when there is no additional note.', + ], + response: { status: "cancelled", answers: [], note: "" }, + } + if (params.context !== undefined) payload.context = params.context + return JSON.stringify(payload, null, 2) +} + +export function parseStructuredExchangeEditorResponse( + value: string, +): StructuredExchangeEditorResponse | null { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return null + } + + if (!isRecord(parsed)) return null + const response = parsed.response + if (!isRecord(response)) return null + + if (response.status === "cancelled") { + return { status: "cancelled", answers: [], note: "" } + } + if (response.status !== "answered") return null + if (!Array.isArray(response.answers)) return null + if (typeof response.note !== "string") return null + + const answers = response.answers.map(parseEditorAnswer) + if (answers.some((answer) => answer === null)) return null + return { + status: "answered", + answers: sortAnswers(answers as AskAnswer[]), + note: response.note.trim(), + } +} + +function parseEditorAnswer(value: unknown): AskAnswer | null { + if (!isRecord(value)) return null + + if (value.type === "option") { + if ( + typeof value.label !== "string" || + typeof value.value !== "string" || + typeof value.index !== "number" || + !Number.isInteger(value.index) || + value.index < 1 + ) { + return null + } + return { + type: "option", + label: value.label, + value: value.value, + index: value.index, + } + } + + if (value.type === "other") { + if (typeof value.label !== "string" || typeof value.value !== "string") { + return null + } + return { type: "other", label: value.label, value: value.value } + } + + return null +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +async function askOptionsWithEditor( + ctx: any, + question: string, + context: string | undefined, + mode: Exclude<AskUserQuestionMode, "text">, + options: AskOption[], +): Promise<OptionAnswerResult | null | "invalid"> { + if (typeof ctx.ui.editor !== "function") return "invalid" + const prefillParams: StructuredExchangeEditorPrefillParams = { + question, + mode, + options, + } + if (context !== undefined) prefillParams.context = context + const edited = await ctx.ui.editor( + buildStructuredExchangeEditorPrefill(prefillParams), + ) + if (edited === undefined) return null + + const response = parseStructuredExchangeEditorResponse(edited) + if (!response) return "invalid" + if (response.status === "cancelled") return null + + if (mode === "single-select" && response.answers.length !== 1) { + return "invalid" + } + if (mode === "multi-select" && response.answers.length === 0) { + return "invalid" + } + return { answers: response.answers, note: response.note } +} + async function askSingleChoice( ctx: any, _question: string, @@ -880,12 +1016,24 @@ export default function askUserQuestion(pi: ExtensionAPI) { } if (mode === "single-select") { - const result = await askSingleChoice( - ctx, - params.question, - context, - options, - ) + const result = + typeof ctx.ui.custom === "function" + ? await askSingleChoice(ctx, params.question, context, options) + : await askOptionsWithEditor( + ctx, + params.question, + context, + mode, + options, + ) + if (result === "invalid") { + return unavailableResult( + params.question, + mode, + "ask_user_question editor fallback returned invalid JSON", + context, + ) + } if (!result) { return cancelledResult(params.question, mode, context) } @@ -898,12 +1046,24 @@ export default function askUserQuestion(pi: ExtensionAPI) { ) } - const result = await askMultiChoice( - ctx, - params.question, - context, - options, - ) + const result = + typeof ctx.ui.custom === "function" + ? await askMultiChoice(ctx, params.question, context, options) + : await askOptionsWithEditor( + ctx, + params.question, + context, + mode, + options, + ) + if (result === "invalid") { + return unavailableResult( + params.question, + mode, + "ask_user_question editor fallback returned invalid JSON", + context, + ) + } if (!result) { return cancelledResult(params.question, mode, context) } From 929c18f84c7d6af972669455436d54345b88437e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 14:09:15 +0200 Subject: [PATCH 104/164] Add structured exchange RPC proof --- memory/CARDS.md | 2 +- memory/PLAN.md | 2 +- src/pi-extensions/structured-exchange.ts | 61 +++- src/structured-exchange-rpc-proof.test.ts | 62 ++++ src/structured-exchange-rpc-proof.ts | 357 ++++++++++++++++++++++ 5 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 src/structured-exchange-rpc-proof.test.ts create mode 100644 src/structured-exchange-rpc-proof.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 5f54146c..db94b3aa 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -88,7 +88,7 @@ The structured-exchange tool can collect option answers plus optional notes thro ## Card 3 — RPC structured-exchange evaluator proof -- **Status:** next +- **Status:** done - **Weight:** light build/proof card, dependent on Card 2 RPC fallback ### Objective diff --git a/memory/PLAN.md b/memory/PLAN.md index 73ffb20f..6db4873a 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -216,7 +216,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, and response-side projection have landed; current missing seams are full structured-exchange proof across TUI/RPC/web plus visual chrome recovery) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, and the structured-exchange RPC evaluator proof have landed; current missing seams are web real-time structured-exchange observation plus visual chrome recovery) - **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-exchange proof: a registered Pi tool can collect every supported structured exchange shape (text/freeform, action or confirmation where supported, single-select/radio, multi-select/checkbox, questionnaire, optional freeform-plus-choice, option-selection notes, and terminal statuses such as answered/skipped/cancelled/unavailable); rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/note/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes terminal structured results as response-side; Brunch exposes one public product RPC surface that wraps Pi RPC extension-UI requests for agent-as-user probes and web relay clients; and the web UI receives real-time product updates when TUI or RPC interactions change selected session/exchange state. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol; scripted TUI demo covering all supported structured-exchange permutations; RPC agent-as-user evaluator probe where the evaluator has a mission/intention, a critical UX or feature-evaluation focus, and a maximum turn budget, then reports blockers and frictions; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. diff --git a/src/pi-extensions/structured-exchange.ts b/src/pi-extensions/structured-exchange.ts index 7964bde7..8871f3b5 100644 --- a/src/pi-extensions/structured-exchange.ts +++ b/src/pi-extensions/structured-exchange.ts @@ -13,7 +13,7 @@ import { } from "@earendil-works/pi-tui" import { Type } from "typebox" -interface AskOption { +export interface AskOption { label: string value: string description?: string @@ -49,13 +49,16 @@ type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable" type AskUserQuestionMode = "text" | "single-select" | "multi-select" -interface AskUserQuestionResultDetails { +export interface AskUserQuestionResultDetails { status: AskUserQuestionStatus question: string context?: string mode: AskUserQuestionMode + options?: AskOption[] answers: AskAnswer[] + rejectedOptions?: AskOption[] note?: string + transport?: { surface: "tui-custom" | "rpc-editor" | "headless" } message?: string } @@ -64,7 +67,7 @@ interface OptionAnswerResult { note: string } -interface StructuredExchangeEditorPrefillParams { +export interface StructuredExchangeEditorPrefillParams { question: string context?: string mode: Exclude<AskUserQuestionMode, "text"> @@ -285,6 +288,8 @@ function buildStructuredResult( context?: string, message?: string, note?: string, + options?: AskOption[], + transport?: AskUserQuestionResultDetails["transport"], ): AskUserQuestionResultDetails { const result: AskUserQuestionResultDetails = { status, @@ -293,7 +298,15 @@ function buildStructuredResult( answers, } if (context !== undefined) result.context = context + if (options !== undefined) { + result.options = options + result.rejectedOptions = options.filter( + (option) => + !answers.some((answer) => optionMatchesAnswer(option, answer)), + ) + } if (note !== undefined) result.note = note + if (transport !== undefined) result.transport = transport if (message !== undefined) result.message = message return result } @@ -342,6 +355,8 @@ function buildResult( mode: AskUserQuestionMode, answers: AskAnswer[], note?: string, + options?: AskOption[], + transport?: AskUserQuestionResultDetails["transport"], ) { let text: string if (mode === "text") { @@ -370,6 +385,8 @@ function buildResult( context, undefined, note, + options, + transport, ), } } @@ -430,6 +447,36 @@ export function parseStructuredExchangeEditorResponse( } } +export function structuredExchangeResultFromEditor( + params: StructuredExchangeEditorPrefillParams, + edited: string | undefined, +) { + const response = parseStructuredExchangeEditorResponse(edited ?? "") + if (edited === undefined) { + return cancelledResult(params.question, params.mode, params.context) + } + if (!response) { + return unavailableResult( + params.question, + params.mode, + "ask_user_question editor fallback returned invalid JSON", + params.context, + ) + } + if (response.status === "cancelled") { + return cancelledResult(params.question, params.mode, params.context) + } + return buildResult( + params.question, + params.context, + params.mode, + response.answers, + response.note, + params.options, + { surface: "rpc-editor" }, + ) +} + function parseEditorAnswer(value: unknown): AskAnswer | null { if (!isRecord(value)) return null @@ -1043,6 +1090,10 @@ export default function askUserQuestion(pi: ExtensionAPI) { mode, result.answers, result.note, + options, + typeof ctx.ui.custom === "function" + ? { surface: "tui-custom" } + : { surface: "rpc-editor" }, ) } @@ -1073,6 +1124,10 @@ export default function askUserQuestion(pi: ExtensionAPI) { mode, result.answers, result.note, + options, + typeof ctx.ui.custom === "function" + ? { surface: "tui-custom" } + : { surface: "rpc-editor" }, ) }) }, diff --git a/src/structured-exchange-rpc-proof.test.ts b/src/structured-exchange-rpc-proof.test.ts new file mode 100644 index 00000000..0cb9d385 --- /dev/null +++ b/src/structured-exchange-rpc-proof.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import { runStructuredExchangeRpcProof } from "./structured-exchange-rpc-proof.js" + +describe("structured-exchange RPC proof", () => { + it("round-trips option answers and notes through Pi RPC editor fallback", async () => { + const proof = await runStructuredExchangeRpcProof() + + expect(proof.scenario).toMatchObject({ + mission: expect.stringContaining("option-based structured exchange"), + evaluationFocus: expect.stringContaining("optional note"), + maxTurns: 1, + }) + expect(proof.editorRequest).toMatchObject({ + type: "extension_ui_request", + method: "editor", + title: "Answer structured exchange as JSON", + }) + expect(JSON.parse(proof.editorRequest.prefill ?? "{}")).toMatchObject({ + schema: "brunch.structured_exchange.editor", + schemaVersion: 1, + question: "Which implementation path should the evaluator choose?", + mode: "multi-select", + options: [ + { index: 1, label: "Ship RPC fallback", value: "rpc-fallback" }, + { index: 2, label: "Wait for web relay", value: "wait-web" }, + { index: 3, label: "Escalate blocker", value: "blocker" }, + ], + }) + expect(proof.terminalDetails).toMatchObject({ + status: "answered", + mode: "multi-select", + question: "Which implementation path should the evaluator choose?", + context: "Scenario: prove option answers plus notes over Pi RPC.", + options: [ + { label: "Ship RPC fallback", value: "rpc-fallback" }, + { label: "Wait for web relay", value: "wait-web" }, + { label: "Escalate blocker", value: "blocker" }, + ], + answers: [ + { + type: "option", + label: "Ship RPC fallback", + value: "rpc-fallback", + index: 1, + }, + ], + rejectedOptions: [ + { label: "Wait for web relay", value: "wait-web" }, + { label: "Escalate blocker", value: "blocker" }, + ], + note: "Proceed, but report any relay friction separately.", + transport: { surface: "rpc-editor" }, + probe: { + name: "structured-exchange-rpc-proof", + transport: "pi-rpc-editor", + }, + frictionReport: { blockers: [], frictions: [] }, + }) + expect(proof.sessionFile).toContain(".brunch/sessions") + }, 20_000) +}) diff --git a/src/structured-exchange-rpc-proof.ts b/src/structured-exchange-rpc-proof.ts new file mode 100644 index 00000000..44c41eaf --- /dev/null +++ b/src/structured-exchange-rpc-proof.ts @@ -0,0 +1,357 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import type { AskUserQuestionResultDetails } from "./pi-extensions/structured-exchange.js" + +interface ProbeMetadata { + name: string + transport: "pi-rpc-editor" +} + +interface FrictionReport { + blockers: string[] + frictions: string[] +} + +interface TerminalDetails extends AskUserQuestionResultDetails { + probe: ProbeMetadata + frictionReport: FrictionReport +} + +interface ProofResultEntry { + customType: string + data: unknown +} + +export interface StructuredExchangeRpcProofResult { + scenario: { + mission: string + evaluationFocus: string + maxTurns: number + } + editorRequest: { + type: "extension_ui_request" + id: string + method: "editor" + title?: string + prefill?: string + } + terminalDetails: TerminalDetails + sessionFile: string + stdout: unknown[] +} + +interface StructuredExchangeRpcProofOptions { + cwd?: string + timeoutMs?: number +} + +const PROOF_CUSTOM_TYPE = "brunch.structured_exchange_rpc_proof_result" + +const scenario = { + mission: + "Complete an option-based structured exchange as an agent-as-user evaluator.", + evaluationFocus: + "Verify that selected option answers and an optional note survive the Pi RPC editor fallback as structured terminal details.", + maxTurns: 1, +} + +export async function runStructuredExchangeRpcProof( + options: StructuredExchangeRpcProofOptions = {}, +): Promise<StructuredExchangeRpcProofResult> { + const cwd = + options.cwd ?? (await mkdtemp(join(tmpdir(), "brunch-exchange-rpc-proof-"))) + const timeoutMs = options.timeoutMs ?? 10_000 + const extensionPath = await writeProofExtension(cwd) + const sessionDir = join(cwd, ".brunch", "sessions") + await mkdir(sessionDir, { recursive: true }) + + const child = spawn( + process.execPath, + [ + piCliPath(), + "--mode", + "rpc", + "--no-extensions", + "--extension", + extensionPath, + "--session-dir", + sessionDir, + ], + { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }, + ) + + const client = new RpcProbeClient(child, timeoutMs) + try { + const promptAccepted = client.waitFor( + (event): event is RpcResponse => + isRpcResponse(event) && event.command === "prompt", + ) + child.stdin.write( + `${JSON.stringify({ id: "proof", type: "prompt", message: "/brunch-structured-exchange-rpc-proof" })}\n`, + ) + + const editorRequest = await client.waitFor( + (event): event is StructuredExchangeRpcProofResult["editorRequest"] => + isEditorRequest(event), + ) + child.stdin.write( + `${JSON.stringify({ + type: "extension_ui_response", + id: editorRequest.id, + value: answeredEditorPayload(editorRequest.prefill), + })}\n`, + ) + + const promptResponse = await promptAccepted + if (!promptResponse.success) { + throw new Error( + `Proof command failed: ${promptResponse.error ?? "unknown error"}`, + ) + } + + const stateResponse = client.waitFor( + (event): event is RpcResponse<{ sessionFile?: string }> => + isRpcResponse(event) && event.id === "state", + ) + child.stdin.write(`${JSON.stringify({ id: "state", type: "get_state" })}\n`) + const state = await stateResponse + const sessionFile = state.data?.sessionFile + if (!state.success || typeof sessionFile !== "string") { + throw new Error("RPC proof did not expose a persisted session file") + } + + return { + scenario, + editorRequest, + terminalDetails: await readProofDetails(sessionFile), + sessionFile, + stdout: client.events, + } + } finally { + client.dispose() + } +} + +async function writeProofExtension(cwd: string): Promise<string> { + const extensionPath = join(cwd, "structured-exchange-rpc-proof-extension.ts") + const adapterPath = resolve("src/pi-extensions/structured-exchange.ts") + const content = ` + import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + import { + buildStructuredExchangeEditorPrefill, + structuredExchangeResultFromEditor, + } from ${JSON.stringify(adapterPath)} + + const params = { + question: "Which implementation path should the evaluator choose?", + context: "Scenario: prove option answers plus notes over Pi RPC.", + mode: "multi-select", + options: [ + { label: "Ship RPC fallback", value: "rpc-fallback" }, + { label: "Wait for web relay", value: "wait-web" }, + { label: "Escalate blocker", value: "blocker" }, + ], + } as const + + export default function(pi: ExtensionAPI): void { + pi.registerCommand("brunch-structured-exchange-rpc-proof", { + description: "Exercise Brunch structured-exchange RPC editor fallback.", + handler: async (_args, ctx) => { + const edited = await ctx.ui.editor( + "Answer structured exchange as JSON", + buildStructuredExchangeEditorPrefill(params), + ) + const result = structuredExchangeResultFromEditor(params, edited) + const details = { + ...result.details, + probe: { name: "structured-exchange-rpc-proof", transport: "pi-rpc-editor" }, + frictionReport: { blockers: [], frictions: [] }, + } + ctx.sessionManager.appendMessage({ + role: "assistant", + content: result.content, + api: "openai-completions", + provider: "openai", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }) + pi.appendEntry(${JSON.stringify(PROOF_CUSTOM_TYPE)}, details) + ctx.ui.notify(result.content[0]?.text ?? "Structured exchange completed.", "info") + }, + }) + } + ` + await writeFile(extensionPath, content, "utf8") + return extensionPath +} + +function answeredEditorPayload(prefill: string | undefined): string { + if (!prefill) throw new Error("RPC editor request did not include a prefill") + const payload = JSON.parse(prefill) as { response?: unknown } + payload.response = { + status: "answered", + answers: [ + { + type: "option", + label: "Ship RPC fallback", + value: "rpc-fallback", + index: 1, + }, + ], + note: "Proceed, but report any relay friction separately.", + } + return `${JSON.stringify(payload, null, 2)}\n` +} + +async function readProofDetails( + sessionFile: string, +): Promise<StructuredExchangeRpcProofResult["terminalDetails"]> { + const entries = (await readFile(sessionFile, "utf8")) + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as unknown) + const proofEntry = entries.find( + (entry): entry is ProofResultEntry => + typeof entry === "object" && + entry !== null && + (entry as { customType?: unknown }).customType === PROOF_CUSTOM_TYPE && + "data" in entry, + ) + if (!proofEntry) { + throw new Error("RPC proof result entry was not written to the session") + } + return proofEntry.data as StructuredExchangeRpcProofResult["terminalDetails"] +} + +function piCliPath(): string { + return fileURLToPath( + new URL( + "../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + import.meta.url, + ), + ) +} + +interface RpcResponse<T = unknown> { + type: "response" + id?: string + command: string + success: boolean + data?: T + error?: string +} + +function isRpcResponse(value: unknown): value is RpcResponse { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "response" && + typeof (value as { command?: unknown }).command === "string" && + typeof (value as { success?: unknown }).success === "boolean" + ) +} + +function isEditorRequest( + value: unknown, +): value is StructuredExchangeRpcProofResult["editorRequest"] { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "extension_ui_request" && + typeof (value as { id?: unknown }).id === "string" && + (value as { method?: unknown }).method === "editor" + ) +} + +class RpcProbeClient { + readonly events: unknown[] = [] + readonly #child: ChildProcessWithoutNullStreams + readonly #timeoutMs: number + #stdout = "" + #stderr = "" + #waiters: Array<{ + predicate: (event: unknown) => boolean + resolve: (event: unknown) => void + }> = [] + + constructor(child: ChildProcessWithoutNullStreams, timeoutMs: number) { + this.#child = child + this.#timeoutMs = timeoutMs + child.stdout.on("data", (chunk) => this.#ingestStdout(String(chunk))) + child.stderr.on("data", (chunk) => { + this.#stderr += String(chunk) + }) + } + + waitFor<T,>(predicate: (event: unknown) => event is T): Promise<T> { + const existing = this.events.find(predicate) + if (existing) return Promise.resolve(existing) + + return new Promise<T>((resolve, reject) => { + const timeout = setTimeout( + () => { + reject( + new Error( + `Timed out waiting for RPC proof event. Stderr:\n${this.#stderr}`, + ), + ) + }, + this.#timeoutMs, + ) + this.#waiters.push({ + predicate, + resolve: (event) => { + clearTimeout(timeout) + resolve(event as T) + }, + }) + }) + } + + dispose(): void { + this.#child.kill("SIGTERM") + } + + #ingestStdout(chunk: string): void { + this.#stdout += chunk + while (true) { + const newline = this.#stdout.indexOf("\n") + if (newline === -1) return + const line = this.#stdout.slice(0, newline).replace(/\r$/, "") + this.#stdout = this.#stdout.slice(newline + 1) + if (line.trim().length === 0) continue + let event: unknown + try { + event = JSON.parse(line) + } catch { + continue + } + this.events.push(event) + const waiters = this.#waiters.slice() + for (const waiter of waiters) { + if (!waiter.predicate(event)) continue + this.#waiters = this.#waiters.filter( + (candidate) => candidate !== waiter, + ) + waiter.resolve(event) + } + } + } +} From 2c1c4d5dbd1f57d396721f43e92dd7393af272e5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 14:38:39 +0200 Subject: [PATCH 105/164] ln- skill pass on attacking uncertainty --- .agents/skills/ln-consult/SKILL.md | 17 ++++++++++++++++- .agents/skills/ln-design/SKILL.md | 20 ++++++++++++++------ .agents/skills/ln-plan/SKILL.md | 28 +++++++++++++++++++++++++--- .agents/skills/ln-scope/SKILL.md | 28 +++++++++++++++++++++++++--- docs/praxis/ln-skills.md | 15 +++++++++++---- 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/.agents/skills/ln-consult/SKILL.md b/.agents/skills/ln-consult/SKILL.md index 96af4b67..3743ef10 100644 --- a/.agents/skills/ln-consult/SKILL.md +++ b/.agents/skills/ln-consult/SKILL.md @@ -30,6 +30,7 @@ Start the assessment with 2-4 bullets naming: - the active frontier item or nearby priority - volatile state or manual follow-up from handoff - the main open risk +- the dominant load-bearing uncertainty (which `memory/SPEC.md` §Assumption, if any) and the cheapest way to retire it ## Work-type classification @@ -72,9 +73,23 @@ Only recommend the bounded or direct-build exceptions when all of these are true - the containing seam is already named in the live docs - no durable requirement / assumption / decision / invariant change is expected - post-build reconciliation can plausibly be a no-op +- no high-impact unresolved `memory/SPEC.md` §Assumption is load-bearing for this work Only recommend the bounded serial exception when those same conditions hold and the next several commit-sized steps are obvious enough to queue without fresh planning. +## Uncertainty-first override + +When several routes fit the work, prefer the one with the highest information gain per unit of cost. Given the repo's pre-release posture, the default uncertainty-attack vehicle is **a thin vertical slice that would break if the assumption is wrong**, built via `ln-scope` → `ln-build`. That is usually cheaper and more informative than a study-step spike. + +Recommend a non-build route **only when** the cheapest informative move genuinely is not a slice: + +- `ln-design` — the module shape itself rests on a low-confidence claim and building any slice would lock in the wrong seam +- `ln-oracles` — the verification strategy is so uncertain that you cannot tell a passing slice from a wrong one +- `ln-spike` — the question is research-grade or external (third-party API contract, vendor perf characteristic, library behavior under load) and no vertical slice would be cheaper than a pure probe +- `ln-prototype` — the question is about feel, comparison, or UX legibility, where playable variants beat real code + +This override is for **attacking** uncertainty aggressively, not deferring it. If in doubt, build the thinnest slice that would break if the belief is wrong, and let landing it produce the evidence. Spikes are the escape hatch, not the default. + ## Routing table | Situation | Work type | Suggest | @@ -88,7 +103,7 @@ Only recommend the bounded serial exception when those same conditions hold and | One settled frontier item needs several small verified commits in sequence | bounded, hardening | `ln-scope` then serial `ln-build` loop, optionally via `memory/CARDS.md` | | Module interface needs exploration | structural | `ln-design` | | Full or light scope card exists, ready to code | bounded, hardening, bugfix | `ln-build` | -| Technical uncertainty blocks progress | any | `ln-spike` | +| Technical uncertainty blocks progress, or a cheap investigation could invalidate planned work | any | `ln-spike` | | Code works but needs restructuring | refactor | `ln-refactor` | | Code works but quality / architecture needs audit | any | `ln-review` | | Docs are stale, overweight, or milestone context needs cleanup | structural / maintenance | `ln-sync` | diff --git a/.agents/skills/ln-design/SKILL.md b/.agents/skills/ln-design/SKILL.md index c764fbff..74416c24 100644 --- a/.agents/skills/ln-design/SKILL.md +++ b/.agents/skills/ln-design/SKILL.md @@ -31,7 +31,7 @@ Spawn 3+ sub-agents simultaneously. Each must produce a **radically different** - "Optimize for the most common case" - "Take inspiration from [specific paradigm or library]" -Each agent returns: **interface** (types, methods, params, invariants, ordering constraints, error modes, required configuration, and performance characteristics), **usage example**, **what it hides**, **seam / adapter strategy** where relevant, and **trade-offs**. +Each agent returns: **interface** (types, methods, params, invariants, ordering constraints, error modes, required configuration, and performance characteristics), **usage example**, **what it hides**, **seam / adapter strategy** where relevant, **trade-offs**, **load-bearing claims** (1–3 falsifiable beliefs the design rests on — for each, note whether it is already covered by `memory/SPEC.md` §Assumptions), and **cheapest falsifier** — preferably a thin `ln-scope` slice whose landing would break if the design's load-bearing claim is wrong. Fall back to `ln-spike` only when no buildable slice could carry the proof (external API contract, vendor characteristic, research-grade unknown). ### 3. Present and compare @@ -43,8 +43,9 @@ Show each design sequentially, then compare in prose on: - **Ease of correct use** vs ease of misuse - **General-purpose vs specialized**: flexibility vs focus - **Implementation efficiency**: does the shape allow efficient internals? +- **Epistemic cost**: how much unvalidated reality this shape asks callers / sequencing to trust, and how cheaply that trust can be tested before committing -Highlight where designs diverge most. +Highlight where designs diverge most, including which design has the cheapest path to falsification if its load-bearing claims are wrong. ### 4. Synthesize @@ -52,7 +53,13 @@ The best design often combines insights from multiple options. Ask which shape b ## Output -Present the recommended module shape with rationale. If `memory/SPEC.md` exists, ensure names align with its lexicon. +Present the recommended module shape with rationale, plus: + +- the 1–3 load-bearing claims it rests on +- which of those are already covered by `memory/SPEC.md` §Assumptions and which need to be added there +- the recommended first proving step — default to a thin `ln-scope` slice that would break if the chosen design's highest load-bearing claim is wrong; recommend `ln-spike` instead only when no slice could carry the proof more cheaply + +If `memory/SPEC.md` exists, ensure names align with its lexicon. Do not invent a standalone design document unless the user explicitly asks for one. Durable design choices reconcile back into `memory/SPEC.md` and `memory/PLAN.md`. @@ -63,10 +70,11 @@ After choosing a design, present these options to the user (use `tool-ask-questi | # | Label | Target | Why | | --- | ------------- | ---------- | ---------------------------------------- | | 1 | Scope a slice | `ln-scope` | Design is chosen, define the first slice | -| 2 | Write a spec | `ln-spec` | Module needs a full spec before slicing | -| 3 | Grill it more | `ln-grill` | Design choice raised new questions | +| 2 | Spike first | `ln-spike` | The chosen design rests on a low-confidence load-bearing claim worth retiring before scoping | +| 3 | Write a spec | `ln-spec` | Module needs a full spec before slicing | +| 4 | Grill it more | `ln-grill` | Design choice raised new questions | -Recommended: **1** +Recommended: **1** — including when a load-bearing claim is low-confidence, because the preferred falsifier is a thin slice that breaks if the claim is wrong. Recommend **2 (Spike first)** only when no buildable slice could carry the proof (external API contract, vendor characteristic, research-grade unknown). --- *Adapted from [mattpocock/skills/design-an-interface](https://github.com/mattpocock/skills/tree/main/design-an-interface).* diff --git a/.agents/skills/ln-plan/SKILL.md b/.agents/skills/ln-plan/SKILL.md index 93d3c326..d4dcccf5 100644 --- a/.agents/skills/ln-plan/SKILL.md +++ b/.agents/skills/ln-plan/SKILL.md @@ -100,6 +100,27 @@ When a frontier completes, remove it from `Sequencing`, add a terse `Recently Co If live low-confidence assumptions block downstream work, stop the plan at that boundary. Plan spikes or thinner proving frontier items, not fantasy certainty. +### Uncertainty-first sequencing + +Sequencing is not only seam-driven. Before fixing `Sequencing`, rank the live assumptions in `memory/SPEC.md` §Assumptions by: + +- **blast radius** if the assumption turns out false (how many downstream frontier items rework) +- **reversibility cost** if discovered late vs early +- **validation cost** (cheap spike vs expensive end-to-end build) +- **load-bearingness** (how many active/next frontiers depend on it) + +Given the repo's pre-release posture, prefer **the thinnest vertical frontier item that would break if the load-bearing assumption is wrong**. A frontier whose landing falsifies or confirms the belief is almost always cheaper and more informative than a study-step spike. Verticality of slices still applies; this is a tie-breaker and a re-ordering pressure, not a license to fragment into horizontal investigations. + +Annotate each `Active` / `Next` frontier definition with one of the following lines when assumptions are in play: + +- `Retires: <SPEC assumption id(s)>` — this frontier collapses the assumption by landing +- `Depends on: <SPEC assumption id(s)> (validated enough)` — assumption must be settled before this frontier starts +- `Blocked by: <SPEC assumption id(s)>` — assumption is live and load-bearing; do not start until retired + +Use `ln-spike` only when the question is genuinely outside the buildable surface — for example a third-party API contract, vendor performance characteristic, or research-grade unknown where no vertical frontier could carry the proof cheaper than a probe. Do not insert ceremonial spikes when a thin proving frontier exists. + +This sequencing pressure is distinct from "Epistemic horizon": that rule tells the planner to *stop* at fog; this rule tells the planner to *attack the fog* by reordering toward whichever next landed frontier produces the most information. + ## Procedure 1. Read `memory/PLAN.md` if it exists. Identify existing frontier ids and retire/archive stale completed material into `docs/archive/PLAN_HISTORY.md`. @@ -146,7 +167,8 @@ After writing the plan, present these options to the user (use `tool-ask-questio | --- | ----------------- | ------------ | --- | | 1 | Scope next slice | `ln-scope` | The frontier is clear and ready to scope | | 2 | Design oracles | `ln-oracles` | Verification design needs explicit work | -| 3 | Grill it more | `ln-grill` | Planning surfaced unresolved product questions | -| 4 | Back to triage | `ln-consult` | Direction needs reassessment | +| 3 | Spike first | `ln-spike` | A load-bearing assumption should be retired before scoping | +| 4 | Grill it more | `ln-grill` | Planning surfaced unresolved product questions | +| 5 | Back to triage | `ln-consult` | Direction needs reassessment | -Recommended: **1** +Recommended: **1** unless uncertainty-first sequencing surfaced a load-bearing assumption whose cheapest retirement is a spike (then **3**). diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index e0150bf5..5148becc 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -101,10 +101,22 @@ Every boundary the slice passes through, entry to exit: ``` - RISK: [what might not work] → MITIGATION: [how to handle it] -- ASSUMPTION: [what we're assuming] → VALIDATE: [how we'll know] → [→ memory/SPEC.md §Assumptions] +- ASSUMPTION: [what we're assuming] + → IMPACT IF FALSE: [what breaks / rework cost / blast radius across queued cards or other frontiers] + → VALIDATE: [cheapest proof — spike, fixture, contract test, prototype] + → [→ memory/SPEC.md §Assumptions id] ``` -High-risk unvalidated assumption → suggest `ln-spike` before `ln-build`. +### Uncertainty gate + +If the slice depends on an unresolved high-impact assumption that the slice's own acceptance criteria and oracle strategy will not directly retire, do not let it pass as-is. Given the repo's pre-release posture, the preferred response is **aggressive**, not cautious: + +1. **First choice — narrow into a tracer bullet.** Reshape the slice so that *landing it end-to-end is the proof step* that falsifies or confirms the assumption. A thin vertical slice that would break if the assumption is wrong is almost always the cheapest and most informative falsifier in this codebase. Then recommend `ln-build`. +2. **Escape hatch — `ln-spike`.** Only when no vertical slice would be cheaper than a pure-investigation probe (for example: an external API contract question, a third-party perf characteristic, a research-grade unknown) route to `ln-spike` instead of building. + +"High-impact" means the assumption being false would force rework across more than this slice — invalidating queued cards, changing the chosen module shape from `ln-design`, or forcing a different frontier-level sequencing decision. + +The gate is not a brake. It is a reshaping rule: the assumption must be **attacked** by the next move, preferably by building. ### Acceptance Criteria @@ -161,12 +173,22 @@ For light cards, include this section whenever the containing frontier definitio - [obligation] ``` +### Assumption dependency + +State one of: + +- `None` — this slice's correctness does not hinge on any live `memory/SPEC.md` §Assumptions +- `Depends on: <SPEC assumption id(s)>` — and a one-line note on why those assumptions are validated enough to build against + +If a light card would have to mark `Depends on:` a high-impact unvalidated assumption, promote to a full scope card and apply the **Uncertainty gate**. + ### Promotion checklist If any answer is yes, stop treating the work as light and promote it to a full scope card before routing to `ln-build`. Do not quietly carry durable change under a light card. - [ ] Does this change a requirement? - [ ] Does this create, retire, or invalidate an assumption? +- [ ] Does this slice depend on an unvalidated high-impact assumption? - [ ] Does this make or reverse a non-trivial design decision? - [ ] Does this establish a new seam-level invariant? - [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? @@ -201,4 +223,4 @@ After the scope card is complete, present these options to the user (use `tool-a | 5 | Revise plan | `ln-plan` | The work no longer fits the current frontier | | 6 | Back to triage | `ln-consult` | Scope revealed unclear state | -Recommended: **1** unless the promotion checklist fires or the verification approach is still unclear. If a short prepared queue is warranted, write it to `memory/CARDS.md` and let `ln-build` consume the next ready card from there. +Recommended: **1** in nearly all cases — including when the **Uncertainty gate** trips, because the gate's preferred resolution is to reshape the slice into a tracer bullet that falsifies the assumption by landing, then build it. Recommend **3 (Spike first)** only when no vertical slice would be cheaper than a pure-investigation probe. Recommend **2 (Design oracles)** only when the verification approach for the reshaped slice is still genuinely unclear. If a short prepared queue is warranted, write it to `memory/CARDS.md` and let `ln-build` consume the next ready card from there. diff --git a/docs/praxis/ln-skills.md b/docs/praxis/ln-skills.md index 29193af5..b15f673b 100644 --- a/docs/praxis/ln-skills.md +++ b/docs/praxis/ln-skills.md @@ -23,10 +23,8 @@ ln-consult → ln-grill or ln-disambiguate → ln-spec → ln-plan - → ln-oracles - → ln-scope - → ln-spike (optional) - → ln-build + → ln-scope ← default uncertainty-attack: thin tracer-bullet slice + → ln-build whose landing falsifies the load-bearing belief → ln-review → ln-witness (optional) → ln-refactor (optional) @@ -36,6 +34,15 @@ ln-consult The flow is not a checklist. Skip steps whose uncertainty is already retired. +Given the repo's pre-release posture, **the primary vehicle for retiring uncertainty is a thin vertical slice that would break if a load-bearing assumption is wrong** — built through `ln-scope` → `ln-build`. Non-build detours are escape hatches for questions a slice cannot cheaply carry: + +- `ln-design` — module shape itself is the uncertain thing and any slice would lock in the wrong seam +- `ln-oracles` — verification strategy is too uncertain to distinguish a passing slice from a wrong one +- `ln-spike` — research-grade or external question (third-party API contract, vendor performance characteristic, library behavior under load) where no buildable slice would be cheaper than a probe +- `ln-prototype` — feel, comparison, or UX-legibility question better answered by playable variants than by real code + +`ln-plan`, `ln-design`, `ln-scope`, and `ln-consult` all bias toward attacking uncertainty by building. The escape hatches stay available; they just are not the default. + ## Skill map ### Triage and orientation From b4c226005a0ddedfb36f0e19d371929e3b7521fb Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 14:55:36 +0200 Subject: [PATCH 106/164] Add public RPC method discovery --- memory/CARDS.md | 145 +++++++++++++++--------------------- src/rpc.test.ts | 156 +++++++++++++++++++++++++++++++++++++++ src/rpc.ts | 190 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 403 insertions(+), 88 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index db94b3aa..7f70da26 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -1,123 +1,92 @@ <!-- CARDS.md — temporary execution queue for the active FE-744 frontier. Delete when exhausted or superseded. Canonical state remains in memory/SPEC.md and memory/PLAN.md. --> -# Cards — FE-744 structured exchange proof +# Cards — FE-744 public RPC elicitation parity ## Orientation -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange tool surface now hosted at `src/pi-extensions/structured-exchange.ts` with a project-local Pi loader at `.pi/extensions/structured-exchange.ts`. -- **Frontier item:** `pi-ui-extension-patterns`; these cards are slices inside the existing FE-744 Linear/branch boundary, not new tracker items. -- **Volatile state:** `HANDOFF.md` is absent; the working tree was clean before this queue was written. Canonical reconciliation for the newly remembered optional-note requirement has been applied to `memory/SPEC.md` and `memory/PLAN.md`. -- **Main open risk:** the TUI note step must not make the active `ctx.ui.custom()` surface tall again, and the RPC proof must falsify semantic parity rather than merely proving a low-level Pi editor request round-trips. +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, now re-aimed from the completed raw Pi RPC editor-fallback proof toward the public Brunch JSON-RPC elicitation session parity proof. +- **Frontier item:** `pi-ui-extension-patterns`; this card is a slice inside the existing FE-744 Linear/branch boundary, not a new tracker item or branch. +- **Volatile handoff state:** `HANDOFF.md` remains an untracked transfer artifact. Its durable claims have been reconciled into `memory/SPEC.md` / `memory/PLAN.md`; do not treat the old completed card queue as active. +- **Main open risk:** discovery must be useful to an agent-as-user without becoming a generic RPC platform or drifting away from the handlers that actually validate and serve Brunch methods. ## Cross-cutting obligations for all cards -- Preserve Pi transcript truth: terminal structured exchange results must be self-contained in `toolResult.details` (or proof custom entry details where the probe directly exercises adapter helpers). -- Preserve linear transcript policy: no Pi branching, no parallel chat/turn store, and no mid-turn state outside the established Pi transcript / Brunch handler seams. -- Keep option-selection `note` separate from `Other`/custom answers: `Other` is an answer value; `note` is additional context attached to the selected answer(s). -- Keep full question/details content out of the focused picker unless a later explicit internal viewport slice is scoped. -- Do not mutate the user-level Pi extension/config under `/Users/lunelson/.pi/agent/`. +- Public clients speak Brunch JSON-RPC only. Raw Pi RPC may be used behind Brunch adapters, but method discovery must not expose Pi command objects, Pi `get_commands`, or slash-command internals as Brunch product methods. +- Preserve TypeBox as the runtime schema vocabulary for Brunch boundaries (`D41-L`); do not introduce Zod or hand-wavy schema prose for RPC discovery. +- Preserve the thin named-method-family posture (`D19-L`): concrete product methods and projection handlers, not a generic read gateway or generic records API. +- Preserve workspace/spec/session hierarchy and explicit activation semantics (`D11-L`, `D21-L`, `I22-L`). Discovery must describe activation; it must not silently activate or create sessions. +- Preserve linear transcript policy and transcript-backed elicitation (`I19-L`, `I23-L`, `I32-L`) even though this first card does not yet implement pending/respond. --- -## Card 1 — Option-selection note step in TUI +## Card 1 — Public RPC method discovery registry - **Status:** done -- **Weight:** light build card inside a now-reconciled structural frontier +- **Weight:** full scope card — establishes the public method-discovery seam for FE-744 and becomes the contract source for later agent-as-user probes. -### Objective +### Target Behavior -Option-based structured exchanges advance from answer selection to a focused optional-note editor before submitting the terminal result. +A Brunch JSON-RPC client can call `rpc.discover` with no params and receive a self-describing list of currently supported public Brunch methods with descriptions, parameter schemas, result schemas, and example calls. -### Acceptance Criteria - -✓ Single-select mode moves to a note step after selecting a listed option or `Other`; the note editor is focused; pressing Enter submits even when empty. -✓ Multi-select mode moves to a note step after activating Submit; the note editor is focused; pressing Enter submits even when empty. -✓ Esc from the note step returns to the answer picker with prior selections preserved rather than cancelling the whole exchange. -✓ `toolResult.details` for answered option modes includes a string `note` field, with `""` representing an intentionally empty note. -✓ `Other` remains represented as an `OtherAnswer`; it is not folded into `note`. -✓ `renderResult` shows the note only when non-empty while preserving the selected/rejected summary. -✓ Text/freeform mode behavior is unchanged by this card. +### Boundary Crossings -### Verification Approach +```text +→ JSON-RPC request { method: "rpc.discover" } +→ createRpcHandlers dispatch +→ Brunch-owned RPC method registry / schema descriptions +→ TypeBox/JSON-Schema-shaped method metadata +→ JSON-RPC success response usable by CLI/web/fixture clients +``` -- **Inner:** `npm run fix`; targeted `vitest` for structured-exchange tests; `npm run check`. -- **Middle:** component/state-machine tests drive the registered tool through fake `ctx.ui.custom()` callbacks for single-select and multi-select, including empty-note and non-empty-note submissions. -- **Outer:** optional manual TUI smoke to confirm the note step feels like a compact second tab/step and does not reintroduce tall active content. +### Risks and Assumptions -### Promotion checklist - -- [ ] Requirement already reconciled? Yes — SPEC/PLAN now name optional notes for option-selection exchanges. -- [ ] Creates/retires/invalidates an assumption? No. -- [ ] New seam-level invariant? No; implements the existing structured-result self-containment invariant. -- [ ] More than two major seams? No — TUI tool UI + result payload/rendering. - ---- - -## Card 2 — RPC editor fallback carries option notes - -- **Status:** done -- **Weight:** light build card, dependent on Card 1 result shape - -### Objective - -The structured-exchange tool can collect option answers plus optional notes through Pi RPC using schema-tagged JSON over `ctx.ui.editor()` instead of `ctx.ui.custom()`. +- RISK: Discovery schemas drift from handler validation schemas. + → MITIGATION: centralize discoverable method metadata near the RPC handler layer; reuse exported TypeBox schemas where they already exist (for example `SpecSessionActivationDecisionSchema` / activation params) rather than duplicating shapes in comments. +- RISK: Discovery tries to describe future methods and misleads the agent-as-user probe. + → MITIGATION: `rpc.discover` lists only methods implemented by the current host in this slice; pending future methods (`session.startElicitation`, `session.pendingExchange`, `elicitation.respond`) land with their own cards. +- RISK: Examples become a second informal contract that diverges from schemas. + → MITIGATION: tests assert examples are valid JSON-RPC request shapes for their advertised methods and include no raw Pi RPC commands. +- ASSUMPTION: A compact hand-authored registry for the current method set is enough to bootstrap public discovery without refactoring the whole dispatcher into a framework. + → IMPACT IF FALSE: later pending/respond work may need a deeper handler-table refactor before the parity driver can rely on discovery. + → VALIDATE: this card lands a discoverable registry for all currently implemented public methods and tests it against existing handler behavior. + → `memory/SPEC.md` §Assumptions: A23-L. ### Acceptance Criteria -✓ In an RPC-compatible path, single-select payloads include options, selected answer, and `note`. -✓ In an RPC-compatible path, multi-select payloads include options, selected answers, and `note`. -✓ Empty-note submissions round-trip as `note: ""`. -✓ Invalid editor JSON returns a structured terminal failure or retry/error result without producing a malformed answered payload. -✓ TUI `ctx.ui.custom()` behavior from Card 1 remains the rich path; RPC/editor fallback is an adapter over Pi-supported extension UI, not a new public Pi command family. +✓ `rpc.discover` — returns entries for `rpc.discover`, `workspace.snapshot`, `workspace.selectionState`, `workspace.activate`, `session.elicitationExchanges`, and `session.transcriptDisplay`. -### Verification Approach +✓ `rpc.discover` params contract — rejects any non-empty `params` with JSON-RPC `-32602 Invalid params`. -- **Inner:** `npm run fix`; targeted helper/adapter tests; `npm run check`. -- **Middle:** contract tests for JSON prefill/parse/validation prove the returned `toolResult.details` is self-contained for option answers plus notes. -- **Outer:** defer full subprocess RPC proof to Card 3. +✓ Method metadata shape — every discovered method has `method`, `description`, `paramsSchema`, `resultSchema`, and at least one JSON-RPC example call. -### Promotion checklist - -- [ ] Requirement already reconciled? Yes. -- [ ] Creates/retires/invalidates an assumption? No unless Pi RPC cannot express the fallback. -- [ ] New seam-level invariant? No; it exercises D38-L JSON-over-editor compatibility. -- [ ] More than two major seams? Borderline but acceptable: tool result model + Pi RPC editor adapter; public Brunch relay stays for later proof work. - ---- - -## Card 3 — RPC structured-exchange evaluator proof - -- **Status:** done -- **Weight:** light build/proof card, dependent on Card 2 RPC fallback - -### Objective - -A repeatable RPC probe demonstrates that an agent-as-user can complete an option-based structured exchange with an optional note and report blocker/friction findings. +✓ Product boundary — discovery does not list raw Pi RPC commands such as `prompt`, `get_state`, `get_commands`, or slash command names. -### Acceptance Criteria +✓ Schema usefulness — `workspace.activate` discovery exposes the activation decision union closely enough that a client can see `continue`, `openSession`, `newSession`, `newSpec`, and `cancel` variants without reading source. -✓ The probe runs Pi in `--mode rpc` with the project structured-exchange extension or a minimal proof extension importing the same implementation/helpers. -✓ The evaluator scenario declares mission/intention, UX or feature-evaluation focus, and max-turn budget in the probe fixture/result. -✓ The scripted agent-as-user response selects at least one option and submits a non-empty note. -✓ The captured terminal details include prompt/question, options, selected answer(s), rejected option context where applicable, `note`, mode, status, and transport/probe metadata sufficient for Brunch projection. -✓ The probe emits a blocker/friction report even when no blockers are found. -✓ A regression test fails if the RPC path silently drops `note` or only proves raw `extension_ui_request(editor)` without validating the structured result payload. +✓ Drift guard — examples in discovery are valid JSON-RPC request objects for advertised methods, and tests fail if discovery omits an implemented public method or advertises an unsupported method. ### Verification Approach -- **Inner:** `npm run fix`; targeted `vitest` for the RPC proof; `npm run check`. -- **Middle:** subprocess RPC proof analogous to `src/structured-question-rpc-proof.ts`, but shaped around structured exchange option selection plus note. -- **Outer:** manual review of the saved probe result/session snippet to confirm the transcript is intelligible as evidence, not just protocol noise. +- **Inner:** `npm run fix`; targeted `vitest` for `src/rpc.test.ts`; `npm run check`. +- **Middle:** JSON-RPC contract tests for discovery shape, invalid params, no raw Pi exposure, example validity, and registry/dispatcher method parity for the currently implemented public method set. +- **Outer:** none for this card; human review of the discovery response is sufficient until the agent-as-user parity driver consumes it. -### Promotion checklist +### Cross-cutting obligations -- [ ] Requirement already reconciled? Yes. -- [ ] Creates/retires/invalidates an assumption? No if it passes; if it fails, route to `ln-plan`/`ln-spike` because A5-L / FE-744 RPC proof pressure changes. -- [ ] New seam-level invariant? No; it adds coverage to existing structured-exchange/RPC obligations. -- [ ] More than two major seams? No for the proof harness; public web relay remains intentionally out of this queue. +- Keep discovery Brunch-owned and product-shaped (`D5-L`, `D48-L`); do not copy Pi's non-JSON-RPC command shape. +- Keep TypeBox/JSON-Schema as the schema vocabulary for RPC boundary metadata (`D41-L`). +- Keep discovery scoped to named Brunch method families (`D19-L`); do not introduce generic `records.*` or a read-model platform. +- Preserve activation/session semantics: describe `workspace.*` methods without opening sessions or invoking TUI picker code (`I22-L`). -## Not queued yet +### Promotion checklist -- Web real-time update smoke should be scoped after Card 3, because its exact target should follow the proven RPC/public-surface shape rather than guessing ahead. -- Invocation-discipline tightening should be scoped separately after the transport proof, because it changes assistant-facing tool guidance rather than response semantics. +- [x] Does this change a requirement? Already reconciled in `memory/SPEC.md` as R27/R24/R28. +- [x] Does this create, retire, or invalidate an assumption? It advances but does not retire A23-L. +- [x] Does this slice depend on an unvalidated high-impact assumption? It attacks A23-L as the first tracer bullet rather than assuming the whole ten-turn proof works. +- [x] Does this make or reverse a non-trivial design decision? Already reconciled as D48-L; this card implements it. +- [x] Does this establish a new seam-level invariant? Already reconciled as I32-L; this card establishes the discovery part. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No — obligations were reconciled in SPEC/PLAN before this card. +- [ ] Does it cross more than two major seams? No — JSON-RPC dispatch + registry/schema metadata. +- [x] Is this the first touch in an unfamiliar seam from a fresh thread? Yes; use full card. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/src/rpc.test.ts b/src/rpc.test.ts index cb14c51f..644ef45f 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it } from "vitest" import { SessionManager } from "@earendil-works/pi-coding-agent" +import { Value } from "typebox/value" + import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" import { createSessionBindingData } from "./session-binding.js" import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" @@ -176,6 +178,160 @@ function sessionBindingEntry(sessionId = "session-1", specId = "spec-1") { } describe("JSON-RPC handlers", () => { + it("discovers the current public Brunch JSON-RPC surface", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: "/tmp/brunch-project", + }) + + const response = await handlers.handle({ + jsonrpc: "2.0", + id: 30, + method: "rpc.discover", + }) + + expect(response).toMatchObject({ jsonrpc: "2.0", id: 30 }) + if (!("result" in response)) throw new Error("expected success response") + + const methods = (response.result as { + methods: Array<{ + method: string + description: string + paramsSchema: unknown + resultSchema: unknown + examples: Array<Record<string, unknown>> + }> + }).methods + expect(methods.map((entry) => entry.method).sort()).toEqual([ + "rpc.discover", + "session.elicitationExchanges", + "session.transcriptDisplay", + "workspace.activate", + "workspace.selectionState", + "workspace.snapshot", + ]) + + const discoveredNames = new Set(methods.map((entry) => entry.method)) + for (const entry of methods) { + expect(entry.description).toEqual(expect.any(String)) + expect(entry.description.length).toBeGreaterThan(10) + expect(entry.paramsSchema).toEqual(expect.any(Object)) + expect(entry.resultSchema).toEqual(expect.any(Object)) + expect(entry.examples.length).toBeGreaterThanOrEqual(1) + for (const example of entry.examples) { + expect(example).toMatchObject({ jsonrpc: "2.0", method: entry.method }) + expect(discoveredNames.has(String(example.method))).toBe(true) + } + } + }) + + it("rejects params on method discovery", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 31, + method: "rpc.discover", + params: {}, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 31, + error: { code: -32602, message: "Invalid params" }, + }) + }) + + it("keeps discovery product-shaped and exposes workspace activation variants", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: "/tmp/brunch-project", + }) + + const response = await handlers.handle({ + jsonrpc: "2.0", + id: 32, + method: "rpc.discover", + }) + if (!("result" in response)) throw new Error("expected success response") + + const result = response.result as { + methods: Array<{ + method: string + paramsSchema: unknown + examples: unknown[] + }> + } + const methods = result.methods + const discoveryJson = JSON.stringify(result) + expect(discoveryJson).not.toContain("get_commands") + expect(discoveryJson).not.toContain("get_state") + expect(discoveryJson).not.toContain("prompt") + expect(discoveryJson).not.toContain("/brunch") + + const activation = methods.find( + (entry) => entry.method === "workspace.activate", + ) + expect(activation).toBeDefined() + const activationSchema = JSON.stringify(activation?.paramsSchema) + for (const action of [ + "continue", + "openSession", + "newSession", + "newSpec", + "cancel", + ]) { + expect(activationSchema).toContain(action) + } + }) + + it("serves discovery examples that are valid JSON-RPC requests for advertised methods", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: "/tmp/brunch-project", + }) + + const response = await handlers.handle({ + jsonrpc: "2.0", + id: 33, + method: "rpc.discover", + }) + if (!("result" in response)) throw new Error("expected success response") + + const methods = (response.result as { + methods: Array<{ + method: string + examples: unknown[] + }> + }).methods + const discoveredNames = new Set(methods.map((entry) => entry.method)) + const exampleRequestSchema = { + type: "object", + properties: { + jsonrpc: { const: "2.0" }, + id: { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }, + method: { type: "string" }, + params: {}, + }, + required: ["jsonrpc", "method"], + additionalProperties: false, + } + + for (const entry of methods) { + for (const example of entry.examples) { + expect(Value.Check(exampleRequestSchema, example)).toBe(true) + expect( + discoveredNames.has((example as { method: string }).method), + ).toBe(true) + } + } + }) + it("serves structured workspace selection state without invoking the TUI picker", async () => { const handlers = createRpcHandlers({ coordinator: coordinator(selectSpecState()), diff --git a/src/rpc.ts b/src/rpc.ts index b442c074..46b983ef 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -20,6 +20,7 @@ import { jsonRpcRequestId, dispatchJsonRpcMessage, type JsonRpcId, + type JsonRpcRequest, type JsonRpcResponse, } from "./json-rpc-protocol.js" import { workspaceSnapshotFromState } from "./print-snapshot.js" @@ -53,6 +54,13 @@ export function createRpcHandlers(options: { const requestId = jsonRpcRequestId(request) + if (request.method === "rpc.discover") { + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, "Invalid params") + } + return createJsonRpcSuccess(requestId, discoverPublicRpcMethods()) + } + if (request.method === "workspace.snapshot") { if (request.params !== undefined) { return createJsonRpcFailure(requestId, -32602, "Invalid params") @@ -204,6 +212,188 @@ const WorkspaceActivationParamsSchema = Type.Object( type WorkspaceActivationParams = Static<typeof WorkspaceActivationParamsSchema> +const NoParamsSchema = Type.Void({ description: "Omit JSON-RPC params." }) + +const WorkspaceSnapshotResultSchema = Type.Object( + { + status: Type.String(), + cwd: Type.String(), + spec: Type.Union([ + Type.Null(), + Type.Object({ id: Type.String(), title: Type.String() }, { + additionalProperties: true, + }), + ]), + chrome: Type.Object({}, { additionalProperties: true }), + }, + { additionalProperties: true }, +) + +const WorkspaceSelectionStateResultSchema = Type.Object( + { + status: Type.String(), + requiresSelection: Type.Boolean(), + cwd: Type.String(), + specs: Type.Array(Type.Object({}, { additionalProperties: true })), + unavailableSessions: Type.Array( + Type.Object({}, { additionalProperties: true }), + ), + }, + { additionalProperties: true }, +) + +const WorkspaceActivationResultSchema = Type.Union([ + WorkspaceSnapshotResultSchema, + Type.Object( + { + status: Type.Literal("cancelled"), + cwd: Type.String(), + spec: Type.Union([ + Type.Null(), + Type.Object({ id: Type.String(), title: Type.String() }, { + additionalProperties: true, + }), + ]), + chrome: Type.Object( + { + phase: Type.Union([ + Type.Literal("select_spec"), + Type.Literal("elicitation"), + ]), + chatMode: Type.Union([ + Type.Literal("select-spec"), + Type.Literal("responding-to-elicitation"), + ]), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, + ), +]) + +const SessionProjectionParamsSchema = Type.Object( + { + sessionId: NonBlankStringSchema, + specId: Type.Optional(NonBlankStringSchema), + }, + { additionalProperties: false }, +) + +const ElicitationExchangesResultSchema = Type.Object( + { + status: Type.String(), + exchanges: Type.Array(Type.Object({}, { additionalProperties: true })), + }, + { additionalProperties: true }, +) + +const TranscriptDisplayResultSchema = Type.Object( + { + rows: Type.Array(Type.Object({}, { additionalProperties: true })), + }, + { additionalProperties: true }, +) + +type RpcMethodDiscovery = { + method: string + description: string + paramsSchema: unknown + resultSchema: unknown + examples: JsonRpcRequest[] +} + +function discoverPublicRpcMethods(): { methods: RpcMethodDiscovery[] } { + return { methods: PUBLIC_RPC_METHOD_DISCOVERY } +} + +const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ + { + method: "rpc.discover", + description: + "List the public Brunch JSON-RPC methods supported by this host with schemas and example calls.", + paramsSchema: NoParamsSchema, + resultSchema: Type.Object( + { methods: Type.Array(Type.Object({}, { additionalProperties: true })) }, + { additionalProperties: false }, + ), + examples: [{ jsonrpc: "2.0", id: 1, method: "rpc.discover" }], + }, + { + method: "workspace.snapshot", + description: + "Return the current Brunch workspace/spec/session snapshot for the invocation cwd without changing activation state.", + paramsSchema: NoParamsSchema, + resultSchema: WorkspaceSnapshotResultSchema, + examples: [{ jsonrpc: "2.0", id: 2, method: "workspace.snapshot" }], + }, + { + method: "workspace.selectionState", + description: + "Return the product-shaped workspace inventory and whether the client must choose or create a spec/session before an agent loop can run.", + paramsSchema: NoParamsSchema, + resultSchema: WorkspaceSelectionStateResultSchema, + examples: [{ jsonrpc: "2.0", id: 3, method: "workspace.selectionState" }], + }, + { + method: "workspace.activate", + description: + "Apply an explicit workspace→spec→session activation decision such as continuing, opening a session, creating a session, creating a spec, or cancelling.", + paramsSchema: WorkspaceActivationParamsSchema, + resultSchema: WorkspaceActivationResultSchema, + examples: [ + { + jsonrpc: "2.0", + id: 4, + method: "workspace.activate", + params: { decision: { action: "newSpec", title: "POC spec" } }, + }, + { + jsonrpc: "2.0", + id: 5, + method: "workspace.activate", + params: { + decision: { + action: "openSession", + specId: "spec-1", + sessionFile: ".brunch/sessions/session-1.jsonl", + }, + }, + }, + ], + }, + { + method: "session.elicitationExchanges", + description: + "Project structured elicitation exchanges from the selected or explicitly named linear Brunch session transcript.", + paramsSchema: SessionProjectionParamsSchema, + resultSchema: ElicitationExchangesResultSchema, + examples: [ + { + jsonrpc: "2.0", + id: 6, + method: "session.elicitationExchanges", + params: { sessionId: "session-1", specId: "spec-1" }, + }, + ], + }, + { + method: "session.transcriptDisplay", + description: + "Project transcript display rows from the selected or explicitly named linear Brunch session transcript.", + paramsSchema: SessionProjectionParamsSchema, + resultSchema: TranscriptDisplayResultSchema, + examples: [ + { + jsonrpc: "2.0", + id: 7, + method: "session.transcriptDisplay", + params: { sessionId: "session-1", specId: "spec-1" }, + }, + ], + }, +] + type WorkspaceActivationParamsParseResult = { ok: true value: SpecSessionActivationDecision From b438d40ec8d609e9bb924db0c4468e014b639c38 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:05:01 +0200 Subject: [PATCH 107/164] sharpening of ln- skills around tracer bullet convergent targets --- .agents/skills/ln-consult/SKILL.md | 18 +++++++++--------- .agents/skills/ln-design/SKILL.md | 6 +++--- .agents/skills/ln-plan/SKILL.md | 28 +++++++++++++++------------- .agents/skills/ln-scope/SKILL.md | 16 +++++++++------- docs/praxis/ln-skills.md | 19 +++++++++++++------ 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/.agents/skills/ln-consult/SKILL.md b/.agents/skills/ln-consult/SKILL.md index 3743ef10..ca4161c2 100644 --- a/.agents/skills/ln-consult/SKILL.md +++ b/.agents/skills/ln-consult/SKILL.md @@ -30,7 +30,7 @@ Start the assessment with 2-4 bullets naming: - the active frontier item or nearby priority - volatile state or manual follow-up from handoff - the main open risk -- the dominant load-bearing uncertainty (which `memory/SPEC.md` §Assumption, if any) and the cheapest way to retire it +- the cheapest tracer bullet that would score on proof of life, invariants, or uncertainty retirement (see `docs/praxis/ln-skills.md` §Tracer-bullet sequencing) ## Work-type classification @@ -77,18 +77,18 @@ Only recommend the bounded or direct-build exceptions when all of these are true Only recommend the bounded serial exception when those same conditions hold and the next several commit-sized steps are obvious enough to queue without fresh planning. -## Uncertainty-first override +## Tracer-bullet override -When several routes fit the work, prefer the one with the highest information gain per unit of cost. Given the repo's pre-release posture, the default uncertainty-attack vehicle is **a thin vertical slice that would break if the assumption is wrong**, built via `ln-scope` → `ln-build`. That is usually cheaper and more informative than a study-step spike. +When several routes fit the work, prefer the one that fires the **tracer bullet that tells you the most**. A tracer-bullet slice scores on three convergent axes (see `docs/praxis/ln-skills.md` §Tracer-bullet sequencing): proof of life, invariants, uncertainty. The best next slice scores on more than one. -Recommend a non-build route **only when** the cheapest informative move genuinely is not a slice: +Given the repo's pre-release posture, attack uncertainty by building. Recommend a non-build route only when no buildable tracer bullet can carry the proof: -- `ln-design` — the module shape itself rests on a low-confidence claim and building any slice would lock in the wrong seam -- `ln-oracles` — the verification strategy is so uncertain that you cannot tell a passing slice from a wrong one -- `ln-spike` — the question is research-grade or external (third-party API contract, vendor perf characteristic, library behavior under load) and no vertical slice would be cheaper than a pure probe -- `ln-prototype` — the question is about feel, comparison, or UX legibility, where playable variants beat real code +- `ln-design` — module shape itself is uncertain and any slice would lock in the wrong seam +- `ln-oracles` — verification is too uncertain to distinguish a passing slice from a wrong one +- `ln-spike` — research-grade or external question (third-party API contract, vendor perf characteristic, library behavior under load) +- `ln-prototype` — feel, comparison, or UX-legibility question where playable variants beat real code -This override is for **attacking** uncertainty aggressively, not deferring it. If in doubt, build the thinnest slice that would break if the belief is wrong, and let landing it produce the evidence. Spikes are the escape hatch, not the default. +Spikes are the escape hatch, not the default. ## Routing table diff --git a/.agents/skills/ln-design/SKILL.md b/.agents/skills/ln-design/SKILL.md index 74416c24..e439beb9 100644 --- a/.agents/skills/ln-design/SKILL.md +++ b/.agents/skills/ln-design/SKILL.md @@ -31,7 +31,7 @@ Spawn 3+ sub-agents simultaneously. Each must produce a **radically different** - "Optimize for the most common case" - "Take inspiration from [specific paradigm or library]" -Each agent returns: **interface** (types, methods, params, invariants, ordering constraints, error modes, required configuration, and performance characteristics), **usage example**, **what it hides**, **seam / adapter strategy** where relevant, **trade-offs**, **load-bearing claims** (1–3 falsifiable beliefs the design rests on — for each, note whether it is already covered by `memory/SPEC.md` §Assumptions), and **cheapest falsifier** — preferably a thin `ln-scope` slice whose landing would break if the design's load-bearing claim is wrong. Fall back to `ln-spike` only when no buildable slice could carry the proof (external API contract, vendor characteristic, research-grade unknown). +Each agent returns: **interface** (types, methods, params, invariants, ordering constraints, error modes, required configuration, and performance characteristics), **usage example**, **what it hides**, **seam / adapter strategy** where relevant, **trade-offs**, **load-bearing claims** (1–3 falsifiable beliefs the design rests on — for each, note whether it is already covered by `memory/SPEC.md` §Assumptions), and **cheapest tracer bullet** — the thinnest `ln-scope` slice whose landing would light up the seam and break if the claim is wrong. Fall back to `ln-spike` only when no buildable slice could carry the proof. ### 3. Present and compare @@ -57,7 +57,7 @@ Present the recommended module shape with rationale, plus: - the 1–3 load-bearing claims it rests on - which of those are already covered by `memory/SPEC.md` §Assumptions and which need to be added there -- the recommended first proving step — default to a thin `ln-scope` slice that would break if the chosen design's highest load-bearing claim is wrong; recommend `ln-spike` instead only when no slice could carry the proof more cheaply +- the recommended first tracer bullet — a thin `ln-scope` slice that would light up the seam and break if the chosen design's highest load-bearing claim is wrong; fall back to `ln-spike` only when no slice could carry the proof more cheaply If `memory/SPEC.md` exists, ensure names align with its lexicon. @@ -74,7 +74,7 @@ After choosing a design, present these options to the user (use `tool-ask-questi | 3 | Write a spec | `ln-spec` | Module needs a full spec before slicing | | 4 | Grill it more | `ln-grill` | Design choice raised new questions | -Recommended: **1** — including when a load-bearing claim is low-confidence, because the preferred falsifier is a thin slice that breaks if the claim is wrong. Recommend **2 (Spike first)** only when no buildable slice could carry the proof (external API contract, vendor characteristic, research-grade unknown). +Recommended: **1** — including when a load-bearing claim is low-confidence, because the preferred falsifier is a tracer-bullet slice that breaks if the claim is wrong. Recommend **2 (Spike first)** only when no buildable slice could carry the proof. --- *Adapted from [mattpocock/skills/design-an-interface](https://github.com/mattpocock/skills/tree/main/design-an-interface).* diff --git a/.agents/skills/ln-plan/SKILL.md b/.agents/skills/ln-plan/SKILL.md index d4dcccf5..82f5bab2 100644 --- a/.agents/skills/ln-plan/SKILL.md +++ b/.agents/skills/ln-plan/SKILL.md @@ -100,26 +100,28 @@ When a frontier completes, remove it from `Sequencing`, add a terse `Recently Co If live low-confidence assumptions block downstream work, stop the plan at that boundary. Plan spikes or thinner proving frontier items, not fantasy certainty. -### Uncertainty-first sequencing +### Tracer-bullet sequencing -Sequencing is not only seam-driven. Before fixing `Sequencing`, rank the live assumptions in `memory/SPEC.md` §Assumptions by: +Sequencing is not only seam-driven. A good tracer-bullet frontier scores on three convergent axes (see `docs/praxis/ln-skills.md` §Tracer-bullet sequencing): **proof of life**, **invariants**, **uncertainty**. The strongest next frontier scores on more than one. -- **blast radius** if the assumption turns out false (how many downstream frontier items rework) +When ranking candidates, weigh: + +- **blast radius** if a load-bearing assumption turns out false - **reversibility cost** if discovered late vs early -- **validation cost** (cheap spike vs expensive end-to-end build) +- **validation cost** (cheap slice vs expensive end-to-end rework) - **load-bearingness** (how many active/next frontiers depend on it) -Given the repo's pre-release posture, prefer **the thinnest vertical frontier item that would break if the load-bearing assumption is wrong**. A frontier whose landing falsifies or confirms the belief is almost always cheaper and more informative than a study-step spike. Verticality of slices still applies; this is a tie-breaker and a re-ordering pressure, not a license to fragment into horizontal investigations. - -Annotate each `Active` / `Next` frontier definition with one of the following lines when assumptions are in play: +Annotate each `Active` / `Next` frontier definition with the relevant axes when they are in play: -- `Retires: <SPEC assumption id(s)>` — this frontier collapses the assumption by landing -- `Depends on: <SPEC assumption id(s)> (validated enough)` — assumption must be settled before this frontier starts -- `Blocked by: <SPEC assumption id(s)>` — assumption is live and load-bearing; do not start until retired +- `Retires: <SPEC assumption id(s)>` — collapses the assumption by landing +- `Depends on: <SPEC assumption id(s)> (validated enough)` — assumption must be settled first +- `Blocked by: <SPEC assumption id(s)>` — load-bearing; do not start until retired +- `Lights up: <pipeline / seam>` — establishes a new end-to-end path +- `Stabilizes: <invariant id(s) or seam>` — locates or fixes structure others will aim from -Use `ln-spike` only when the question is genuinely outside the buildable surface — for example a third-party API contract, vendor performance characteristic, or research-grade unknown where no vertical frontier could carry the proof cheaper than a probe. Do not insert ceremonial spikes when a thin proving frontier exists. +**Spike exception.** Use `ln-spike` only when no buildable frontier could carry the proof. Do not insert ceremonial spikes when a tracer-bullet frontier exists. -This sequencing pressure is distinct from "Epistemic horizon": that rule tells the planner to *stop* at fog; this rule tells the planner to *attack the fog* by reordering toward whichever next landed frontier produces the most information. +This sequencing pressure is distinct from "Epistemic horizon": that rule tells the planner to *stop* at fog; this rule tells the planner to **fire the tracer that tells you the most**. ## Procedure @@ -171,4 +173,4 @@ After writing the plan, present these options to the user (use `tool-ask-questio | 4 | Grill it more | `ln-grill` | Planning surfaced unresolved product questions | | 5 | Back to triage | `ln-consult` | Direction needs reassessment | -Recommended: **1** unless uncertainty-first sequencing surfaced a load-bearing assumption whose cheapest retirement is a spike (then **3**). +Recommended: **1** unless tracer-bullet sequencing surfaced a question that no buildable frontier could answer cheaper than a spike (then **3**). diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index 5148becc..c2ebc725 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -107,16 +107,18 @@ Every boundary the slice passes through, entry to exit: → [→ memory/SPEC.md §Assumptions id] ``` -### Uncertainty gate +### Tracer-bullet check -If the slice depends on an unresolved high-impact assumption that the slice's own acceptance criteria and oracle strategy will not directly retire, do not let it pass as-is. Given the repo's pre-release posture, the preferred response is **aggressive**, not cautious: +A good tracer-bullet slice scores on at least one of three convergent axes (see `docs/praxis/ln-skills.md` §Tracer-bullet sequencing): **proof of life** (lights up a new end-to-end path), **invariants** (locates or stabilizes a seam), **uncertainty** (retires a load-bearing assumption from `memory/SPEC.md` §Assumptions). The best slices score on more than one. -1. **First choice — narrow into a tracer bullet.** Reshape the slice so that *landing it end-to-end is the proof step* that falsifies or confirms the assumption. A thin vertical slice that would break if the assumption is wrong is almost always the cheapest and most informative falsifier in this codebase. Then recommend `ln-build`. -2. **Escape hatch — `ln-spike`.** Only when no vertical slice would be cheaper than a pure-investigation probe (for example: an external API contract question, a third-party perf characteristic, a research-grade unknown) route to `ln-spike` instead of building. +If the slice depends on a high-impact assumption that landing it will not retire: + +1. **Reshape, don't defer.** Rework the slice so landing it *is* the proof — a tracer bullet that breaks if the assumption is wrong almost always beats a study step in this codebase. +2. **Spike exception.** Route to `ln-spike` only when no vertical slice would be cheaper than a pure probe (third-party API contract, vendor perf characteristic, research-grade unknown). "High-impact" means the assumption being false would force rework across more than this slice — invalidating queued cards, changing the chosen module shape from `ln-design`, or forcing a different frontier-level sequencing decision. -The gate is not a brake. It is a reshaping rule: the assumption must be **attacked** by the next move, preferably by building. +A tracer bullet should *tell you something*. Build it. ### Acceptance Criteria @@ -180,7 +182,7 @@ State one of: - `None` — this slice's correctness does not hinge on any live `memory/SPEC.md` §Assumptions - `Depends on: <SPEC assumption id(s)>` — and a one-line note on why those assumptions are validated enough to build against -If a light card would have to mark `Depends on:` a high-impact unvalidated assumption, promote to a full scope card and apply the **Uncertainty gate**. +If a light card would have to mark `Depends on:` a high-impact unvalidated assumption, promote to a full scope card and apply the **Tracer-bullet check**. ### Promotion checklist @@ -223,4 +225,4 @@ After the scope card is complete, present these options to the user (use `tool-a | 5 | Revise plan | `ln-plan` | The work no longer fits the current frontier | | 6 | Back to triage | `ln-consult` | Scope revealed unclear state | -Recommended: **1** in nearly all cases — including when the **Uncertainty gate** trips, because the gate's preferred resolution is to reshape the slice into a tracer bullet that falsifies the assumption by landing, then build it. Recommend **3 (Spike first)** only when no vertical slice would be cheaper than a pure-investigation probe. Recommend **2 (Design oracles)** only when the verification approach for the reshaped slice is still genuinely unclear. If a short prepared queue is warranted, write it to `memory/CARDS.md` and let `ln-build` consume the next ready card from there. +Recommended: **1** in nearly all cases — including when the **Tracer-bullet check** fires, because the preferred resolution is to reshape, not defer. Recommend **3 (Spike first)** only when no vertical slice would be cheaper than a pure probe. Recommend **2 (Design oracles)** only when verification for the reshaped slice is still genuinely unclear. If a short prepared queue is warranted, write it to `memory/CARDS.md` and let `ln-build` consume the next ready card from there. diff --git a/docs/praxis/ln-skills.md b/docs/praxis/ln-skills.md index b15f673b..3a389199 100644 --- a/docs/praxis/ln-skills.md +++ b/docs/praxis/ln-skills.md @@ -34,14 +34,21 @@ ln-consult The flow is not a checklist. Skip steps whose uncertainty is already retired. -Given the repo's pre-release posture, **the primary vehicle for retiring uncertainty is a thin vertical slice that would break if a load-bearing assumption is wrong** — built through `ln-scope` → `ln-build`. Non-build detours are escape hatches for questions a slice cannot cheaply carry: +### Tracer-bullet sequencing -- `ln-design` — module shape itself is the uncertain thing and any slice would lock in the wrong seam -- `ln-oracles` — verification strategy is too uncertain to distinguish a passing slice from a wrong one -- `ln-spike` — research-grade or external question (third-party API contract, vendor performance characteristic, library behavior under load) where no buildable slice would be cheaper than a probe -- `ln-prototype` — feel, comparison, or UX-legibility question better answered by playable variants than by real code +A good tracer-bullet frontier or slice earns its keep on three convergent axes: -`ln-plan`, `ln-design`, `ln-scope`, and `ln-consult` all bias toward attacking uncertainty by building. The escape hatches stay available; they just are not the default. +- **Proof of life.** Does landing it light up an end-to-end path that did not exist? +- **Invariants.** Does it locate or stabilize a seam that future slices will aim from? +- **Uncertainty.** Does it retire a load-bearing assumption from `memory/SPEC.md` §Assumptions? + +The strongest next move scores on more than one axis. Prefer a slice that does several at once over one that maximizes a single axis. + +- **Reshape, don't defer.** If an assumption blocks a slice, reshape the slice before switching to study. +- **Spike exception.** Use `ln-spike` only when no buildable tracer bullet can carry the proof — a third-party API contract, vendor characteristic, or research-grade unknown. +- **Fire the tracer that tells you the most.** Given the repo's pre-release posture, attack uncertainty by building. Spikes, design passes, and prototypes are escape hatches when no slice could carry the proof more cheaply. + +`ln-plan`, `ln-design`, `ln-scope`, and `ln-consult` all carry this sequencing pressure. ## Skill map From b3a648d25d9fb63eaa9639ecd10384aae59ecd25 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:31:25 +0200 Subject: [PATCH 108/164] Add deterministic elicitation start RPC --- memory/CARDS.md | 92 +++++++++++++++++++++++++++++ memory/PLAN.md | 21 +++---- src/rpc.test.ts | 136 +++++++++++++++++++++++++++++++++++++++++- src/rpc.ts | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 392 insertions(+), 11 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 7f70da26..5aab41f6 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -90,3 +90,95 @@ A Brunch JSON-RPC client can call `rpc.discover` with no params and receive a se - [ ] Does it cross more than two major seams? No — JSON-RPC dispatch + registry/schema metadata. - [x] Is this the first touch in an unfamiliar seam from a fresh thread? Yes; use full card. - [ ] Can you not name the containing seam or current rationale from the live docs? No. + +--- + +## Card 2 — Start deterministic elicitation with a pending exchange + +- **Status:** done +- **Weight:** full scope card — establishes the assistant-first public RPC lifecycle seam while deliberately avoiding structured answer semantics that are being refined in another thread. + +### Orientation + +- **Containing seam:** FE-744 public RPC elicitation parity, after `rpc.discover` landed in `db859418`. +- **Frontier item:** `pi-ui-extension-patterns`; this is still one slice inside the existing FE-744 Linear/branch boundary. +- **Volatile state:** another thread is refining structured-exchange answer behavior (`Other` vs optional note). This slice must not modify `src/pi-extensions/structured-exchange.ts` or commit response semantics. +- **Main open risk:** a deterministic assistant-first prompt is now transcript-backed; later `pendingExchange` / `elicitation.respond` / ten-turn parity work must continue this without inventing a parallel turn store. + +### Target Behavior + +A Brunch JSON-RPC client can call `session.startElicitation` for the selected session and receive the first deterministic assistant-originated pending exchange. + +### Boundary Crossings + +```text +→ JSON-RPC request { method: "session.startElicitation" } +→ createRpcHandlers dispatch +→ selected WorkspaceSessionCoordinator state +→ deterministic elicitation starter +→ Pi JSONL prompt-side entry or entries under the selected session +→ JSON-RPC result containing the pending exchange snapshot +→ rpc.discover metadata for session.startElicitation +``` + +### Risks and Assumptions + +- RISK: The starter becomes an in-memory queue instead of transcript-backed product state. + → MITIGATION: write prompt-side Pi JSONL evidence using the existing `brunch.elicitation_prompt` transcript seam; tests reload the session file and project an open prompt from disk. +- RISK: The slice accidentally commits answer semantics while the structured-exchange refinement thread is active. + → MITIGATION: implement only start/resume and pending snapshot return; do not add `elicitation.respond`, do not parse option answers, and do not modify `src/pi-extensions/structured-exchange.ts`. +- RISK: Repeated `session.startElicitation` calls duplicate the first prompt. + → MITIGATION: make the method idempotent for an already-open prompt in the selected session; return the existing pending exchange rather than appending another prompt. +- RISK: New prompt entries violate lens/custom-entry obligations. + → MITIGATION: any structured elicitor-emitted custom entry data includes `lens: "step-by-step"`; displayable custom-message rows remain prompt-side transcript evidence. +- ASSUMPTION: A deterministic starter prompt over the selected session is enough to attack A23-L before implementing response handling. + → IMPACT IF FALSE: the next cards may need a fuller elicitor state machine before `elicitation.respond` can be scoped. + → VALIDATE: this card proves Brunch can create/resume assistant-first pending state through public RPC and project it from linear Pi JSONL. + → `memory/SPEC.md` §Assumptions: A23-L. + +### Tracer-bullet check + +- **Proof of life:** lights up the first assistant-first public Brunch RPC path after workspace activation. +- **Invariants:** stabilizes the boundary between selected session state, transcript-backed prompt-side entries, and public pending-exchange snapshots. +- **Uncertainty:** attacks A23-L by proving that public RPC can initiate the elicitation loop without raw Pi RPC or a parallel prompt table. + +### Acceptance Criteria + +✓ `session.startElicitation` discovery — `rpc.discover` lists `session.startElicitation` with params/result schemas and an example request. + +✓ selected-session start — with a ready selected session, `session.startElicitation` returns `{ status: "pending", exchange: ... }` with a stable `exchangeId`, `lens: "step-by-step"`, `mode`, prompt text, options (if any), and `note: { allowed: true }` metadata. + +✓ transcript-backed prompt — starting elicitation appends prompt-side Pi JSONL evidence under the selected session, and `session.elicitationExchanges` reports `status: "open_prompt"` after reloading that session file. + +✓ transcript display — `session.transcriptDisplay` includes a prompt row for the deterministic first question after start. + +✓ idempotent resume — calling `session.startElicitation` again while the first prompt is open returns the same pending exchange id and does not append duplicate prompt-side entries. + +✓ no selected session — calling `session.startElicitation` while workspace state is not ready returns the existing product-shaped no-session error (`-32001`, `No selected Brunch session`). + +✓ boundary guard — the implementation does not import or modify the TUI structured-exchange tool module for this slice. + +### Verification Approach + +- **Inner:** `npm run fix`; targeted `vitest src/rpc.test.ts`; `npm run check`. +- **Middle:** JSON-RPC contract tests for discovery, ready/no-session behavior, idempotent start, transcript reload, `session.elicitationExchanges` open-prompt projection, and `session.transcriptDisplay` prompt row. +- **Outer:** none for this slice; the later ten-turn parity proof is the outer/product proof. + +### Cross-cutting obligations + +- Public clients speak Brunch JSON-RPC only; no raw Pi RPC command or slash command participates in this slice (`D5-L`, `D48-L`, `D49-L`). +- Preserve linear transcript policy and prompt-side projection over Pi JSONL (`I19-L`, `I23-L`, `I32-L`). +- Preserve workspace/spec/session activation authority: the selected session comes from `WorkspaceSessionCoordinator`; startup/picker UI is not invoked (`D21-L`, `I22-L`). +- Keep this slice response-free while structured answer permutations are refined elsewhere; `elicitation.respond` is a later card. + +### Promotion checklist + +- [x] Does this change a requirement? Already reconciled in `memory/SPEC.md` as R28/R24. +- [x] Does this create, retire, or invalidate an assumption? It advances but does not retire A23-L. +- [x] Does this slice depend on an unvalidated high-impact assumption? It attacks A23-L as a tracer bullet. +- [x] Does this make or reverse a non-trivial design decision? Already reconciled as D49-L; this card implements the first half. +- [x] Does this establish a new seam-level invariant? Already reconciled as I32-L; this card establishes the start/open-prompt part. +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. +- [x] Does it cross more than two major seams? Yes — RPC dispatch, workspace/session coordination, Pi JSONL transcript projection, and discovery metadata; use full card. +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No — `rpc.discover` just landed, but the lifecycle seam is new enough to keep full weight. +- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/memory/PLAN.md b/memory/PLAN.md index 6db4873a..2d4a2eae 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 must finish Brunch-owned structured-elicitation relay semantics and recover the branded product chrome, then `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved the raw Pi RPC editor fallback for one structured exchange, but must re-aim at the product boundary by proving a public Brunch JSON-RPC, assistant-first, ten-turn elicitation session parity run before chrome/web closeout. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure @@ -25,7 +25,7 @@ The POC should maximize assumption falsification rather than merely implement mi | A1-L Pi substrate seams | A needed host/session/RPC/extension seam cannot be expressed without forking Pi. | Mostly exercised by M0-M3; FE-744 and `sealed-pi-profile-runtime-state` close the remaining UI/profile seams before graph-agent work depends on them. | | A3-L command layer sufficiency | Agent, UI, reviewer, or capture writes need shortcuts around one `CommandExecutor`. | `graph-data-plane`, `agent-graph-integration`, and `authority-model` must prove one command boundary for every write path. | | A4-L global LSN adequacy | Replay, staleness, or reconciliation ordering needs per-entity/vector clocks. | `graph-data-plane` establishes one-LSN-per-transaction; `turn-boundary-reconciliation` tries to break it with cross-session traces. | -| A5-L fixture driver quality | Agent-as-user captures fail to catch regressions or cannot represent realistic briefs. | `brief-library-curation` and `fixture-strategy-evolution` stay live; every assumption-heavy frontier should add or update a fixture/probe. | +| A5-L fixture driver quality | Agent-as-user captures fail to catch regressions or cannot represent realistic briefs. | FE-744 must first prove a deterministic public-RPC ten-turn elicitation driver; `brief-library-curation` and `fixture-strategy-evolution` then keep the broader assumption-proof matrix honest. | | A6-L unified `graph.*` namespace | Intent/oracle/design/plan semantics become confusing or unsafe under one umbrella. | `graph-data-plane` and `agent-graph-integration` should start unified but watch for namespace pressure. | | A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus briefs #1-#7 exercise framing; promote only if fixture pressure demands it. | | A8-L reconciliation substrate | Gaps, contradictions, process debt, and conflicts need separate substrates immediately. | `graph-data-plane` builds the shared substrate; `coherence-first-class` and known-bad briefs test subtype pressure. | @@ -42,13 +42,14 @@ The POC should maximize assumption falsification rather than merely implement mi | A20-L Drizzle 1.0 beta | Beta blocks migrations, SQLite fidelity, or TypeBox derivation. | `graph-data-plane` starts with a version/schema spike before broad imports. | | A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad fixtures earlier so the rubric is falsifiable. | | A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture fixtures before async observer backstops are reconsidered. | +| A23-L public RPC elicitation parity | A public Brunch RPC client cannot discover methods, activate workspace/spec/session, drive assistant-first pending exchanges, or produce TUI-comparable JSONL without speaking raw Pi RPC or adding a parallel turn store. | FE-744 is not done until `rpc.discover`, pending/respond lifecycle, deterministic assistant-first harness, and ten-turn transcript parity proof land. | ## Sequencing ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof by completing the structured-exchange model across interaction surfaces: every supported structured exchange is faithfully communicated and represented in interactive TUI and RPC, RPC is exercised by an agent-as-user evaluator probe, the web UI observes real-time updates caused by TUI/RPC actions, and the branded/themed persistent Brunch chrome is recovered rather than left as a diagnostic dump. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof by moving from the completed raw Pi RPC editor-fallback proof to a public Brunch JSON-RPC elicitation session parity proof: runtime method discovery, workspace/spec/session activation, assistant-first start/resume, pending-exchange respond lifecycle, deterministic ten-turn agent-as-user run, TUI-comparable JSONL/projections, then web real-time observation and branded/themed chrome recovery. ### Next @@ -216,15 +217,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, and the structured-exchange RPC evaluator proof have landed; current missing seams are web real-time structured-exchange observation plus visual chrome recovery) -- **Objective:** Demonstrate the Pi extension seams Brunch needs before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; and, now active, a structured elicitation loop where a system/assistant-originated question or questionnaire can use Pi's registered-tool transcript seam, replace the default TUI input surface with single-choice / multi-choice / questionnaire / optional-freeform custom UI, degrade over Pi RPC through schema-tagged JSON in `ctx.ui.editor`, and persist a self-contained structured result in `toolResult.details` (or a linked custom entry where that is the thinner seam). -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a structured-exchange proof: a registered Pi tool can collect every supported structured exchange shape (text/freeform, action or confirmation where supported, single-select/radio, multi-select/checkbox, questionnaire, optional freeform-plus-choice, option-selection notes, and terminal statuses such as answered/skipped/cancelled/unavailable); rich TUI paths use `ctx.ui.custom()` while raw Pi RPC paths use supported dialogs or schema-tagged JSON over `ctx.ui.editor`; the returned `toolResult.details` echoes enough prompt/question/option/answer/note/mode/status/transport data for Brunch projection without rehydrating semantics solely from assistant tool-call arguments; the model-readable `content` is generated from the same details; elicitation-exchange projection recognizes terminal structured results as response-side; Brunch exposes one public product RPC surface that wraps Pi RPC extension-UI requests for agent-as-user probes and web relay clients; and the web UI receives real-time product updates when TUI or RPC interactions change selected session/exchange state. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for any new Brunch handler shape introduced (slash command router, modal request/response, picker selection, elicitation pending/response relay); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol; scripted TUI demo covering all supported structured-exchange permutations; RPC agent-as-user evaluator probe where the evaluator has a mission/intention, a critical UX or feature-evaluation focus, and a maximum turn budget, then reports blockers and frictions; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, and the raw Pi RPC structured-exchange evaluator proof have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) +- **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L / A10-L, A14-L, A17-L, A18-L, A19-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Today's focus is structured exchanges, not graph work: prove every supported exchange type through interactive TUI and public Brunch RPC, add the agent-as-user RPC evaluator probe, and show web UI real-time updates when TUI/RPC interactions change selected session/exchange state. Scroll-lock spike finding for the project-local `ask_user_question` extension: non-overlay `ctx.ui.custom()` replaces the editor with a component whose rendered lines can exceed the terminal viewport, while the component captures keyboard focus and has no internal viewport, so long question/details content disappears above the visible bottom of the TUI; overlay mode can avoid editor replacement but still clips without internal scroll, so the next remediation should keep full question/context in transcript-friendly tool rendering and make the active answer control compact or explicitly internally scrollable. Then recover branded chrome before FE-744 closeout: inspect the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and port the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until this structured-exchange proof and chrome recovery close the active A10-L/A18-L/UI-RPC-A19-L risk. +- **Current execution pointer:** The public RPC discovery registry and deterministic `session.startElicitation` tracer bullet have landed: `rpc.discover` lists the current Brunch methods, and an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange without raw Pi RPC or a parallel prompt store. Next scope the response side of the public RPC elicitation parity sequence inside this same FE-744 frontier: (1) expose `session.pendingExchange` and `elicitation.respond` over Brunch JSON-RPC with polling semantics, preserving the open prompt projection from Pi JSONL; (2) let the deterministic elicitor advance through at least ten structured exchanges; (3) build the ten-turn agent-as-user parity proof and projection oracle; (4) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/src/rpc.test.ts b/src/rpc.test.ts index 644ef45f..f84bbe51 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -205,6 +205,7 @@ describe("JSON-RPC handlers", () => { expect(methods.map((entry) => entry.method).sort()).toEqual([ "rpc.discover", "session.elicitationExchanges", + "session.startElicitation", "session.transcriptDisplay", "workspace.activate", "workspace.selectionState", @@ -269,7 +270,7 @@ describe("JSON-RPC handlers", () => { const discoveryJson = JSON.stringify(result) expect(discoveryJson).not.toContain("get_commands") expect(discoveryJson).not.toContain("get_state") - expect(discoveryJson).not.toContain("prompt") + expect(discoveryJson).not.toContain('"method":"prompt"') expect(discoveryJson).not.toContain("/brunch") const activation = methods.find( @@ -441,6 +442,7 @@ describe("JSON-RPC handlers", () => { expect(source).not.toContain("workspace-dialog") expect(source).not.toContain("createWorkspaceDialogComponent") + expect(source).not.toContain("structured-exchange") }) it("serves a named workspace snapshot method", async () => { @@ -489,6 +491,138 @@ describe("JSON-RPC handlers", () => { }) }) + it("starts a deterministic assistant-first elicitation prompt for the selected session", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-start-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Start spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + + const start = await handlers.handle({ + jsonrpc: "2.0", + id: 40, + method: "session.startElicitation", + }) + + expect(start).toMatchObject({ + jsonrpc: "2.0", + id: 40, + result: { + status: "pending", + exchange: { + exchangeId: expect.any(String), + lens: "step-by-step", + mode: "single-select", + prompt: expect.stringContaining("new product or feature"), + options: expect.arrayContaining([ + expect.objectContaining({ id: "new-from-scratch" }), + ]), + note: { allowed: true }, + }, + }, + }) + const exchangeId = (start as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId + + const exchanges = await handlers.handle({ + jsonrpc: "2.0", + id: 41, + method: "session.elicitationExchanges", + }) + expect(exchanges).toMatchObject({ + jsonrpc: "2.0", + id: 41, + result: { status: "open_prompt", openPrompt: expect.any(Object) }, + }) + + const display = await handlers.handle({ + jsonrpc: "2.0", + id: 42, + method: "session.transcriptDisplay", + }) + expect(display).toMatchObject({ + jsonrpc: "2.0", + id: 42, + result: { + rows: [ + { + role: "prompt", + text: expect.stringContaining("new product or feature"), + }, + ], + }, + }) + + const sessionText = await readFile(workspace.session.file, "utf8") + expect(sessionText).toContain("brunch.elicitation_prompt") + expect(sessionText).toContain(exchangeId) + expect(sessionText).toContain('"lens":"step-by-step"') + }) + + it("resumes an open deterministic elicitation prompt without duplicating transcript entries", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-resume-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Resume spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + + const first = await handlers.handle({ + jsonrpc: "2.0", + id: 43, + method: "session.startElicitation", + }) + const before = await readFile(workspace.session.file, "utf8") + + const second = await handlers.handle({ + jsonrpc: "2.0", + id: 44, + method: "session.startElicitation", + }) + const after = await readFile(workspace.session.file, "utf8") + + expect(second).toMatchObject({ + jsonrpc: "2.0", + id: 44, + result: { + status: "pending", + exchange: { + exchangeId: (first as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId, + }, + }, + }) + expect(after).toBe(before) + }) + + it("returns a product-shaped no-session error when starting elicitation without a selected session", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 45, + method: "session.startElicitation", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 45, + error: { code: -32001, message: "No selected Brunch session" }, + }) + }) + it("returns a product-shaped error for non-linear selected sessions", async () => { const sessionFile = await createBranchedSessionFile() const handlers = createRpcHandlers({ diff --git a/src/rpc.ts b/src/rpc.ts index 46b983ef..44f4cadf 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -100,6 +100,13 @@ export function createRpcHandlers(options: { ) } + if (request.method === "session.startElicitation") { + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, "Invalid params") + } + return handleStartElicitation(requestId, options) + } + if (request.method === "session.elicitationExchanges") { return handleSessionProjection( requestId, @@ -295,6 +302,33 @@ const TranscriptDisplayResultSchema = Type.Object( { additionalProperties: true }, ) +const PendingElicitationExchangeSchema = Type.Object( + { + exchangeId: NonBlankStringSchema, + lens: Type.Literal("step-by-step"), + mode: Type.Literal("single-select"), + prompt: NonBlankStringSchema, + details: Type.Optional(NonBlankStringSchema), + options: Type.Array( + Type.Object({ id: NonBlankStringSchema, label: NonBlankStringSchema }, { + additionalProperties: false, + }), + ), + note: Type.Object({ allowed: Type.Boolean() }, { + additionalProperties: false, + }), + }, + { additionalProperties: false }, +) + +const StartElicitationResultSchema = Type.Object( + { + status: Type.Literal("pending"), + exchange: PendingElicitationExchangeSchema, + }, + { additionalProperties: false }, +) + type RpcMethodDiscovery = { method: string description: string @@ -392,6 +426,14 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ }, ], }, + { + method: "session.startElicitation", + description: + "Start or resume the selected session's deterministic assistant-first elicitation loop and return the current pending structured exchange.", + paramsSchema: NoParamsSchema, + resultSchema: StartElicitationResultSchema, + examples: [{ jsonrpc: "2.0", id: 8, method: "session.startElicitation" }], + }, ] type WorkspaceActivationParamsParseResult = { @@ -445,6 +487,118 @@ async function handleSessionProjection<T>( } } +async function handleStartElicitation( + requestId: JsonRpcId, + options: { + coordinator: DefaultWorkspaceCoordinator + cwd: string + }, +): Promise<JsonRpcResponse> { + const state = await options.coordinator.openDefaultWorkspace() + if (state.status !== "ready") { + return createJsonRpcFailure(requestId, -32001, "No selected Brunch session") + } + + const existingTarget = await selectedSessionFile(state) + if (!existingTarget.ok) { + return createJsonRpcFailure( + requestId, + existingTarget.code, + existingTarget.message, + ) + } + + const existing = pendingExchangeFromEnvelope(existingTarget.envelope) + if (existing) { + return createJsonRpcSuccess(requestId, { + status: "pending", + exchange: existing, + }) + } + + const exchange = firstDeterministicElicitationExchange() + const manager = state.session.manager + manager.appendCustomMessageEntry( + "brunch.elicitation_prompt", + exchange.prompt, + true, + exchange, + ) + flushSessionEntries(manager, state.session.file) + + const reloadedTarget = await selectedSessionFile(state) + if (!reloadedTarget.ok) { + return createJsonRpcFailure( + requestId, + reloadedTarget.code, + reloadedTarget.message, + ) + } + const reloaded = pendingExchangeFromEnvelope(reloadedTarget.envelope) + + return createJsonRpcSuccess(requestId, { + status: "pending", + exchange: reloaded ?? exchange, + }) +} + +type PendingElicitationExchange = Static<typeof PendingElicitationExchangeSchema> + +function firstDeterministicElicitationExchange(): PendingElicitationExchange { + return { + exchangeId: "deterministic-grounding-1", + lens: "step-by-step", + mode: "single-select", + prompt: "Is this a new product or feature from scratch?", + details: + "This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.", + options: [ + { id: "new-from-scratch", label: "Yes — this is new from scratch" }, + { id: "existing-codebase", label: "No — this builds on existing code" }, + { + id: "relates-to-existing-spec", + label: "It relates to an existing spec", + }, + ], + note: { allowed: true }, + } +} + +function pendingExchangeFromEnvelope( + envelope: BrunchSessionEnvelope, +): PendingElicitationExchange | null { + const projection = projectLinearElicitationExchangeProjection(envelope) + if (!projection.openPrompt) { + return null + } + + for (const entryId of projection.openPrompt.promptEntryIds) { + const entry = envelope.entries.find( + (candidate) => + candidate.type === "custom_message" && + candidate.id === entryId && + candidate.customType === "brunch.elicitation_prompt" && + Value.Check(PendingElicitationExchangeSchema, candidate.details), + ) + if (entry?.type === "custom_message") { + return Value.Parse(PendingElicitationExchangeSchema, entry.details) + } + } + + return null +} + +interface FlushableSessionManager { + _rewriteFile(): void + setSessionFile(file: string): void +} + +function flushSessionEntries(manager: unknown, sessionFile: string): void { + const flushable = manager as FlushableSessionManager + flushable._rewriteFile() + flushable.setSessionFile(sessionFile) +} + type SessionProjectionParamsParseResult = { ok: true value: ExplicitSessionProjectionParams | null From 833f99d003cae34712cef1e3aefdc9b3c8f4e8b0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:34:08 +0200 Subject: [PATCH 109/164] structured-exchange jit side mission --- memory/SPEC.md | 23 +- memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md | 131 ++++++++++ ...tructured-exchange-jit-editor.prototype.ts | 237 ++++++++++++++++++ 3 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md create mode 100644 src/pi-extensions/structured-exchange-jit-editor.prototype.ts diff --git a/memory/SPEC.md b/memory/SPEC.md index 8e790af0..e96c455b 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -62,6 +62,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 11. Brunch must use JSON-RPC as the primary browser and RPC transport through named method families, not a generic data API. 12. Brunch must support subscriptions as a first-class transport primitive for both session and graph state; live views should subscribe to projection handlers over canonical stores rather than read from a parallel view store. - POC dashboard corollary: Brunch must support a one-writer/many-observer local shape: the TUI may drive an elicitation session while the web UI attaches as a read-only dashboard over selected spec/session resources for richer visual projections. Web view selection is client-local unless an explicit product command changes workspace defaults. +27. Brunch public JSON-RPC must be discoverable at runtime through Brunch-owned method discovery. Discovery returns product method names, descriptions, parameter/result schemas, and examples for supported Brunch methods; clients must not infer Brunch product capabilities from raw Pi RPC commands or Pi slash-command discovery. #### Continuity & coherence @@ -79,10 +80,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c 21. Brunch must distinguish single-exchange elicitation flows from batch-proposal/review-set flows by capture and commitment mechanism: single-exchange answers are captured synchronously by the elicitor at turn boundaries, while batch proposals carry structured entity-draft payloads and are committed only through review-set approval. 22. Brunch must maintain spec-owned readiness grade and elicitation posture as forward gates inside the `elicit` operational mode. Grounding establishes the frame required for main elicitation; later grades unlock commitment and planning/export/execute posture without forbidding earlier gathering or refinement. 23. Brunch must support a review-cycle acceptance pattern for batch proposals and commitment review sets — approve / request changes (triggering regeneration) / reject — with batch acceptance committed atomically as one CommandExecutor call; partial acceptance is not representable. +28. Brunch must support assistant-first elicitation session driving over the public JSON-RPC surface: after workspace/spec/session activation, a client can start or resume elicitation, observe the current pending system/assistant-originated structured exchange, submit a response through Brunch product methods, and let Brunch advance the transcript-backed loop without ambient user prompt injection. #### Verification & fixtures -24. Brunch must ship a brief library and an agent-as-user driver over the JSON-RPC stdio surface to capture replayable golden runs and property-checkable fixtures. +24. Brunch must ship a brief library and an agent-as-user driver over the JSON-RPC stdio surface to capture replayable golden runs and property-checkable fixtures. The first product-level driver proof is a deterministic public-RPC elicitation session parity run: at least ten assistant-first exchanges through activated workspace/spec/session state, with Pi JSONL and Brunch projections comparable in kind and quality to an equivalent TUI-driven session. #### Runtime profile & prompting @@ -117,6 +119,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | | A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | | A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness/posture updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | open | D18-L, D26-L, D45-L, I30-L | M5 agent-graph-integration fixtures and review: compare elicitor-captured graph updates against transcript evidence; track over-capture, missed obvious facts, and whether preface-led disambiguation resolves low-confidence material without an async observer owning primary extraction. | +| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive an assistant-first elicitation session for at least ten turns without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | medium | open | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC elicitation parity proof: discover methods, activate workspace/spec/session, start/resume a deterministic elicitor, answer pending exchanges through Brunch methods, and compare the resulting Pi JSONL/projections against TUI-shaped session expectations. | ### Active Decisions @@ -147,7 +150,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D5-L — Brunch JSON-RPC is the single public product protocol.** Brunch exposes one public product RPC surface over stdio, WebSocket, and in-process handlers. Product clients — web UI, CLI probes, TUI adapters, and future relays — call Brunch method families and should not coordinate raw Pi RPC plus Brunch product RPC themselves. Pi RPC may be used behind a Brunch adapter for agent-loop mechanics and Pi extension UI, but it is not a second public product API. HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The Brunch stdio surface is also the agent-as-user fixture-capture interface, even when that driver internally relays Pi RPC events. Depends on: A5-L. Supersedes: treating raw Pi RPC as the product API for Brunch data. - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. - **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. -- **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, and later `elicitation.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. +- **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `rpc.*`, `workspace.*`, `session.*`, `elicitation.*`, `graph.*`, `coherence.*`, and `command.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. - **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/state.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. @@ -190,6 +193,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c Tool returns toolResult.content + self-contained toolResult.details ``` +- **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are TypeBox/JSON-Schema-shaped per D41-L, but discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. +- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial ten-turn parity run. Depends on: A23-L, D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. + #### Persistence - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. @@ -265,6 +271,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-question preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | +| I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | planned (FE-744 `rpc.discover` contract tests, pending/respond lifecycle tests, ten-turn public-RPC elicitation parity proof, and transcript-projection parity oracle) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | ## Future Direction Register @@ -368,7 +375,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Command layer** | The single Brunch-owned mutation surface. Validates, gates concurrency, audits, emits events, triggers coherence. Its public mutation entry point is the `CommandExecutor`, not direct ORM calls or caller-side authority gates. | | **Command executor** | The deep module that accepts Brunch product commands plus execution context and returns structured command results (`ok`, `needs_human`, `policy_blocked`, `version_conflict`, `structural_illegal`). It hides attribution, minimal pre-M6 authority classification, validation, transaction, LSN, change-log, and coherence-trigger mechanics from callers. | | **Brunch public RPC surface** | The one product-facing JSON-RPC surface exposed over stdio, WebSocket, and in-process handlers. Product clients use this surface for workspace, session, graph, coherence, command, agent, and elicitation behavior; raw Pi RPC is hidden behind adapters when needed. | -| **RPC method family** | A named group of Brunch JSON-RPC methods (`workspace.*`, `session.*`, `graph.*`, `coherence.*`, `command.*`, later `elicitation.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second public API surface. | +| **RPC discovery** | Brunch-owned `rpc.discover` method output: public method names, descriptions, parameter/result schemas, and examples for the current Brunch host. It is distinct from Pi `get_commands`, which only lists slash commands/prompt templates/skills invokable through Pi's `prompt` command. | +| **RPC method family** | A named group of Brunch JSON-RPC methods (`rpc.*`, `workspace.*`, `session.*`, `elicitation.*`, `graph.*`, `coherence.*`, `command.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second public API surface. | | **Projection handler** | A thin handler that reads or subscribes to a canonical store and returns product-shaped state for a mode/client. It is not a canonical store itself. | | **Subscription** | A long-lived RPC operation that delivers live updates, often with an initial snapshot, for views that must stay current with session, workspace, graph, or coherence state. | | **Transport adapter** | The stdio, WebSocket, HTTP-shim, Pi-RPC relay, or in-process wrapper around the same Brunch handlers. Transport adapters do not own product semantics. | @@ -379,6 +387,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-question results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-question toolResult details). This is the default post-exchange capture unit. | | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | +| **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `elicitation.respond`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | +| **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | +| **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-question preface** | Plain prose in a structured-question payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | | **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | @@ -478,10 +489,11 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | -| Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L; R11, R12. | +| Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; pending-exchange start/read/respond handlers preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, and compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -529,10 +541,12 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/pi-extensions/subagents/agents/*.md` frontmatter and `src/pi-extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | +| I32-L | FE-744 public-RPC elicitation parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic ten-turn agent-as-user run over Brunch JSON-RPC only, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | ### Design Notes - **Deterministic before generative.** M1 should prefer a deterministic or tightly scripted user-agent path for the first captured run before relying on LLM persona variance. Generative/adversarial probes come after the transcript and fixture substrate is trusted. M1 scripted captures prove the transport/projection/fixture substrate on its current terms; they do not settle the final elicitation interaction logic, knowledge flow, or prompt/response expectation model. +- **Public RPC parity before LLM quality.** FE-744's next product proof should use a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, and JSONL/projection parity. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. - **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. @@ -561,3 +575,4 @@ The first required runbook is M0: after manual TUI interaction, a checker proves 5. The transcript strategy is validated: pi JSONL sessions either suffice for the POC, or their insufficiency is sharply bounded with a justified fallback. 6. A fixture library of at least the seven starter briefs is captured and replayable; property invariants from the fixture-strategy doc pass against captured runs. 7. Brunch can be built as a local product over pi without forking pi. +8. A public Brunch RPC agent-as-user can discover methods, activate workspace/spec/session, complete at least ten assistant-first structured elicitation turns, and leave JSONL/projection evidence comparable to a TUI session without speaking raw Pi RPC. diff --git a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md new file mode 100644 index 00000000..253d48c8 --- /dev/null +++ b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md @@ -0,0 +1,131 @@ +<!-- STRUCTURED_EXCHANGE_SIDE_MISSION.md — temporary side-mission scope. + Created because memory/CARDS.md is currently owned by another in-flight builder. + Delete or absorb after the prototype verdict is reconciled into SPEC/PLAN/CARDS/code. --> + +# Structured Exchange Side Mission — JIT Editor Probe + +## Orientation + +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange response surface in `src/pi-extensions/structured-exchange.ts` and its transcript replay rendering. +- **Frontier item:** `pi-ui-extension-patterns`; this side mission stays inside the existing FE-744 branch/Linear boundary and must not create a new tracker item. +- **Coordination:** do **not** edit `memory/CARDS.md` for this side mission while another builder thread owns the active card queue. This file is a temporary sidecar scope by explicit user request. +- **Main open risk:** the single just-in-time editor may feel better than the second note tab, but it may not be feasible with current `ctx.ui.custom()` focus/render constraints or may create ambiguous result payload semantics. + +## Disambiguation findings to carry into the probe + +- **Single global context field:** For option questions, there should be at most one additional text field for the whole response, regardless of single-select or multi-select. +- **JIT visibility:** The additional field appears only after a selection is made; no-selection does not reveal a freeform field. +- **Listed option semantics:** Selecting a listed option makes the JIT field optional additional context. Payload: selected `OptionAnswer`(s) plus `note` when non-empty. +- **Other semantics:** Selecting the built-in `Other` / `Something else` row makes the same JIT field required custom-answer text. Payload: one `OtherAnswer` with the custom text; `note` is empty/omitted. +- **Multi-select Other rule:** Tentative model for the probe: `Other` is exclusive in multi-select and deselects listed options. This is not yet a durable decision; the prototype should validate or reject it. +- **Replay rendering finding:** On transcript resume, Pi appears to replay only `renderResult`, not `renderCall`; therefore result rendering must be self-contained enough to show the question/context as well as the answer. +- **Review-set flow is deferred:** Review-set proposals likely need approve / request-changes / reject plus comments that can mention simulated proposal IDs, but this side mission should only note that future complexity. Do not solve review-set UI in this probe. + +## Scope Card — JIT editor structured-exchange prototype + +- **Status:** next +- **Weight:** full scope card — this probes a live interaction model and may change the production structured-exchange state machine/result rendering. + +### Target Behavior + +A throwaway structured-exchange prototype answers whether one inline just-in-time editor can replace the second note step across the option-selection permutations. + +### Boundary Crossings + +```text +→ local prototype command or narrowly marked prototype branch in structured-exchange tests +→ option-selection state machine mirroring ask_user_question single/multi modes +→ TUI-like render/input loop with picker focus and inline editor focus +→ payload projection examples for OptionAnswer / OtherAnswer / note +→ prototype verdict captured in this file or handoff +``` + +### Risks and Assumptions + +- RISK: `pi-tui` `Editor` cannot comfortably render/focus inline beneath the picker for all option modes. + → MITIGATION: build the probe near the current `ctx.ui.custom()` component and drive real `Editor` instances if possible; if not, record the exact technical blocker and fall back to state-machine-only evidence. +- RISK: JIT editor reduces tab complexity but reintroduces height/scroll problems in the active answer surface. + → MITIGATION: prototype with compact prompt rendering: full question/context remains in transcript/tool-call render, active picker/editor stays short. +- RISK: multi-select editing creates stale note text when selections change. + → MITIGATION: include scripted cases for selection changes after text entry; prototype must expose current state after each action. +- RISK: replay rendering bug is conflated with JIT interaction. + → MITIGATION: treat replay as an adjacent acceptance candidate, not the main prototype question; record whether production `renderResult` should include prompt context. +- ASSUMPTION: A small prototype is cheaper than directly rewriting production `askSingleChoice` / `askMultiChoice`. + → IMPACT IF FALSE: if the prototype is too artificial, production work still needs exploratory churn. + → VALIDATE: the probe must exercise the same keyboard/focus primitives or clearly state where it diverges. + → `memory/SPEC.md` §Assumptions: A23-L indirectly; this mostly informs FE-744 structured-exchange UX, not the public RPC parity assumption. + +### Tracer-bullet check + +- **Proof of life:** lights up the proposed no-second-tab interaction before production rewrite. +- **Invariants:** clarifies payload semantics for `OptionAnswer`, `OtherAnswer`, and global `note`. +- **Uncertainty:** attacks the open “is this even possible / does it feel usable?” question directly. + +### Acceptance Criteria + +✓ **exclusive listed option** — selecting a listed option focuses one inline optional context editor and can submit `{ answers: [OptionAnswer], note }`. + +✓ **exclusive Other** — selecting `Other` focuses the same inline editor as a required custom-answer field and submits `{ answers: [OtherAnswer], note: "" }` or omits `note`. + +✓ **inclusive listed options** — selecting multiple listed options uses one global optional context editor and submits sorted option answers plus one global note. + +✓ **inclusive Other exclusivity** — selecting `Other` in multi-select clears listed options in the prototype, requires custom text, and submits one `OtherAnswer`. + +✓ **no-selection state** — before any selection, no editor is shown and submission is unavailable. + +✓ **selection-change behavior** — the prototype demonstrates what happens when selections change after editor text exists, with state visible after each action. + +✓ **replay note** — the verdict records whether production `renderResult` must render `question` / `context` because resumed transcripts do not replay `renderCall`. + +✓ **review-set note** — the verdict records that review-set comments with simulated proposal IDs and `#`-mention-like affordances are a later flow, not part of the option-question prototype. + +### Verification Approach + +- **Inner:** no production test gate required for a throwaway prototype; if any production or test code is touched, run `npm run fix` and `npm run check`. +- **Middle:** scripted interaction cases print state/render/payload for the six acceptance permutations above. +- **Outer:** human/user judgement on whether the inline editor feels clearer than the second tab and whether the `Other` semantics are legible. + +### Cross-cutting obligations + +- Keep prompt/question content transcript-backed and replayable; production result rendering must not rely solely on `renderCall` if resumed transcripts only replay `renderResult`. +- Do not introduce a parallel chat/turn store or non-transcript response state. +- Keep `Other` as an answer value, not a note, unless the prototype disproves this model. +- Keep review-set proposal/comment semantics out of this slice; only record future complexity. +- Do not mutate user-level Pi config or ambient `.pi` resources. + +### Expected prototype verdict shape + +```md +## Prototype Verdict: JIT editor structured exchange + +**Command:** [exact command] +**What we tried:** [single listed, single Other, multi listed, multi Other, selection-change case] +**Verdict:** [JIT editor viable? production shape?] +**Absorb:** [state-machine/result-render changes to production] +**Delete:** [prototype file(s) or branch] +**Follow-up:** [scope card for production rewrite, if warranted] +``` + +## Candidate production slices after verdict + +These are **not** active cards; they are likely follow-ups if the prototype is positive. + +1. **Replace option-note second step with JIT editor** — rewrite `askSingleChoice` / `askMultiChoice` production UI around one inline editor and update tests. +2. **Make result rendering replay-self-contained** — update `renderResult` so resumed transcripts show question/context plus selected/rejected/note lines. +3. **Align RPC editor fallback payload examples** — adjust schema instructions/examples so listed-option notes and `OtherAnswer` custom text match the chosen payload semantics. +4. **Review-set flow design pass** — later: model review-set proposal IDs, approve/request-changes/reject, and comment editor with simulated `#`-mention affordance. + +## Prototype Verdict: JIT editor structured exchange + +**Branch:** UI +**Command:** `npx tsx src/pi-extensions/structured-exchange-jit-editor.prototype.ts` + +**What we tried:** A throwaway state/render/payload probe in `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` covering: no-selection hidden-editor state; single-select listed option with optional note; single-select `Other` with required custom text; multi-select listed options with one global note; multi-select `Other` exclusivity; and selection changes after editor text exists. + +**Verdict:** The single JIT editor model is viable at the state/payload level and clearer than the second note tab. Production should keep one editor whose meaning changes by selection kind: listed options treat the text as optional global `note`; `Other` treats the text as the required `OtherAnswer` value and omits/empties `note`. The tentative multi-select `Other` exclusivity rule held up: selecting `Other` clears listed options and submits exactly one `OtherAnswer`. The only unresolved feel risk is low-level Pi focus/height behavior in the real `ctx.ui.custom()` component; the prototype is intentionally state-machine/render-level and did not instantiate real `pi-tui` `Editor` objects. + +**Absorb:** Replace the option-note second step with one inline editor under the picker; keep submit disabled before any selection; focus the inline editor after a selection; preserve global note text across listed-option changes; treat switching to `Other` as converting the current editor text into the required custom answer; sort listed option answers by original index. Update result rendering so `renderResult` is self-contained: resumed transcripts appear to replay only `renderResult`, so production result display should include question/context (or a compact prompt summary) along with selected/rejected answers and note. Align RPC editor fallback instructions/examples to the same semantics: listed option answers plus `note`; `OtherAnswer` custom text plus omitted/empty `note`. + +**Delete:** Delete `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` after the production rewrite or after a scoped build explicitly rejects this direction. Delete or absorb this side-mission file after its findings are reconciled into canonical SPEC/PLAN and the active card queue. + +**Follow-up:** Scope a production slice to implement the inline JIT editor in `askSingleChoice` / `askMultiChoice` and update tests; scope a second small slice if needed for replay-self-contained `renderResult`. Review-set comments with proposal IDs and `#`-mention-like affordances remain a later design pass, not part of option-question UI. diff --git a/src/pi-extensions/structured-exchange-jit-editor.prototype.ts b/src/pi-extensions/structured-exchange-jit-editor.prototype.ts new file mode 100644 index 00000000..6373710a --- /dev/null +++ b/src/pi-extensions/structured-exchange-jit-editor.prototype.ts @@ -0,0 +1,237 @@ +// PROTOTYPE — delete or absorb after verdict. +// Throwaway probe for structured-exchange just-in-time inline editor semantics. +// Run with: npx tsx src/pi-extensions/structured-exchange-jit-editor.prototype.ts + +type Mode = "single-select" | "multi-select" + +interface OptionAnswer { + type: "option" + label: string + value: string + index: number +} + +interface OtherAnswer { + type: "other" + label: string + value: string +} + +type Answer = OptionAnswer | OtherAnswer + +interface Option { + label: string + value: string + description?: string +} + +interface State { + mode: Mode + options: Option[] + selectedOptionIndexes: number[] + otherSelected: boolean + editorText: string + editorVisible: boolean + editorRequired: boolean + submitEnabled: boolean + focus: "picker" | "jit-editor" + history: string[] +} + +type Action = + | { type: "select-option"; index: number } + | { type: "select-other" } + | { type: "edit"; text: string } + | { type: "clear-selection" } + +interface Payload { + answers: Answer[] + note?: string +} + +const options: Option[] = [ + { + label: "Public Brunch RPC only (Recommended)", + value: "public-brunch-rpc", + description: "Client speaks only product methods.", + }, + { + label: "Raw Pi RPC bridge", + value: "raw-pi-rpc", + description: "Useful as internal adapter evidence only.", + }, + { + label: "TUI-only for now", + value: "tui-only", + }, +] + +function initialState(mode: Mode): State { + return derive({ + mode, + options, + selectedOptionIndexes: [], + otherSelected: false, + editorText: "", + editorVisible: false, + editorRequired: false, + submitEnabled: false, + focus: "picker", + history: ["initial"], + }) +} + +function reduce(state: State, action: Action): State { + const next: State = { ...state, history: [...state.history] } + + switch (action.type) { + case "select-option": { + if (state.mode === "single-select") { + next.selectedOptionIndexes = [action.index] + } else if (next.selectedOptionIndexes.includes(action.index)) { + next.selectedOptionIndexes = next.selectedOptionIndexes.filter( + (index) => index !== action.index, + ) + } else { + next.selectedOptionIndexes = [ + ...next.selectedOptionIndexes, + action.index, + ] + } + next.otherSelected = false + // Preserve text when switching listed options: it is global context, not option-owned. + next.history.push(`select option ${action.index + 1}`) + return derive(next) + } + case "select-other": { + next.selectedOptionIndexes = [] + next.otherSelected = true + next.history.push("select Other") + return derive(next) + } + case "edit": { + next.editorText = action.text + next.history.push(`edit ${JSON.stringify(action.text)}`) + return derive(next) + } + case "clear-selection": { + next.selectedOptionIndexes = [] + next.otherSelected = false + next.editorText = "" + next.history.push("clear selection") + return derive(next) + } + } +} + +function derive(state: State): State { + const hasSelection = + state.otherSelected || state.selectedOptionIndexes.length > 0 + return { + ...state, + selectedOptionIndexes: [...state.selectedOptionIndexes].sort( + (a, b) => a - b, + ), + editorVisible: hasSelection, + editorRequired: state.otherSelected, + submitEnabled: + hasSelection && + (!state.otherSelected || state.editorText.trim().length > 0), + focus: hasSelection ? "jit-editor" : "picker", + } +} + +function toPayload(state: State): Payload | null { + if (!state.submitEnabled) return null + if (state.otherSelected) { + const text = state.editorText.trim() + return { answers: [{ type: "other", label: text, value: text }] } + } + + const answers = state.selectedOptionIndexes.map((index) => { + const option = state.options[index]! + return { + type: "option" as const, + label: option.label, + value: option.value, + index: index + 1, + } + }) + const note = state.editorText.trim() + return note ? { answers, note } : { answers } +} + +function render(state: State): string { + const rows = state.options.map((option, index) => { + const marker = state.selectedOptionIndexes.includes(index) ? "●" : "○" + return `${marker} ${index + 1}. ${option.label}` + }) + rows.push(`${state.otherSelected ? "●" : "○"} Other`) + + const editor = state.editorVisible + ? [ + "", + state.editorRequired + ? "JIT editor — required custom answer for Other:" + : "JIT editor — optional additional context for selected option(s):", + `> ${state.editorText || "(empty)"}`, + ] + : ["", "JIT editor hidden until a selection exists."] + + const payload = toPayload(state) + return [ + `mode=${state.mode} focus=${state.focus} submit=${ + state.submitEnabled ? "enabled" : "disabled" + }`, + ...rows, + ...editor, + "", + `payload=${payload ? JSON.stringify(payload) : "unavailable"}`, + `history=${state.history.join(" → ")}`, + ].join("\n") +} + +function runCase(name: string, mode: Mode, actions: Action[]): void { + let state = initialState(mode) + console.log(`\n=== ${name} ===`) + console.log(render(state)) + for (const action of actions) { + state = reduce(state, action) + console.log(`\n--- after ${action.type} ---`) + console.log(render(state)) + } +} + +runCase("no-selection state", "single-select", []) +runCase("exclusive listed option + optional note", "single-select", [ + { type: "select-option", index: 0 }, + { type: "edit", text: "Use product semantics: workspace > spec > session." }, +]) +runCase("exclusive Other + required custom answer", "single-select", [ + { type: "select-other" }, + { type: "edit", text: "Something else: use a guided interview first." }, +]) +runCase("inclusive listed options + one global note", "multi-select", [ + { type: "select-option", index: 1 }, + { type: "select-option", index: 0 }, + { type: "edit", text: "Need both product boundary and fallback evidence." }, +]) +runCase("inclusive Other exclusivity", "multi-select", [ + { type: "select-option", index: 0 }, + { + type: "edit", + text: "This context should become the Other answer if Other is picked.", + }, + { type: "select-other" }, + { type: "edit", text: "Other path: defer until browser relay." }, +]) +runCase( + "selection-change behavior preserves global listed-option note", + "multi-select", + [ + { type: "select-option", index: 0 }, + { type: "edit", text: "Global context survives listed-option changes." }, + { type: "select-option", index: 2 }, + { type: "select-option", index: 0 }, + ], +) From 771b2d4d9db3ea8daf8e641c2b38bd8493b74b32 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:45:14 +0200 Subject: [PATCH 110/164] Replace option notes with inline JIT editor --- memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md | 92 +++++++ ...tructured-exchange-jit-editor.prototype.ts | 237 ---------------- src/pi-extensions/structured-exchange.test.ts | 106 ++++++-- src/pi-extensions/structured-exchange.ts | 255 ++++++++---------- 4 files changed, 287 insertions(+), 403 deletions(-) delete mode 100644 src/pi-extensions/structured-exchange-jit-editor.prototype.ts diff --git a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md index 253d48c8..5795198a 100644 --- a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md +++ b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md @@ -129,3 +129,95 @@ These are **not** active cards; they are likely follow-ups if the prototype is p **Delete:** Delete `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` after the production rewrite or after a scoped build explicitly rejects this direction. Delete or absorb this side-mission file after its findings are reconciled into canonical SPEC/PLAN and the active card queue. **Follow-up:** Scope a production slice to implement the inline JIT editor in `askSingleChoice` / `askMultiChoice` and update tests; scope a second small slice if needed for replay-self-contained `renderResult`. Review-set comments with proposal IDs and `#`-mention-like affordances remain a later design pass, not part of option-question UI. + +## Scope Card — Inline JIT editor for option structured exchanges + +- **Status:** done +- **Weight:** full scope card — this changes the option-answer UI state machine and retires the second-step note tab. + +### Target Behavior + +Option-selection structured exchanges use one inline just-in-time editor whose payload meaning is determined by whether the current selection is listed options or `Other`. + +### Boundary Crossings + +```text +→ ask_user_question tool execution +→ ctx.ui.custom option component state machine +→ pi-tui Editor rendered inline beneath the picker +→ OptionAnswer / OtherAnswer / note result details +→ renderResult transcript projection for the completed answer +``` + +### Risks and Assumptions + +- RISK: real `pi-tui` `Editor` focus/input handling does not work cleanly when rendered inline under the picker. → MITIGATION: add a test harness that records `component.render(width)` after each input and drives actual component `handleInput`; require acceptance tests to observe the inline editor state before result submission. +- RISK: inline editor height makes the active answer surface too tall. → MITIGATION: keep the active component compact: picker rows plus one short editor label/value area; leave full prompt/context in `renderCall`/transcript rendering. +- RISK: editor text becomes stale or semantically ambiguous when selections change. → MITIGATION: listed-option changes preserve the editor text as one global note; switching to `Other` clears listed selections and interprets the current editor text as the required custom answer. +- ASSUMPTION: multi-select `Other` should be exclusive rather than combinable with listed options. + → IMPACT IF FALSE: production payload semantics and tests need revision before public RPC parity depends on them. + → VALIDATE: multi-select UI test selects listed options, enters editor text, switches to `Other`, and asserts listed answers are cleared and one `OtherAnswer` is produced. + → memory/SPEC.md §Assumptions: indirect pressure on A23-L; mainly FE-744 UI semantics. + +### Tracer-bullet check + +- **Proof of life:** drives the real `ctx.ui.custom()` component through listed-option and `Other` flows with rendered inline editor snapshots. +- **Invariants:** stabilizes the answer payload mapping: listed options → `OptionAnswer[]` plus optional global `note`; `Other` → one `OtherAnswer` and omitted/empty `note`. +- **Uncertainty:** retires the prototype's remaining real-UI uncertainty around inline editor focus/render feasibility. + +### Acceptance Criteria + +✓ **single listed JIT** — selecting a listed single-select option renders the inline editor immediately, allows submitting with an empty note, and allows submitting `{ answers: [OptionAnswer], note }` after typing context. + +✓ **single Other JIT** — selecting `Other` renders the same inline editor as a required custom-answer field, blocks empty submission, and submits `{ answers: [OtherAnswer] }` with empty/omitted note after text entry. + +✓ **multi listed JIT** — selecting multiple listed options renders one global inline editor and submits sorted `OptionAnswer` values plus one global note. + +✓ **multi Other exclusivity** — selecting `Other` in multi-select clears listed selections, reuses the inline editor as required custom-answer text, and submits exactly one `OtherAnswer`. + +✓ **no second note tab** — option flows no longer enter a separate note-only mode after selection; rendered snapshots show picker plus inline editor in the same active surface before submission. + +✓ **selection-change behavior** — changing listed selections after typing preserves the editor text as global note; switching to `Other` interprets the editor text as custom answer text. + +✓ **result payload compatibility** — existing structured details keep `question`, `context`, `mode`, `options`, `answers`, `rejectedOptions`, `note`, and `transport` semantics; RPC editor fallback parsing remains compatible with listed-option notes and `OtherAnswer` values. + +### Verification Approach + +- **Inner:** `npm run fix` after meaningful edits; targeted `vitest src/pi-extensions/structured-exchange.test.ts src/ask-user-question-extension.test.ts` during the loop. +- **Middle:** component-driving tests with render snapshots before and after input, proving the real custom component displays the inline editor and produces the expected details payloads. +- **Outer:** manual TUI smoke or scripted pty check if available: answer one single-select listed option, one single-select `Other`, one multi-select listed combination, and one multi-select `Other` path; confirm the interaction feels like one surface rather than a second tab. + +### Cross-cutting obligations + +- Do not edit `memory/CARDS.md` for this side mission while another builder owns it. +- Keep prompt/question content transcript-backed; do not introduce a parallel chat/turn store or non-transcript response state. +- Keep `Other` as an answer value, not a note. +- Preserve the finding that resumed transcripts appear to replay only `renderResult`; if this slice does not make `renderResult` question/context-self-contained, leave a visible follow-up for that separate replay-rendering slice. +- Keep review-set approval/comment semantics out of this slice. + +### Notes for build + +- Existing tests named around the second note step should be renamed or replaced rather than preserved as compatibility behavior. +- The throwaway prototype `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` should be deleted in the same build once production tests cover its cases, unless the builder explicitly keeps it with a deletion trigger. + +### Build Result + +Implemented in `src/pi-extensions/structured-exchange.ts` and covered by `src/pi-extensions/structured-exchange.test.ts`. + +- Single-select listed options now reveal one inline optional-context editor and submit `OptionAnswer` plus optional `note`. +- Single-select `Other` uses the same inline editor as required custom-answer text and submits `OtherAnswer` with empty note. +- Multi-select listed options use one global inline editor; listed answer changes preserve the editor text as global note. +- Multi-select `Other` is exclusive: it clears listed options and submits one `OtherAnswer`. +- The separate note-only mode/tab was removed from option flows. +- The prototype file `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` was deleted after production tests covered its cases. + +Verification run: + +```sh +npm run check +npx vitest --run src/pi-extensions/structured-exchange.test.ts src/ask-user-question-extension.test.ts +npm run test +npm run build +``` + +Canonical follow-up: promote the durable UI semantics into `memory/SPEC.md`/`memory/PLAN.md` after the concurrent RPC builder's edits settle. The replay finding remains open: production `renderResult` still needs a separate replay-self-contained question/context slice if resumed transcripts continue to replay only results. diff --git a/src/pi-extensions/structured-exchange-jit-editor.prototype.ts b/src/pi-extensions/structured-exchange-jit-editor.prototype.ts deleted file mode 100644 index 6373710a..00000000 --- a/src/pi-extensions/structured-exchange-jit-editor.prototype.ts +++ /dev/null @@ -1,237 +0,0 @@ -// PROTOTYPE — delete or absorb after verdict. -// Throwaway probe for structured-exchange just-in-time inline editor semantics. -// Run with: npx tsx src/pi-extensions/structured-exchange-jit-editor.prototype.ts - -type Mode = "single-select" | "multi-select" - -interface OptionAnswer { - type: "option" - label: string - value: string - index: number -} - -interface OtherAnswer { - type: "other" - label: string - value: string -} - -type Answer = OptionAnswer | OtherAnswer - -interface Option { - label: string - value: string - description?: string -} - -interface State { - mode: Mode - options: Option[] - selectedOptionIndexes: number[] - otherSelected: boolean - editorText: string - editorVisible: boolean - editorRequired: boolean - submitEnabled: boolean - focus: "picker" | "jit-editor" - history: string[] -} - -type Action = - | { type: "select-option"; index: number } - | { type: "select-other" } - | { type: "edit"; text: string } - | { type: "clear-selection" } - -interface Payload { - answers: Answer[] - note?: string -} - -const options: Option[] = [ - { - label: "Public Brunch RPC only (Recommended)", - value: "public-brunch-rpc", - description: "Client speaks only product methods.", - }, - { - label: "Raw Pi RPC bridge", - value: "raw-pi-rpc", - description: "Useful as internal adapter evidence only.", - }, - { - label: "TUI-only for now", - value: "tui-only", - }, -] - -function initialState(mode: Mode): State { - return derive({ - mode, - options, - selectedOptionIndexes: [], - otherSelected: false, - editorText: "", - editorVisible: false, - editorRequired: false, - submitEnabled: false, - focus: "picker", - history: ["initial"], - }) -} - -function reduce(state: State, action: Action): State { - const next: State = { ...state, history: [...state.history] } - - switch (action.type) { - case "select-option": { - if (state.mode === "single-select") { - next.selectedOptionIndexes = [action.index] - } else if (next.selectedOptionIndexes.includes(action.index)) { - next.selectedOptionIndexes = next.selectedOptionIndexes.filter( - (index) => index !== action.index, - ) - } else { - next.selectedOptionIndexes = [ - ...next.selectedOptionIndexes, - action.index, - ] - } - next.otherSelected = false - // Preserve text when switching listed options: it is global context, not option-owned. - next.history.push(`select option ${action.index + 1}`) - return derive(next) - } - case "select-other": { - next.selectedOptionIndexes = [] - next.otherSelected = true - next.history.push("select Other") - return derive(next) - } - case "edit": { - next.editorText = action.text - next.history.push(`edit ${JSON.stringify(action.text)}`) - return derive(next) - } - case "clear-selection": { - next.selectedOptionIndexes = [] - next.otherSelected = false - next.editorText = "" - next.history.push("clear selection") - return derive(next) - } - } -} - -function derive(state: State): State { - const hasSelection = - state.otherSelected || state.selectedOptionIndexes.length > 0 - return { - ...state, - selectedOptionIndexes: [...state.selectedOptionIndexes].sort( - (a, b) => a - b, - ), - editorVisible: hasSelection, - editorRequired: state.otherSelected, - submitEnabled: - hasSelection && - (!state.otherSelected || state.editorText.trim().length > 0), - focus: hasSelection ? "jit-editor" : "picker", - } -} - -function toPayload(state: State): Payload | null { - if (!state.submitEnabled) return null - if (state.otherSelected) { - const text = state.editorText.trim() - return { answers: [{ type: "other", label: text, value: text }] } - } - - const answers = state.selectedOptionIndexes.map((index) => { - const option = state.options[index]! - return { - type: "option" as const, - label: option.label, - value: option.value, - index: index + 1, - } - }) - const note = state.editorText.trim() - return note ? { answers, note } : { answers } -} - -function render(state: State): string { - const rows = state.options.map((option, index) => { - const marker = state.selectedOptionIndexes.includes(index) ? "●" : "○" - return `${marker} ${index + 1}. ${option.label}` - }) - rows.push(`${state.otherSelected ? "●" : "○"} Other`) - - const editor = state.editorVisible - ? [ - "", - state.editorRequired - ? "JIT editor — required custom answer for Other:" - : "JIT editor — optional additional context for selected option(s):", - `> ${state.editorText || "(empty)"}`, - ] - : ["", "JIT editor hidden until a selection exists."] - - const payload = toPayload(state) - return [ - `mode=${state.mode} focus=${state.focus} submit=${ - state.submitEnabled ? "enabled" : "disabled" - }`, - ...rows, - ...editor, - "", - `payload=${payload ? JSON.stringify(payload) : "unavailable"}`, - `history=${state.history.join(" → ")}`, - ].join("\n") -} - -function runCase(name: string, mode: Mode, actions: Action[]): void { - let state = initialState(mode) - console.log(`\n=== ${name} ===`) - console.log(render(state)) - for (const action of actions) { - state = reduce(state, action) - console.log(`\n--- after ${action.type} ---`) - console.log(render(state)) - } -} - -runCase("no-selection state", "single-select", []) -runCase("exclusive listed option + optional note", "single-select", [ - { type: "select-option", index: 0 }, - { type: "edit", text: "Use product semantics: workspace > spec > session." }, -]) -runCase("exclusive Other + required custom answer", "single-select", [ - { type: "select-other" }, - { type: "edit", text: "Something else: use a guided interview first." }, -]) -runCase("inclusive listed options + one global note", "multi-select", [ - { type: "select-option", index: 1 }, - { type: "select-option", index: 0 }, - { type: "edit", text: "Need both product boundary and fallback evidence." }, -]) -runCase("inclusive Other exclusivity", "multi-select", [ - { type: "select-option", index: 0 }, - { - type: "edit", - text: "This context should become the Other answer if Other is picked.", - }, - { type: "select-other" }, - { type: "edit", text: "Other path: defer until browser relay." }, -]) -runCase( - "selection-change behavior preserves global listed-option note", - "multi-select", - [ - { type: "select-option", index: 0 }, - { type: "edit", text: "Global context survives listed-option changes." }, - { type: "select-option", index: 2 }, - { type: "select-option", index: 0 }, - ], -) diff --git a/src/pi-extensions/structured-exchange.test.ts b/src/pi-extensions/structured-exchange.test.ts index f396d4ea..704c826f 100644 --- a/src/pi-extensions/structured-exchange.test.ts +++ b/src/pi-extensions/structured-exchange.test.ts @@ -41,8 +41,8 @@ interface FakeTheme { } const enter = "\r" -const escape = "\x1b" const down = "\x1b[B" +const up = "\x1b[A" const space = " " const theme: FakeTheme = { fg: (_color, text) => text } @@ -57,22 +57,24 @@ function registeredTool(): RegisteredTool { return tool } -function contextDrivingCustom(inputs: string[]) { +function contextDrivingCustom(inputs: string[], renders?: string[]) { return { hasUI: true, ui: { custom: async (factory: any) => { + let resolved: unknown = undefined const component = factory( - { requestRender: () => {} }, + { requestRender: () => {}, terminal: { rows: 30 } }, theme, {}, (result: unknown) => { resolved = result }, ) - let resolved: unknown = undefined + renders?.push(component.render(80).join("\n")) for (const input of inputs) { component.handleInput(input) + renders?.push(component.render(80).join("\n")) if (resolved !== undefined) return resolved } throw new Error("custom UI did not resolve") @@ -106,18 +108,23 @@ function optionParams(multiSelect = false): Record<string, unknown> { } } -describe("structured exchange option notes", () => { - it("requires a focused note submit after a single-select option answer", async () => { +describe("structured exchange inline JIT editor", () => { + it("renders one inline optional editor after a single-select listed option", async () => { const tool = registeredTool() + const renders: string[] = [] const result = await tool.execute( "call-1", optionParams(), undefined, undefined, - contextDrivingCustom([enter, ..."Add context", enter]), + contextDrivingCustom([enter, ..."Add context", enter], renders), ) + expect( + renders.some((rendered) => rendered.includes("Optional context:")), + ).toBe(true) + expect(renders.join("\n")).not.toContain("Optional note:") expect(result.details).toMatchObject({ status: "answered", mode: "single-select", @@ -127,17 +134,22 @@ describe("structured exchange option notes", () => { expect(result.content[0]?.text).toContain("Add context") }) - it("preserves Other as an answer and records an intentionally empty single-select note", async () => { + it("uses the inline editor as required single-select Other text", async () => { const tool = registeredTool() + const emptyAttemptRenders: string[] = [] const result = await tool.execute( "call-1", optionParams(), undefined, undefined, - contextDrivingCustom([down, down, enter, ..."Custom", enter, enter]), + contextDrivingCustom( + [down, down, enter, enter, ..."Custom", enter], + emptyAttemptRenders, + ), ) + expect(emptyAttemptRenders.join("\n")).toContain("Custom answer required:") expect(result.details).toMatchObject({ status: "answered", mode: "single-select", @@ -146,7 +158,37 @@ describe("structured exchange option notes", () => { }) }) - it("returns from the note step to the multi-select picker with selections preserved", async () => { + it("renders one global inline editor for multi-select listed options", async () => { + const tool = registeredTool() + const renders: string[] = [] + + const result = await tool.execute( + "call-1", + optionParams(true), + undefined, + undefined, + contextDrivingCustom( + [space, down, enter, ..."Shared note", down, down, enter], + renders, + ), + ) + + expect( + renders.some((rendered) => rendered.includes("Optional context:")), + ).toBe(true) + expect(renders.join("\n")).not.toContain("Optional note:") + expect(result.details).toMatchObject({ + status: "answered", + mode: "multi-select", + note: "Shared note", + answers: [ + { type: "option", label: "Alpha", value: "a", index: 1 }, + { type: "option", label: "Beta", value: "b", index: 2 }, + ], + }) + }) + + it("makes multi-select Other exclusive and required", async () => { const tool = registeredTool() const result = await tool.execute( @@ -156,12 +198,7 @@ describe("structured exchange option notes", () => { undefined, contextDrivingCustom([ space, - down, - space, - down, - down, - enter, - escape, + ..."Existing note becomes custom text", down, down, enter, @@ -174,12 +211,45 @@ describe("structured exchange option notes", () => { mode: "multi-select", note: "", answers: [ - { type: "option", label: "Alpha", value: "a", index: 1 }, - { type: "option", label: "Beta", value: "b", index: 2 }, + { + type: "other", + label: "Existing note becomes custom text", + value: "Existing note becomes custom text", + }, ], }) }) + it("preserves global listed-option context as selections change", async () => { + const tool = registeredTool() + + const result = await tool.execute( + "call-1", + optionParams(true), + undefined, + undefined, + contextDrivingCustom([ + space, + ..."Global context", + down, + enter, + up, + enter, + down, + down, + down, + enter, + ]), + ) + + expect(result.details).toMatchObject({ + status: "answered", + mode: "multi-select", + note: "Global context", + answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], + }) + }) + it("renders a non-empty note without rendering empty notes", async () => { const tool = registeredTool() const withNote = await tool.execute( diff --git a/src/pi-extensions/structured-exchange.ts b/src/pi-extensions/structured-exchange.ts index 8871f3b5..20bd0e4e 100644 --- a/src/pi-extensions/structured-exchange.ts +++ b/src/pi-extensions/structured-exchange.ts @@ -568,26 +568,22 @@ async function askSingleChoice( done: (result: OptionAnswerResult | null) => void, ) => { let optionIndex = 0 - let editMode = false - let noteMode = false let selectedAnswer: AskAnswer | undefined let cachedLines: string[] | undefined const editor = new Editor(tui, createEditorTheme(theme)) - const noteEditor = new Editor(tui, createEditorTheme(theme)) editor.onSubmit = (value) => { const trimmed = value.trim() - if (!trimmed) return - selectedAnswer = { type: "other", label: trimmed, value: trimmed } - editMode = false - noteMode = true - noteEditor.setText("") - refresh() - } - - noteEditor.onSubmit = (value) => { if (!selectedAnswer) return - done({ answers: [selectedAnswer], note: value.trim() }) + if (selectedAnswer.type === "other") { + if (!trimmed) return + done({ + answers: [{ type: "other", label: trimmed, value: trimmed }], + note: "", + }) + return + } + done({ answers: [selectedAnswer], note: trimmed }) } function refresh() { @@ -595,62 +591,50 @@ async function askSingleChoice( tui.requestRender() } - function handleInput(data: string) { - if (noteMode) { - if (matchesKey(data, Key.escape)) { - noteMode = false - noteEditor.setText("") - refresh() - return - } - noteEditor.handleInput(data) - refresh() + function selectFocusedOption() { + const selected = allOptions[optionIndex]! + if (selected.isOther) { + selectedAnswer = { type: "other", label: "", value: "" } return } - - if (editMode) { - if (matchesKey(data, Key.escape)) { - editMode = false - editor.setText("") - refresh() - return - } - editor.handleInput(data) - refresh() - return + selectedAnswer = { + type: "option", + label: selected.label, + value: selected.value, + index: selected.index!, } + } + function handleInput(data: string) { if (matchesKey(data, Key.up)) { optionIndex = Math.max(0, optionIndex - 1) + if (selectedAnswer) selectFocusedOption() refresh() return } if (matchesKey(data, Key.down)) { optionIndex = Math.min(allOptions.length - 1, optionIndex + 1) + if (selectedAnswer) selectFocusedOption() refresh() return } if (matchesKey(data, Key.enter)) { - const selected = allOptions[optionIndex]! - if (selected.isOther) { - editMode = true - editor.setText("") - refresh() + if (selectedAnswer) { + editor.onSubmit?.(editor.getText()) return } - selectedAnswer = { - type: "option", - label: selected.label, - value: selected.value, - index: selected.index!, - } - noteMode = true - noteEditor.setText("") + selectFocusedOption() refresh() return } if (matchesKey(data, Key.escape)) { done(null) + return + } + + if (selectedAnswer) { + editor.handleInput(data) + refresh() } } @@ -662,23 +646,6 @@ async function askSingleChoice( add(pickerTopBorder(theme, width)) - if (noteMode) { - add(theme.fg("success", " Answer selected")) - if (selectedAnswer) { - add(` ${formatAnswerForModel(selectedAnswer)}`) - } - lines.push("") - add(theme.fg("muted", " Optional note:")) - for (const line of noteEditor.render(Math.max(1, width - 2))) { - add(` ${line}`) - } - lines.push("") - add(theme.fg("dim", " Enter to submit • Esc to go back")) - add(pickerBottomBorder(theme, width)) - cachedLines = lines - return lines - } - for (let i = 0; i < allOptions.length; i++) { const option = allOptions[i]! const selected = i === optionIndex @@ -701,14 +668,25 @@ async function askSingleChoice( } } - if (editMode) { + if (selectedAnswer) { lines.push("") - add(theme.fg("muted", " Write your custom answer:")) + const isOther = selectedAnswer.type === "other" + add( + theme.fg( + isOther ? "warning" : "muted", + isOther ? " Custom answer required:" : " Optional context:", + ), + ) for (const line of editor.render(Math.max(1, width - 2))) { add(` ${line}`) } lines.push("") - add(theme.fg("dim", " Enter to submit • Esc to go back")) + add( + theme.fg( + "dim", + " ↑↓ change selection • Type context • Enter submit • Esc cancel", + ), + ) } else { lines.push("") add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel")) @@ -762,26 +740,27 @@ async function askMultiChoice( done: (result: OptionAnswerResult | null) => void, ) => { let optionIndex = 0 - let editMode = false - let noteMode = false let cachedLines: string[] | undefined const selected = new Map<string, AskAnswer>() const editor = new Editor(tui, createEditorTheme(theme)) - const noteEditor = new Editor(tui, createEditorTheme(theme)) + let otherSelected = false editor.onSubmit = (value) => { const trimmed = value.trim() - if (!trimmed) return - selected.set("other", { type: "other", label: trimmed, value: trimmed }) - editMode = false - refresh() - } - - noteEditor.onSubmit = (value) => { - done({ - answers: sortAnswers(Array.from(selected.values())), - note: value.trim(), - }) + if (otherSelected) { + if (!trimmed) return + done({ + answers: [{ type: "other", label: trimmed, value: trimmed }], + note: "", + }) + return + } + if (selected.size > 0) { + done({ + answers: sortAnswers(Array.from(selected.values())), + note: trimmed, + }) + } } function refresh() { @@ -790,6 +769,7 @@ async function askMultiChoice( } function toggleOption(item: DisplayOption) { + otherSelected = false if (selected.has(item.id)) { selected.delete(item.id) } else { @@ -804,30 +784,6 @@ async function askMultiChoice( } function handleInput(data: string) { - if (noteMode) { - if (matchesKey(data, Key.escape)) { - noteMode = false - noteEditor.setText("") - refresh() - return - } - noteEditor.handleInput(data) - refresh() - return - } - - if (editMode) { - if (matchesKey(data, Key.escape)) { - editMode = false - editor.setText(selected.get("other")?.label || "") - refresh() - return - } - editor.handleInput(data) - refresh() - return - } - if (matchesKey(data, Key.up)) { optionIndex = Math.max(0, optionIndex - 1) refresh() @@ -841,16 +797,16 @@ async function askMultiChoice( const current = allItems[optionIndex]! if (matchesKey(data, Key.space)) { + if (otherSelected || selected.size > 0) { + editor.handleInput(data) + refresh() + return + } if (current.isSubmit) return if (current.isOther) { - if (selected.has("other")) { - selected.delete("other") - refresh() - } else { - editMode = true - editor.setText("") - refresh() - } + selected.clear() + otherSelected = true + refresh() return } toggleOption(current) @@ -859,16 +815,16 @@ async function askMultiChoice( if (matchesKey(data, Key.enter)) { if (current.isSubmit) { - if (selected.size > 0) { - noteMode = true - noteEditor.setText("") - refresh() - } + editor.onSubmit?.(editor.getText()) + return + } + if (current.isOther && otherSelected) { + editor.onSubmit?.(editor.getText()) return } if (current.isOther) { - editMode = true - editor.setText(selected.get("other")?.label || "") + selected.clear() + otherSelected = true refresh() return } @@ -878,6 +834,12 @@ async function askMultiChoice( if (matchesKey(data, Key.escape)) { done(null) + return + } + + if (otherSelected || selected.size > 0) { + editor.handleInput(data) + refresh() } } @@ -889,23 +851,6 @@ async function askMultiChoice( add(pickerTopBorder(theme, width)) - if (noteMode) { - add(theme.fg("success", ` ${selected.size} answer(s) selected`)) - for (const answer of sortAnswers(Array.from(selected.values()))) { - add(` ${formatAnswerForModel(answer)}`) - } - lines.push("") - add(theme.fg("muted", " Optional note:")) - for (const line of noteEditor.render(Math.max(1, width - 2))) { - add(` ${line}`) - } - lines.push("") - add(theme.fg("dim", " Enter to submit • Esc to go back")) - add(pickerBottomBorder(theme, width)) - cachedLines = lines - return lines - } - for (let i = 0; i < allItems.length; i++) { const item = allItems[i]! const isFocused = i === optionIndex @@ -913,24 +858,28 @@ async function askMultiChoice( if (item.isSubmit) { const label = - selected.size > 0 - ? `✓ ${item.label} (${selected.size} selected)` + selected.size > 0 || otherSelected + ? `✓ ${item.label} (${ + otherSelected ? 1 : selected.size + } selected)` : `○ ${item.label}` const styled = isFocused ? theme.fg("accent", label) - : theme.fg(selected.size > 0 ? "success" : "dim", label) + : theme.fg( + selected.size > 0 || otherSelected ? "success" : "dim", + label, + ) add(`${prefix}${styled}`) continue } if (item.isOther) { - const other = selected.get("other") - const marker = other ? "[x]" : "[ ]" - const suffix = other ? ` — ${other.label}` : "" + const marker = otherSelected ? "[x]" : "[ ]" + const suffix = otherSelected ? ` — ${editor.getText?.() ?? ""}` : "" const styled = isFocused ? theme.fg("accent", `${marker} ${item.label}${suffix}`) : theme.fg( - other ? "success" : "text", + otherSelected ? "success" : "text", `${marker} ${item.label}${suffix}`, ) add(`${prefix}${styled}`) @@ -957,17 +906,27 @@ async function askMultiChoice( } } - if (editMode) { + if (otherSelected || selected.size > 0) { lines.push("") - add(theme.fg("muted", " Write your custom answer:")) + add( + theme.fg( + otherSelected ? "warning" : "muted", + otherSelected ? " Custom answer required:" : " Optional context:", + ), + ) for (const line of editor.render(Math.max(1, width - 2))) { add(` ${line}`) } lines.push("") - add(theme.fg("dim", " Enter to save • Esc to go back")) + add( + theme.fg( + "dim", + " ↑↓ change focus • Enter toggle/submit • Type context • Esc cancel", + ), + ) } else { lines.push("") - if (selected.size === 0) { + if (selected.size === 0 && !otherSelected) { add( theme.fg( "warning", From c7a9b28a3db975da6cc96a4ef05bf42e7cd61d26 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:48:59 +0200 Subject: [PATCH 111/164] Add pending elicitation exchange RPC --- src/rpc.test.ts | 147 ++++++++++++++++++++++++++++++++++++++++++++++++ src/rpc.ts | 46 +++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/src/rpc.test.ts b/src/rpc.test.ts index f84bbe51..d0d9bb93 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -205,6 +205,7 @@ describe("JSON-RPC handlers", () => { expect(methods.map((entry) => entry.method).sort()).toEqual([ "rpc.discover", "session.elicitationExchanges", + "session.pendingExchange", "session.startElicitation", "session.transcriptDisplay", "workspace.activate", @@ -564,6 +565,152 @@ describe("JSON-RPC handlers", () => { expect(sessionText).toContain('"lens":"step-by-step"') }) + it("reads the selected pending elicitation exchange from transcript truth", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-pending-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + await coordinatorInstance.createSetupSession({ + specTitle: "Pending spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + + const start = await handlers.handle({ + jsonrpc: "2.0", + id: 46, + method: "session.startElicitation", + }) + const pending = await handlers.handle({ + jsonrpc: "2.0", + id: 47, + method: "session.pendingExchange", + }) + + expect(pending).toMatchObject({ + jsonrpc: "2.0", + id: 47, + result: { + status: "pending", + exchange: { + exchangeId: (start as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId, + prompt: expect.stringContaining("new product or feature"), + lens: "step-by-step", + note: { allowed: true }, + }, + }, + }) + }) + + it("reads an explicit pending exchange without opening the selected workspace session", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-explicit-pending-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Explicit pending spec", + }) + const startHandlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + await startHandlers.handle({ + jsonrpc: "2.0", + id: 48, + method: "session.startElicitation", + }) + + const handlers = createRpcHandlers({ + coordinator: { + ...coordinatorInstance, + async openDefaultWorkspace() { + throw new Error( + "explicit pending reads must not open selected session", + ) + }, + }, + cwd, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 49, + method: "session.pendingExchange", + params: { sessionId: workspace.session.id, specId: workspace.spec.id }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 49, + result: { + status: "pending", + exchange: { exchangeId: "deterministic-grounding-1" }, + }, + }) + }) + + it("reports idle pending state when the selected session has no open prompt", async () => { + const sessionFile = await createSessionFile() + const handlers = createRpcHandlers({ + coordinator: coordinator(readyState(sessionFile)), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 50, + method: "session.pendingExchange", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 50, + result: { status: "idle", exchange: null }, + }) + }) + + it("returns a product-shaped no-session error when reading pending without a selected session", async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 51, + method: "session.pendingExchange", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 51, + error: { code: -32001, message: "No selected Brunch session" }, + }) + }) + + it("returns product-shaped non-linear errors when reading pending exchanges", async () => { + const sessionFile = await createBranchedSessionFile() + const handlers = createRpcHandlers({ + coordinator: coordinator(readyState(sessionFile)), + cwd: "/tmp/brunch-project", + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 52, + method: "session.pendingExchange", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 52, + error: { + code: -32002, + message: "Selected Brunch session transcript is non-linear", + }, + }) + }) + it("resumes an open deterministic elicitation prompt without duplicating transcript entries", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-resume-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) diff --git a/src/rpc.ts b/src/rpc.ts index 44f4cadf..1122ae13 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -107,6 +107,15 @@ export function createRpcHandlers(options: { return handleStartElicitation(requestId, options) } + if (request.method === "session.pendingExchange") { + return handleSessionProjection( + requestId, + request.params, + options, + projectPendingElicitationExchange, + ) + } + if (request.method === "session.elicitationExchanges") { return handleSessionProjection( requestId, @@ -329,6 +338,17 @@ const StartElicitationResultSchema = Type.Object( { additionalProperties: false }, ) +const PendingExchangeResultSchema = Type.Union([ + StartElicitationResultSchema, + Type.Object( + { + status: Type.Literal("idle"), + exchange: Type.Null(), + }, + { additionalProperties: false }, + ), +]) + type RpcMethodDiscovery = { method: string description: string @@ -434,6 +454,22 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ resultSchema: StartElicitationResultSchema, examples: [{ jsonrpc: "2.0", id: 8, method: "session.startElicitation" }], }, + { + method: "session.pendingExchange", + description: + "Read the current transcript-backed pending elicitation exchange from the selected or explicitly named linear Brunch session.", + paramsSchema: SessionProjectionParamsSchema, + resultSchema: PendingExchangeResultSchema, + examples: [ + { jsonrpc: "2.0", id: 9, method: "session.pendingExchange" }, + { + jsonrpc: "2.0", + id: 10, + method: "session.pendingExchange", + params: { sessionId: "session-1", specId: "spec-1" }, + }, + ], + }, ] type WorkspaceActivationParamsParseResult = { @@ -588,6 +624,16 @@ function pendingExchangeFromEnvelope( return null } +function projectPendingElicitationExchange( + envelope: BrunchSessionEnvelope, +): Static<typeof PendingExchangeResultSchema> { + const exchange = pendingExchangeFromEnvelope(envelope) + if (!exchange) { + return { status: "idle", exchange: null } + } + return { status: "pending", exchange } +} + interface FlushableSessionManager { _rewriteFile(): void setSessionFile(file: string): void From 1107684e72ea593647c60f94108129f85f5b51d6 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:54:15 +0200 Subject: [PATCH 112/164] Add listed-option elicitation response RPC --- memory/CARDS.md | 184 ---------------------------------------- memory/PLAN.md | 2 +- src/rpc.test.ts | 220 ++++++++++++++++++++++++++++++++++++++++++++++++ src/rpc.ts | 150 +++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 185 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 5aab41f6..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,184 +0,0 @@ -<!-- CARDS.md — temporary execution queue for the active FE-744 frontier. - Delete when exhausted or superseded. Canonical state remains in memory/SPEC.md and memory/PLAN.md. --> - -# Cards — FE-744 public RPC elicitation parity - -## Orientation - -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, now re-aimed from the completed raw Pi RPC editor-fallback proof toward the public Brunch JSON-RPC elicitation session parity proof. -- **Frontier item:** `pi-ui-extension-patterns`; this card is a slice inside the existing FE-744 Linear/branch boundary, not a new tracker item or branch. -- **Volatile handoff state:** `HANDOFF.md` remains an untracked transfer artifact. Its durable claims have been reconciled into `memory/SPEC.md` / `memory/PLAN.md`; do not treat the old completed card queue as active. -- **Main open risk:** discovery must be useful to an agent-as-user without becoming a generic RPC platform or drifting away from the handlers that actually validate and serve Brunch methods. - -## Cross-cutting obligations for all cards - -- Public clients speak Brunch JSON-RPC only. Raw Pi RPC may be used behind Brunch adapters, but method discovery must not expose Pi command objects, Pi `get_commands`, or slash-command internals as Brunch product methods. -- Preserve TypeBox as the runtime schema vocabulary for Brunch boundaries (`D41-L`); do not introduce Zod or hand-wavy schema prose for RPC discovery. -- Preserve the thin named-method-family posture (`D19-L`): concrete product methods and projection handlers, not a generic read gateway or generic records API. -- Preserve workspace/spec/session hierarchy and explicit activation semantics (`D11-L`, `D21-L`, `I22-L`). Discovery must describe activation; it must not silently activate or create sessions. -- Preserve linear transcript policy and transcript-backed elicitation (`I19-L`, `I23-L`, `I32-L`) even though this first card does not yet implement pending/respond. - ---- - -## Card 1 — Public RPC method discovery registry - -- **Status:** done -- **Weight:** full scope card — establishes the public method-discovery seam for FE-744 and becomes the contract source for later agent-as-user probes. - -### Target Behavior - -A Brunch JSON-RPC client can call `rpc.discover` with no params and receive a self-describing list of currently supported public Brunch methods with descriptions, parameter schemas, result schemas, and example calls. - -### Boundary Crossings - -```text -→ JSON-RPC request { method: "rpc.discover" } -→ createRpcHandlers dispatch -→ Brunch-owned RPC method registry / schema descriptions -→ TypeBox/JSON-Schema-shaped method metadata -→ JSON-RPC success response usable by CLI/web/fixture clients -``` - -### Risks and Assumptions - -- RISK: Discovery schemas drift from handler validation schemas. - → MITIGATION: centralize discoverable method metadata near the RPC handler layer; reuse exported TypeBox schemas where they already exist (for example `SpecSessionActivationDecisionSchema` / activation params) rather than duplicating shapes in comments. -- RISK: Discovery tries to describe future methods and misleads the agent-as-user probe. - → MITIGATION: `rpc.discover` lists only methods implemented by the current host in this slice; pending future methods (`session.startElicitation`, `session.pendingExchange`, `elicitation.respond`) land with their own cards. -- RISK: Examples become a second informal contract that diverges from schemas. - → MITIGATION: tests assert examples are valid JSON-RPC request shapes for their advertised methods and include no raw Pi RPC commands. -- ASSUMPTION: A compact hand-authored registry for the current method set is enough to bootstrap public discovery without refactoring the whole dispatcher into a framework. - → IMPACT IF FALSE: later pending/respond work may need a deeper handler-table refactor before the parity driver can rely on discovery. - → VALIDATE: this card lands a discoverable registry for all currently implemented public methods and tests it against existing handler behavior. - → `memory/SPEC.md` §Assumptions: A23-L. - -### Acceptance Criteria - -✓ `rpc.discover` — returns entries for `rpc.discover`, `workspace.snapshot`, `workspace.selectionState`, `workspace.activate`, `session.elicitationExchanges`, and `session.transcriptDisplay`. - -✓ `rpc.discover` params contract — rejects any non-empty `params` with JSON-RPC `-32602 Invalid params`. - -✓ Method metadata shape — every discovered method has `method`, `description`, `paramsSchema`, `resultSchema`, and at least one JSON-RPC example call. - -✓ Product boundary — discovery does not list raw Pi RPC commands such as `prompt`, `get_state`, `get_commands`, or slash command names. - -✓ Schema usefulness — `workspace.activate` discovery exposes the activation decision union closely enough that a client can see `continue`, `openSession`, `newSession`, `newSpec`, and `cancel` variants without reading source. - -✓ Drift guard — examples in discovery are valid JSON-RPC request objects for advertised methods, and tests fail if discovery omits an implemented public method or advertises an unsupported method. - -### Verification Approach - -- **Inner:** `npm run fix`; targeted `vitest` for `src/rpc.test.ts`; `npm run check`. -- **Middle:** JSON-RPC contract tests for discovery shape, invalid params, no raw Pi exposure, example validity, and registry/dispatcher method parity for the currently implemented public method set. -- **Outer:** none for this card; human review of the discovery response is sufficient until the agent-as-user parity driver consumes it. - -### Cross-cutting obligations - -- Keep discovery Brunch-owned and product-shaped (`D5-L`, `D48-L`); do not copy Pi's non-JSON-RPC command shape. -- Keep TypeBox/JSON-Schema as the schema vocabulary for RPC boundary metadata (`D41-L`). -- Keep discovery scoped to named Brunch method families (`D19-L`); do not introduce generic `records.*` or a read-model platform. -- Preserve activation/session semantics: describe `workspace.*` methods without opening sessions or invoking TUI picker code (`I22-L`). - -### Promotion checklist - -- [x] Does this change a requirement? Already reconciled in `memory/SPEC.md` as R27/R24/R28. -- [x] Does this create, retire, or invalidate an assumption? It advances but does not retire A23-L. -- [x] Does this slice depend on an unvalidated high-impact assumption? It attacks A23-L as the first tracer bullet rather than assuming the whole ten-turn proof works. -- [x] Does this make or reverse a non-trivial design decision? Already reconciled as D48-L; this card implements it. -- [x] Does this establish a new seam-level invariant? Already reconciled as I32-L; this card establishes the discovery part. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No — obligations were reconciled in SPEC/PLAN before this card. -- [ ] Does it cross more than two major seams? No — JSON-RPC dispatch + registry/schema metadata. -- [x] Is this the first touch in an unfamiliar seam from a fresh thread? Yes; use full card. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. - ---- - -## Card 2 — Start deterministic elicitation with a pending exchange - -- **Status:** done -- **Weight:** full scope card — establishes the assistant-first public RPC lifecycle seam while deliberately avoiding structured answer semantics that are being refined in another thread. - -### Orientation - -- **Containing seam:** FE-744 public RPC elicitation parity, after `rpc.discover` landed in `db859418`. -- **Frontier item:** `pi-ui-extension-patterns`; this is still one slice inside the existing FE-744 Linear/branch boundary. -- **Volatile state:** another thread is refining structured-exchange answer behavior (`Other` vs optional note). This slice must not modify `src/pi-extensions/structured-exchange.ts` or commit response semantics. -- **Main open risk:** a deterministic assistant-first prompt is now transcript-backed; later `pendingExchange` / `elicitation.respond` / ten-turn parity work must continue this without inventing a parallel turn store. - -### Target Behavior - -A Brunch JSON-RPC client can call `session.startElicitation` for the selected session and receive the first deterministic assistant-originated pending exchange. - -### Boundary Crossings - -```text -→ JSON-RPC request { method: "session.startElicitation" } -→ createRpcHandlers dispatch -→ selected WorkspaceSessionCoordinator state -→ deterministic elicitation starter -→ Pi JSONL prompt-side entry or entries under the selected session -→ JSON-RPC result containing the pending exchange snapshot -→ rpc.discover metadata for session.startElicitation -``` - -### Risks and Assumptions - -- RISK: The starter becomes an in-memory queue instead of transcript-backed product state. - → MITIGATION: write prompt-side Pi JSONL evidence using the existing `brunch.elicitation_prompt` transcript seam; tests reload the session file and project an open prompt from disk. -- RISK: The slice accidentally commits answer semantics while the structured-exchange refinement thread is active. - → MITIGATION: implement only start/resume and pending snapshot return; do not add `elicitation.respond`, do not parse option answers, and do not modify `src/pi-extensions/structured-exchange.ts`. -- RISK: Repeated `session.startElicitation` calls duplicate the first prompt. - → MITIGATION: make the method idempotent for an already-open prompt in the selected session; return the existing pending exchange rather than appending another prompt. -- RISK: New prompt entries violate lens/custom-entry obligations. - → MITIGATION: any structured elicitor-emitted custom entry data includes `lens: "step-by-step"`; displayable custom-message rows remain prompt-side transcript evidence. -- ASSUMPTION: A deterministic starter prompt over the selected session is enough to attack A23-L before implementing response handling. - → IMPACT IF FALSE: the next cards may need a fuller elicitor state machine before `elicitation.respond` can be scoped. - → VALIDATE: this card proves Brunch can create/resume assistant-first pending state through public RPC and project it from linear Pi JSONL. - → `memory/SPEC.md` §Assumptions: A23-L. - -### Tracer-bullet check - -- **Proof of life:** lights up the first assistant-first public Brunch RPC path after workspace activation. -- **Invariants:** stabilizes the boundary between selected session state, transcript-backed prompt-side entries, and public pending-exchange snapshots. -- **Uncertainty:** attacks A23-L by proving that public RPC can initiate the elicitation loop without raw Pi RPC or a parallel prompt table. - -### Acceptance Criteria - -✓ `session.startElicitation` discovery — `rpc.discover` lists `session.startElicitation` with params/result schemas and an example request. - -✓ selected-session start — with a ready selected session, `session.startElicitation` returns `{ status: "pending", exchange: ... }` with a stable `exchangeId`, `lens: "step-by-step"`, `mode`, prompt text, options (if any), and `note: { allowed: true }` metadata. - -✓ transcript-backed prompt — starting elicitation appends prompt-side Pi JSONL evidence under the selected session, and `session.elicitationExchanges` reports `status: "open_prompt"` after reloading that session file. - -✓ transcript display — `session.transcriptDisplay` includes a prompt row for the deterministic first question after start. - -✓ idempotent resume — calling `session.startElicitation` again while the first prompt is open returns the same pending exchange id and does not append duplicate prompt-side entries. - -✓ no selected session — calling `session.startElicitation` while workspace state is not ready returns the existing product-shaped no-session error (`-32001`, `No selected Brunch session`). - -✓ boundary guard — the implementation does not import or modify the TUI structured-exchange tool module for this slice. - -### Verification Approach - -- **Inner:** `npm run fix`; targeted `vitest src/rpc.test.ts`; `npm run check`. -- **Middle:** JSON-RPC contract tests for discovery, ready/no-session behavior, idempotent start, transcript reload, `session.elicitationExchanges` open-prompt projection, and `session.transcriptDisplay` prompt row. -- **Outer:** none for this slice; the later ten-turn parity proof is the outer/product proof. - -### Cross-cutting obligations - -- Public clients speak Brunch JSON-RPC only; no raw Pi RPC command or slash command participates in this slice (`D5-L`, `D48-L`, `D49-L`). -- Preserve linear transcript policy and prompt-side projection over Pi JSONL (`I19-L`, `I23-L`, `I32-L`). -- Preserve workspace/spec/session activation authority: the selected session comes from `WorkspaceSessionCoordinator`; startup/picker UI is not invoked (`D21-L`, `I22-L`). -- Keep this slice response-free while structured answer permutations are refined elsewhere; `elicitation.respond` is a later card. - -### Promotion checklist - -- [x] Does this change a requirement? Already reconciled in `memory/SPEC.md` as R28/R24. -- [x] Does this create, retire, or invalidate an assumption? It advances but does not retire A23-L. -- [x] Does this slice depend on an unvalidated high-impact assumption? It attacks A23-L as a tracer bullet. -- [x] Does this make or reverse a non-trivial design decision? Already reconciled as D49-L; this card implements the first half. -- [x] Does this establish a new seam-level invariant? Already reconciled as I32-L; this card establishes the start/open-prompt part. -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? No. -- [x] Does it cross more than two major seams? Yes — RPC dispatch, workspace/session coordination, Pi JSONL transcript projection, and discovery metadata; use full card. -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? No — `rpc.discover` just landed, but the lifecycle seam is new enough to keep full weight. -- [ ] Can you not name the containing seam or current rationale from the live docs? No. diff --git a/memory/PLAN.md b/memory/PLAN.md index 2d4a2eae..d04c6e2f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry and deterministic `session.startElicitation` tracer bullet have landed: `rpc.discover` lists the current Brunch methods, and an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange without raw Pi RPC or a parallel prompt store. Next scope the response side of the public RPC elicitation parity sequence inside this same FE-744 frontier: (1) expose `session.pendingExchange` and `elicitation.respond` over Brunch JSON-RPC with polling semantics, preserving the open prompt projection from Pi JSONL; (2) let the deterministic elicitor advance through at least ten structured exchanges; (3) build the ten-turn agent-as-user parity proof and projection oracle; (4) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/src/rpc.test.ts b/src/rpc.test.ts index d0d9bb93..cde1386b 100644 --- a/src/rpc.test.ts +++ b/src/rpc.test.ts @@ -203,6 +203,7 @@ describe("JSON-RPC handlers", () => { }> }).methods expect(methods.map((entry) => entry.method).sort()).toEqual([ + "elicitation.respond", "rpc.discover", "session.elicitationExchanges", "session.pendingExchange", @@ -711,6 +712,225 @@ describe("JSON-RPC handlers", () => { }) }) + it("responds to the deterministic listed-option exchange and closes the projection", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Respond spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + + const start = await handlers.handle({ + jsonrpc: "2.0", + id: 53, + method: "session.startElicitation", + }) + const exchangeId = (start as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId + + const response = await handlers.handle({ + jsonrpc: "2.0", + id: 54, + method: "elicitation.respond", + params: { + exchangeId, + answer: { optionId: "new-from-scratch" }, + note: "This is a greenfield product.", + }, + }) + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 54, + result: { + status: "accepted", + exchangeId, + answer: { + optionId: "new-from-scratch", + label: "Yes — this is new from scratch", + }, + note: "This is a greenfield product.", + }, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 55, + method: "session.pendingExchange", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 55, + result: { status: "idle", exchange: null }, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 56, + method: "session.elicitationExchanges", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 56, + result: { + status: "ready", + exchanges: [ + { + promptEntryIds: [expect.any(String)], + responseEntryIds: [expect.any(String), expect.any(String)], + }, + ], + }, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 57, + method: "session.transcriptDisplay", + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 57, + result: { + rows: [ + { + role: "prompt", + text: expect.stringContaining("new product or feature"), + }, + { + role: "user", + text: expect.stringContaining("Yes — this is new from scratch"), + }, + ], + }, + }) + + const sessionText = await readFile(workspace.session.file, "utf8") + expect(sessionText).toContain("brunch.elicitation_response") + expect(sessionText).toContain("This is a greenfield product.") + }) + + it("rejects mismatched elicitation response ids without appending transcript entries", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-bad-id-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Bad id spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + await handlers.handle({ + jsonrpc: "2.0", + id: 58, + method: "session.startElicitation", + }) + const before = await readFile(workspace.session.file, "utf8") + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 59, + method: "elicitation.respond", + params: { + exchangeId: "not-current", + answer: { optionId: "new-from-scratch" }, + }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 59, + error: { + code: -32006, + message: "Pending elicitation exchange does not match request", + }, + }) + await expect(readFile(workspace.session.file, "utf8")).resolves.toBe(before) + }) + + it("rejects unknown elicitation option ids without appending transcript entries", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-bad-option-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Bad option spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + const start = await handlers.handle({ + jsonrpc: "2.0", + id: 60, + method: "session.startElicitation", + }) + const exchangeId = (start as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId + const before = await readFile(workspace.session.file, "utf8") + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 61, + method: "elicitation.respond", + params: { exchangeId, answer: { optionId: "missing-option" } }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 61, + error: { code: -32007, message: "Invalid elicitation option" }, + }) + await expect(readFile(workspace.session.file, "utf8")).resolves.toBe(before) + }) + + it("guards duplicate elicitation responses without appending transcript entries", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-duplicate-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Duplicate spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + const start = await handlers.handle({ + jsonrpc: "2.0", + id: 62, + method: "session.startElicitation", + }) + const exchangeId = (start as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId + await handlers.handle({ + jsonrpc: "2.0", + id: 63, + method: "elicitation.respond", + params: { exchangeId, answer: { optionId: "existing-codebase" } }, + }) + const before = await readFile(workspace.session.file, "utf8") + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 64, + method: "elicitation.respond", + params: { exchangeId, answer: { optionId: "existing-codebase" } }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 64, + error: { code: -32008, message: "No pending elicitation exchange" }, + }) + await expect(readFile(workspace.session.file, "utf8")).resolves.toBe(before) + }) + it("resumes an open deterministic elicitation prompt without duplicating transcript entries", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-resume-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) diff --git a/src/rpc.ts b/src/rpc.ts index 1122ae13..81c77b09 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -116,6 +116,10 @@ export function createRpcHandlers(options: { ) } + if (request.method === "elicitation.respond") { + return handleRespondToElicitation(requestId, request.params, options) + } + if (request.method === "session.elicitationExchanges") { return handleSessionProjection( requestId, @@ -349,6 +353,36 @@ const PendingExchangeResultSchema = Type.Union([ ), ]) +const ElicitationRespondParamsSchema = Type.Object( + { + exchangeId: NonBlankStringSchema, + answer: Type.Object({ optionId: NonBlankStringSchema }, { + additionalProperties: false, + }), + note: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +const ElicitationRespondResultSchema = Type.Object( + { + status: Type.Literal("accepted"), + exchangeId: NonBlankStringSchema, + answer: Type.Object( + { + optionId: NonBlankStringSchema, + label: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + note: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +) + +type ElicitationRespondParams = Static<typeof ElicitationRespondParamsSchema> +type ElicitationRespondResult = Static<typeof ElicitationRespondResultSchema> + type RpcMethodDiscovery = { method: string description: string @@ -470,6 +504,25 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ }, ], }, + { + method: "elicitation.respond", + description: + "Submit a listed-option answer for the selected session's current deterministic pending elicitation exchange.", + paramsSchema: ElicitationRespondParamsSchema, + resultSchema: ElicitationRespondResultSchema, + examples: [ + { + jsonrpc: "2.0", + id: 11, + method: "elicitation.respond", + params: { + exchangeId: "deterministic-grounding-1", + answer: { optionId: "new-from-scratch" }, + note: "This is a greenfield product.", + }, + }, + ], + }, ] type WorkspaceActivationParamsParseResult = { @@ -578,6 +631,103 @@ async function handleStartElicitation( }) } +async function handleRespondToElicitation( + requestId: JsonRpcId, + rawParams: unknown, + options: { + coordinator: DefaultWorkspaceCoordinator + cwd: string + }, +): Promise<JsonRpcResponse> { + if (!Value.Check(ElicitationRespondParamsSchema, rawParams)) { + return createJsonRpcFailure(requestId, -32602, "Invalid params") + } + const params: ElicitationRespondParams = Value.Parse( + ElicitationRespondParamsSchema, + rawParams, + ) + + const state = await options.coordinator.openDefaultWorkspace() + if (state.status !== "ready") { + return createJsonRpcFailure(requestId, -32001, "No selected Brunch session") + } + + const target = await selectedSessionFile(state) + if (!target.ok) { + return createJsonRpcFailure(requestId, target.code, target.message) + } + + let pending: PendingElicitationExchange | null + try { + pending = pendingExchangeFromEnvelope(target.envelope) + } catch (error) { + if (error instanceof NonLinearTranscriptError) { + return createJsonRpcFailure(requestId, -32002, target.nonLinearMessage) + } + throw error + } + + if (!pending) { + return createJsonRpcFailure( + requestId, + -32008, + "No pending elicitation exchange", + ) + } + + if (params.exchangeId !== pending.exchangeId) { + return createJsonRpcFailure( + requestId, + -32006, + "Pending elicitation exchange does not match request", + ) + } + + const selectedOption = pending.options.find( + (option) => option.id === params.answer.optionId, + ) + if (!selectedOption) { + return createJsonRpcFailure(requestId, -32007, "Invalid elicitation option") + } + + const result: ElicitationRespondResult = { + status: "accepted", + exchangeId: pending.exchangeId, + answer: { optionId: selectedOption.id, label: selectedOption.label }, + ...(params.note === undefined ? {} : { note: params.note }), + } + + state.session.manager.appendMessage({ + role: "user", + content: responseDisplayText(result), + timestamp: 0, + }) + state.session.manager.appendCustomEntry("brunch.elicitation_response", { + exchangeId: pending.exchangeId, + lens: pending.lens, + mode: pending.mode, + prompt: pending.prompt, + answer: { + type: "option", + optionId: selectedOption.id, + label: selectedOption.label, + }, + ...(params.note === undefined ? {} : { note: params.note }), + transport: { surface: "brunch-json-rpc" }, + }) + flushSessionEntries(state.session.manager, state.session.file) + + return createJsonRpcSuccess(requestId, result) +} + +function responseDisplayText(response: ElicitationRespondResult): string { + const lines = [`Selected: ${response.answer.label}`] + if (response.note !== undefined && response.note.length > 0) { + lines.push(`Note: ${response.note}`) + } + return lines.join("\n") +} + type PendingElicitationExchange = Static<typeof PendingElicitationExchangeSchema> function firstDeterministicElicitationExchange(): PendingElicitationExchange { From ac61cdfac325b1b635b01b68eaf2ac6b2c19d19b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 15:54:31 +0200 Subject: [PATCH 113/164] Accept chunked startup title input --- src/pi-components/workspace-dialog/component.ts | 11 +++++++---- src/workspace-dialog.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/pi-components/workspace-dialog/component.ts b/src/pi-components/workspace-dialog/component.ts index 4ca2064f..b2107115 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/pi-components/workspace-dialog/component.ts @@ -196,8 +196,9 @@ class WorkspaceDialogComponent implements Component { } return } - if (isPrintableInput(data)) { - this.#title += data + const text = printableInputText(data) + if (text) { + this.#title += text } } @@ -413,6 +414,8 @@ function style( return theme ? theme.fg(color, text) : text } -function isPrintableInput(data: string): boolean { - return data.length === 1 && data >= " " && data !== "\u007f" +function printableInputText(data: string): string { + return Array.from(data) + .filter((char) => char >= " " && char !== "\u007f") + .join("") } diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index e7238b05..30c2c617 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -233,6 +233,22 @@ describe("spec/session picker", () => { ]) }) + it("accepts chunked title input from terminal automation", () => { + const decisions: unknown[] = [] + const component = createWorkspaceDialogComponent({ + inventory: inventory(), + onDecision: (decision) => decisions.push(decision), + }) + + component.handleInput!("\x1B[B") + component.handleInput!("\x1B[B") + component.handleInput!("\r") + component.handleInput!("Gamma") + component.handleInput!("\r") + + expect(decisions).toEqual([{ action: "newSpec", title: "Gamma" }]) + }) + it("backs out one picker stage on escape and cancels from the home stage", () => { const decisions: unknown[] = [] const component = createWorkspaceDialogComponent({ From 13aec58154e745ccb4d155c63214e526b4e72730 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 16:01:34 +0200 Subject: [PATCH 114/164] Expose ask user question in elicit mode --- src/brunch-tui.test.ts | 19 ++++++++++++++++--- src/pi-extensions.ts | 2 ++ src/pi-extensions/operational-mode.test.ts | 17 ++++++++++++++--- src/pi-extensions/operational-mode.ts | 3 ++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index ed6c3eaa..32d75ebf 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -232,6 +232,7 @@ describe("Brunch TUI boot", () => { } }, registerCommand: (_name: string, _options: unknown) => {}, + registerTool: (_tool: unknown) => {}, } as never) for (const handler of sessionStart) await handler({}, ctx) @@ -295,6 +296,7 @@ describe("Brunch TUI boot", () => { "ls", "present_alternatives", "brunch_structured_question", + "ask_user_question", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( "Open the Brunch spec/session picker", @@ -534,6 +536,7 @@ describe("Brunch TUI boot", () => { handlers.set(event, handler) }, registerCommand: (_name: string, _options: unknown) => {}, + registerTool: (_tool: unknown) => {}, } as never) await expect( @@ -683,7 +686,15 @@ describe("Brunch TUI boot", () => { registerBrunchOperationalModePolicy({ registerTool: (tool: { name: string }) => registeredTools.push(tool.name), getAllTools: () => - ["read", "grep", "find", "ls", "bash", "write"].map((name) => ({ + [ + "read", + "grep", + "find", + "ls", + "ask_user_question", + "bash", + "write", + ].map((name) => ({ name, })), setActiveTools: (tools: string[]) => activeTools.push(tools), @@ -694,14 +705,16 @@ describe("Brunch TUI boot", () => { expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) await events.session_start?.({} as never) - expect(activeTools).toEqual([["read", "grep", "find", "ls"]]) + expect(activeTools).toEqual([ + ["read", "grep", "find", "ls", "ask_user_question"], + ]) await expect( Promise.resolve( events.before_agent_start?.({ systemPrompt: "base" } as never), ), ).resolves.toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only read-only tools: read, grep, find, ls.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, ask_user_question.", ), }) await expect( diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index a74934e6..7e77796d 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -11,6 +11,7 @@ import { type GraphMentionSource, } from "./pi-extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" +import registerBrunchStructuredExchange from "./pi-extensions/structured-exchange.js" import { registerBrunchStructuredQuestion } from "./pi-extensions/structured-question.js" import { renderBrunchChrome, @@ -113,6 +114,7 @@ export function createBrunchPiExtensionShell( ) registerBrunchAlternatives(pi) registerBrunchStructuredQuestion(pi) + registerBrunchStructuredExchange(pi) registerBrunchWorkspaceDialog(pi, options) } } diff --git a/src/pi-extensions/operational-mode.test.ts b/src/pi-extensions/operational-mode.test.ts index e8e1ae38..e623b158 100644 --- a/src/pi-extensions/operational-mode.test.ts +++ b/src/pi-extensions/operational-mode.test.ts @@ -118,7 +118,16 @@ describe("Brunch agent runtime-state projection", () => { registerBrunchOperationalModePolicy({ registerTool: (_tool: { name: string }) => {}, getAllTools: () => - ["read", "grep", "find", "ls", "bash", "edit", "write"].map((name) => ({ + [ + "read", + "grep", + "find", + "ls", + "ask_user_question", + "bash", + "edit", + "write", + ].map((name) => ({ name, })), setActiveTools: (tools: string[]) => activeTools.push(tools), @@ -135,7 +144,9 @@ describe("Brunch agent runtime-state projection", () => { } as never), ) - expect(activeTools).toEqual([["read", "grep", "find", "ls"]]) + expect(activeTools).toEqual([ + ["read", "grep", "find", "ls", "ask_user_question"], + ]) expect(promptResult).toMatchObject({ systemPrompt: expect.stringContaining("Operational mode: elicit."), }) @@ -149,7 +160,7 @@ describe("Brunch agent runtime-state projection", () => { }) expect(promptResult).toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only read-only tools: read, grep, find, ls.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, ask_user_question.", ), }) await expect( diff --git a/src/pi-extensions/operational-mode.ts b/src/pi-extensions/operational-mode.ts index 87351356..6c507812 100644 --- a/src/pi-extensions/operational-mode.ts +++ b/src/pi-extensions/operational-mode.ts @@ -25,6 +25,7 @@ const READ_ONLY_TOOLS = [ "find", "ls", "present_alternatives", + "ask_user_question", ] as const type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] @@ -358,7 +359,7 @@ function composeBrunchAgentStatePrompt( ...state.agentRoleDefinition.promptPackIds, ].join(", ")}.\n` + `\n[Brunch tool policy]\n` + - `- Brunch exposes only read-only tools: ${tools}.\n` + + `- Brunch exposes only elicit-safe tools: ${tools}.\n` + `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.` ) From fcd01506953eca17e0652c713dcc614635f8b3cb Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 17:20:39 +0200 Subject: [PATCH 115/164] Move structured exchange into discoverable tui extension --- .pi/extensions/structured-exchange.ts | 1 - memory/PLAN.md | 4 ++-- memory/SPEC.md | 2 +- memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md | 8 ++++---- src/ask-user-question-extension.test.ts | 2 +- src/pi-extensions.ts | 2 +- src/structured-exchange-rpc-proof.ts | 6 ++++-- src/{pi-extensions => }/structured-exchange.test.ts | 2 +- .../.pi/extensions/structured-exchange/index.ts} | 0 9 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 .pi/extensions/structured-exchange.ts rename src/{pi-extensions => }/structured-exchange.test.ts (99%) rename src/{pi-extensions/structured-exchange.ts => tui-client/.pi/extensions/structured-exchange/index.ts} (100%) diff --git a/.pi/extensions/structured-exchange.ts b/.pi/extensions/structured-exchange.ts deleted file mode 100644 index 2ba496e9..00000000 --- a/.pi/extensions/structured-exchange.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../src/pi-extensions/structured-exchange.js" diff --git a/memory/PLAN.md b/memory/PLAN.md index d04c6e2f..ea1f1283 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -217,7 +217,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, and the raw Pi RPC structured-exchange evaluator proof have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, and the discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts` have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests outside `.pi/` directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index e96c455b..57081d4d 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -127,7 +127,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions now live as product modules under flat `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`, with reusable Pi TUI widgets under `src/pi-components/*`; project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Most product extension modules still live under `src/pi-extensions/*`, while the structured-exchange extension now lives at `src/tui-client/.pi/extensions/structured-exchange/index.ts` so it can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under any `.pi/` directory; keep tests outside the discoverable Pi resource tree. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. diff --git a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md index 5795198a..fbf74689 100644 --- a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md +++ b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md @@ -6,7 +6,7 @@ ## Orientation -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange response surface in `src/pi-extensions/structured-exchange.ts` and its transcript replay rendering. +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange response surface in `src/tui-client/.pi/extensions/structured-exchange/index.ts` and its transcript replay rendering. - **Frontier item:** `pi-ui-extension-patterns`; this side mission stays inside the existing FE-744 branch/Linear boundary and must not create a new tracker item. - **Coordination:** do **not** edit `memory/CARDS.md` for this side mission while another builder thread owns the active card queue. This file is a temporary sidecar scope by explicit user request. - **Main open risk:** the single just-in-time editor may feel better than the second note tab, but it may not be feasible with current `ctx.ui.custom()` focus/render constraints or may create ambiguous result payload semantics. @@ -183,7 +183,7 @@ Option-selection structured exchanges use one inline just-in-time editor whose p ### Verification Approach -- **Inner:** `npm run fix` after meaningful edits; targeted `vitest src/pi-extensions/structured-exchange.test.ts src/ask-user-question-extension.test.ts` during the loop. +- **Inner:** `npm run fix` after meaningful edits; targeted `vitest src/structured-exchange.test.ts src/ask-user-question-extension.test.ts` during the loop. - **Middle:** component-driving tests with render snapshots before and after input, proving the real custom component displays the inline editor and produces the expected details payloads. - **Outer:** manual TUI smoke or scripted pty check if available: answer one single-select listed option, one single-select `Other`, one multi-select listed combination, and one multi-select `Other` path; confirm the interaction feels like one surface rather than a second tab. @@ -202,7 +202,7 @@ Option-selection structured exchanges use one inline just-in-time editor whose p ### Build Result -Implemented in `src/pi-extensions/structured-exchange.ts` and covered by `src/pi-extensions/structured-exchange.test.ts`. +Implemented in `src/tui-client/.pi/extensions/structured-exchange/index.ts` and covered by `src/structured-exchange.test.ts`. - Single-select listed options now reveal one inline optional-context editor and submit `OptionAnswer` plus optional `note`. - Single-select `Other` uses the same inline editor as required custom-answer text and submits `OtherAnswer` with empty note. @@ -215,7 +215,7 @@ Verification run: ```sh npm run check -npx vitest --run src/pi-extensions/structured-exchange.test.ts src/ask-user-question-extension.test.ts +npx vitest --run src/structured-exchange.test.ts src/ask-user-question-extension.test.ts npm run test npm run build ``` diff --git a/src/ask-user-question-extension.test.ts b/src/ask-user-question-extension.test.ts index 86cf7af9..21658eaa 100644 --- a/src/ask-user-question-extension.test.ts +++ b/src/ask-user-question-extension.test.ts @@ -1,6 +1,6 @@ import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import askUserQuestion from "./pi-extensions/structured-exchange.js" +import askUserQuestion from "./tui-client/.pi/extensions/structured-exchange/index.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 7e77796d..4ba47186 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -11,7 +11,7 @@ import { type GraphMentionSource, } from "./pi-extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" -import registerBrunchStructuredExchange from "./pi-extensions/structured-exchange.js" +import registerBrunchStructuredExchange from "./tui-client/.pi/extensions/structured-exchange/index.js" import { registerBrunchStructuredQuestion } from "./pi-extensions/structured-question.js" import { renderBrunchChrome, diff --git a/src/structured-exchange-rpc-proof.ts b/src/structured-exchange-rpc-proof.ts index 44c41eaf..5827b6f3 100644 --- a/src/structured-exchange-rpc-proof.ts +++ b/src/structured-exchange-rpc-proof.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import type { AskUserQuestionResultDetails } from "./pi-extensions/structured-exchange.js" +import type { AskUserQuestionResultDetails } from "./tui-client/.pi/extensions/structured-exchange/index.js" interface ProbeMetadata { name: string @@ -142,7 +142,9 @@ export async function runStructuredExchangeRpcProof( async function writeProofExtension(cwd: string): Promise<string> { const extensionPath = join(cwd, "structured-exchange-rpc-proof-extension.ts") - const adapterPath = resolve("src/pi-extensions/structured-exchange.ts") + const adapterPath = resolve( + "src/tui-client/.pi/extensions/structured-exchange/index.ts", + ) const content = ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { diff --git a/src/pi-extensions/structured-exchange.test.ts b/src/structured-exchange.test.ts similarity index 99% rename from src/pi-extensions/structured-exchange.test.ts rename to src/structured-exchange.test.ts index 704c826f..fe989799 100644 --- a/src/pi-extensions/structured-exchange.test.ts +++ b/src/structured-exchange.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import registerStructuredExchange, { buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, -} from "./structured-exchange.js" +} from "./tui-client/.pi/extensions/structured-exchange/index.js" interface ToolTextContent { type: "text" diff --git a/src/pi-extensions/structured-exchange.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts similarity index 100% rename from src/pi-extensions/structured-exchange.ts rename to src/tui-client/.pi/extensions/structured-exchange/index.ts From eec01f9a59f14bd6f6a4c166f5ef50decac576a3 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 17:25:53 +0200 Subject: [PATCH 116/164] Move Pi TUI modules under discoverable client tree --- docs/architecture/pi-ui-extension-patterns.md | 18 +++++----- memory/PLAN.md | 10 +++--- memory/SPEC.md | 26 +++++++------- package.json | 2 +- src/brunch-tui.ts | 4 +-- src/pi-extensions.ts | 32 +++++++++--------- src/structured-question-rpc-proof.ts | 4 ++- .../.pi/components}/cards.ts | 0 .../.pi/components}/workspace-dialog.ts | 0 .../assets/brunch-logo-quad-56x18-240.ansi | 0 .../assets/brunch-logo-quad-56x18.ansi | 0 .../workspace-dialog/assets/brunch.png | Bin .../components}/workspace-dialog/component.ts | 6 ++-- .../.pi/components}/workspace-dialog/index.ts | 0 .../.pi/components}/workspace-dialog/model.ts | 2 +- .../components}/workspace-dialog/preflight.ts | 2 +- .../.pi/extensions}/alternatives.ts | 6 +--- .../extensions}/auto-compaction-anchors.json | 0 .../.pi/extensions}/chrome.ts | 2 +- .../.pi/extensions}/command-policy.ts | 0 .../.pi/extensions}/mention-autocomplete.ts | 0 .../.pi/extensions}/operational-mode.ts | 0 .../.pi/extensions}/session-lifecycle.ts | 0 .../.pi/extensions}/structured-question.ts | 2 +- .../.pi/extensions}/subagents/config.json | 0 .../.pi/extensions}/workspace-dialog.ts | 4 +-- .../chrome.test.ts | 2 +- .../mention-autocomplete.test.ts | 2 +- .../operational-mode.test.ts | 2 +- .../structured-question.test.ts | 2 +- src/workspace-dialog.test.ts | 4 +-- 31 files changed, 65 insertions(+), 67 deletions(-) rename src/{pi-components => tui-client/.pi/components}/cards.ts (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog.ts (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/assets/brunch-logo-quad-56x18.ansi (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/assets/brunch.png (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/component.ts (98%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/index.ts (100%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/model.ts (99%) rename src/{pi-components => tui-client/.pi/components}/workspace-dialog/preflight.ts (97%) rename src/{pi-extensions => tui-client/.pi/extensions}/alternatives.ts (98%) rename src/{pi-extensions => tui-client/.pi/extensions}/auto-compaction-anchors.json (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/chrome.ts (99%) rename src/{pi-extensions => tui-client/.pi/extensions}/command-policy.ts (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/mention-autocomplete.ts (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/operational-mode.ts (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/session-lifecycle.ts (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/structured-question.ts (99%) rename src/{pi-extensions => tui-client/.pi/extensions}/subagents/config.json (100%) rename src/{pi-extensions => tui-client/.pi/extensions}/workspace-dialog.ts (97%) rename src/{pi-extensions => tui-client}/chrome.test.ts (99%) rename src/{pi-extensions => tui-client}/mention-autocomplete.test.ts (98%) rename src/{pi-extensions => tui-client}/operational-mode.test.ts (99%) rename src/{pi-extensions => tui-client}/structured-question.test.ts (99%) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 27611ade..986e8599 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/pi-extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/pi-components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-question RPC oracle:** `npm run test:structured-question-rpc-proof` launches a real Pi RPC subprocess with a minimal Brunch structured-question proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_question.result` payload as the TUI/helper path. @@ -85,7 +85,7 @@ Pi autocomplete persists only the text inserted into the editor. For both file c Brunch `#` mentions must therefore use a stable inserted handle (`#A12`, `#I7`, or a stable node id) as the durable transcript reference. If the agent needs deeper detail, Brunch must teach that convention through `before_agent_start` system-prompt injection and provide a read-only lookup/re-read tool that resolves the handle against the local graph DB. Any structured mention ledger or staleness state is Brunch-owned parsing/indexing work layered after insertion; it is not supplied by Pi autocomplete. -The product `src/pi-extensions/mention-autocomplete.ts` follows this model: it inserts stable graph-code handles from an injectable Brunch mention source, explains via `before_agent_start` that labels/descriptions are UI-only, and leaves deeper detail lookup to future Brunch graph read tools. +The product `src/tui-client/.pi/extensions/mention-autocomplete.ts` follows this model: it inserts stable graph-code handles from an injectable Brunch mention source, explains via `before_agent_start` that labels/descriptions are UI-only, and leaves deeper detail lookup to future Brunch graph read tools. ### Exact slash execution @@ -121,7 +121,7 @@ The same probe emitted corresponding `notify` requests (`cancel switch new`, `ca ## Brunch extension layout and dynamic chrome proof -The Brunch extension entrypoint is intentionally a registration map. `src/pi-extensions.ts` composes flat product-owned modules by Pi surface/responsibility: +The Brunch extension entrypoint is intentionally a registration map. `src/pi-extensions.ts` composes product-owned modules under `src/tui-client/.pi/extensions/*` by Pi surface/responsibility: - `chrome.ts` owns `BrunchChromeState`, reusable formatting helpers, and `renderBrunchChrome()`. - `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. @@ -129,7 +129,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/pi-ext - `workspace-dialog.ts` owns `/brunch`, `ctrl+shift+b`, and the in-session spec/session picker activation adapter. - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. -- `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/pi-components/*`. +- `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/tui-client/.pi/components/*`. `renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: @@ -153,10 +153,10 @@ Observed behavior: Brunch should render the startup/splash logo as TUI chrome, not as a session message, so it does not persist in the transcript/log. For the preferred blocky aesthetic, the selected rendering is a pre-generated Chafa Unicode-symbol asset rather than runtime image rendering: -- Source PNG copied from the legacy Brunch app to `src/pi-components/workspace-dialog/assets/brunch.png`. -- Preferred splash asset: `src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi`. -- Lower-color fallback asset: `src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi`. -- The build copies those assets to `dist/pi-components/workspace-dialog/assets` so runtime code can read them beside the compiled component. +- Source PNG copied from the legacy Brunch app to `src/tui-client/.pi/components/workspace-dialog/assets/brunch.png`. +- Preferred splash asset: `src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi`. +- Lower-color fallback asset: `src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi`. +- The build copies those assets to `dist/tui-client/.pi/components/workspace-dialog/assets` so runtime code can read them beside the compiled component. The selected generator command for the preferred asset is: @@ -168,7 +168,7 @@ chafa -f symbols \ --color-extractor=median \ --bg=black \ --size=56x18 \ - src/pi-components/workspace-dialog/assets/brunch.png > src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi + src/tui-client/.pi/components/workspace-dialog/assets/brunch.png > src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi ``` Runtime should **not** invoke Chafa on startup. The logo should be deterministic, cheap to render, and independent of host-installed CLI tools. Chafa is therefore a maintainer/dev tool at most, not a runtime dependency. Startup chrome should choose `brunch-logo-quad-56x18.ansi` when truecolor is available, otherwise `brunch-logo-quad-56x18-240.ansi`; for very limited terminals, a plain `brunch` wordmark is sufficient rather than carrying 16-color or 8-color assets. diff --git a/memory/PLAN.md b/memory/PLAN.md index ea1f1283..673c8c3b 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -82,9 +82,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** unassigned - **Kind:** structural hardening - **Status:** not-started -- **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into flat product modules under `src/pi-extensions/*.ts` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. +- **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into explicit Brunch-owned product modules under `src/tui-client/.pi/extensions/*` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. - **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, capture/reviewer jobs, and authority gating depend on the embedded harness. -- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load from flat product modules under `src/pi-extensions.ts` / `src/pi-extensions/*` and reusable TUI components under `src/pi-components/*`, with no project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load explicitly from `src/pi-extensions.ts` / `src/tui-client/.pi/extensions/*` and reusable TUI components under `src/tui-client/.pi/components/*`, with no root project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. - **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L @@ -128,7 +128,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** optional enhancement - **Status:** deferred (lands when `agent-and-graph-integration` is far enough along to benefit; never a blocker for M0–M9) - **Objective:** Register a single `subagent` Pi tool per D44-L so the main agent can (a) fan out blocking data-gathering calls (scout / researcher / graph-reader) in parallel to ground proposals, then (b) fan out parallel `proposer` invocations to generate diverse candidate variants — the subagent realization of `ln-design`'s "design it twice" pattern and `ln-oracles`'s parallel-fan-out — and finally compose `brunch.review_set_proposal` entries from those variants via the D31-L meta-rubric. Subagent results return as tool content; no `CommandExecutor` access; no Brunch RPC access; isolated `pi --no-session --no-skills --no-extensions` subprocesses inheriting Brunch Pi Profile sealing. -- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/pi-extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one batch-proposal fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. +- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/tui-client/.pi/extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one batch-proposal fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. - **Verification:** Inner — `subagent` tool argv-shape tests; TypeBox schema validation of agent frontmatter and `config.json`; per-starter-agent tool-allowlist conformance (proposer must have an empty tool set). Middle — isolation audit (no ambient `.pi/` resources reachable; parent `CommandExecutor` / Brunch RPC handlers absent from subprocess environment); subprocess streaming / abort propagation tests; parallel-fan-out independence test (two `proposer` invocations with distinct framings produce structurally distinct outputs). Outer — proposal-generation fixture invokes scout/researcher/graph-reader to ground, then parallel `proposer` variants, and surfaces the composed review-set proposal with grounding-bundle coverage and `epistemic_status` consistent with the gathered evidence; meta-rubric application visible in the comparison rendering. - **Cross-cutting obligations:** Preserve the single-authority mutation rule (`CommandExecutor` only — subagents never bypass it) and the sealed Pi Profile (no ambient `.pi/` leakage through the subprocess boundary). Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. Worker-style write-capable subagents are deferred until an execute operational mode exists. - **Traceability:** R20 / D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L, D44-L / I2-L, I11-L, I24-L, I29-L @@ -180,7 +180,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** not-started - **Objective:** Compaction preserves graph, coherence, and continuity anchors per D43-L; interest sets can widen beyond direct reads when needed; conflict signaling remains intelligible at long horizons. - **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; the auto-compaction extension renders the configured preserved-anchor set byte-stable so active spec, in-flight side-task / deferred-auditor-job / reviewer-job bookkeeping, latest `brunch.agent_runtime_state`, latest `brunch.establishment_offer`, latest `brunch.lens_switch`, unresolved staleness hints, and active review-set leaves remain intelligible after compaction; ambient-affordance chrome continues to render the current offer; auto-compaction failure falls through to Pi default compaction rather than dropping anchors silently. -- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/deferred-auditor/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. +- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/deferred-auditor/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. - **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/deferred-auditor/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. The auto-compaction extension is the canonical owner of `session_before_compact`; product code paths that touch compaction must compose with it rather than register a parallel hook. - **Traceability:** R15 / D6-L, D15-L, D43-L / I12-L, I28-L - **Design docs:** [prd.md §Continuity, Divergence, and Coherence](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests outside `.pi/` directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/pi-extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests outside `.pi/` directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 57081d4d..f68a7072 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -127,9 +127,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Most product extension modules still live under `src/pi-extensions/*`, while the structured-exchange extension now lives at `src/tui-client/.pi/extensions/structured-exchange/index.ts` so it can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under any `.pi/` directory; keep tests outside the discoverable Pi resource tree. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/pi-extensions/brunch/`. -- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/pi-extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. -- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/pi-extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under any `.pi/` directory; keep tests outside the discoverable Pi resource tree. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. +- **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. #### Data model & vocabulary @@ -205,11 +205,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. - **D29-L — Reviewer is an async advisory role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. -- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a TypeBox schema per D41-L when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. +- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a TypeBox schema per D41-L when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. #### Schema & validation -- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/pi-extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, capture/reviewer/deferred-auditor result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. +- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/tui-client/.pi/extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, capture/reviewer/deferred-auditor result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. #### Interaction & UI shape @@ -229,11 +229,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. - **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. - **D47-L — Structured-question `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-question payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. -- **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/pi-extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/pi-extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: +- **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/tui-client/.pi/extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. This division mirrors the batch-proposal flow in D26-L: `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, and `propose-oracle-ensembles` are the natural lenses that delegate to fan-out `proposer` invocations; `project-requirements-from-upstream` may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. -- **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/pi-components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. +- **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/state.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/state.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/tui-client/.pi/components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/state.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. - **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. ### Critical Invariants @@ -267,7 +267,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | -| I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | +| I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-question preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | @@ -401,10 +401,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Side task** | Main-agent-invoked, non-blocking work item tracked by the Brunch `SideTaskRegistry`. The main agent fires it and does not await a return value; the only path it influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary via `prepareNextTurn`. Side-task writes route through the `CommandExecutor`. Distinct from Subagent (blocking) and Side chat (user-invoked). | | **Subagent** | Main-agent-invoked, **blocking** Pi tool call (`subagent`) that runs an isolated `pi` subprocess with a per-agent tool allowlist and per-agent model. Has no inherited conversation context, no `CommandExecutor` access, and no Brunch RPC access. Result text returns directly as tool result content. POC starter agents split into **data gatherers** (scout / researcher / graph-reader — read-only context fetchers that ground proposals) and a **variant proposer** (proposer — system-prompt-only; one variant per invocation, fan-out via parallel mode realizes the "design it twice" pattern). | | **Proposer subagent** | The system-prompt-only starter subagent that emits exactly one well-formed candidate-proposal variant per invocation given a grounding bundle plus a batch-proposal lens frame. Diversity arises from parallel `tasks: []` invocations with intentionally distinct framings; the main agent assembles outputs into a `brunch.review_set_proposal` via the D31-L meta-rubric. Realizes the "design it twice" / parallel-fan-out pattern from `ln-design` and `ln-oracles` skills in subagent form. | -| **Subagent registry** | The set of registered subagent definitions loaded from `src/pi-extensions/subagents/agents/*.md` at extension activation. Brunch-owned only for the POC; cross-extension agent registration is deferred. | +| **Subagent registry** | The set of registered subagent definitions loaded from `src/tui-client/.pi/extensions/subagents/agents/*.md` at extension activation. Brunch-owned only for the POC; cross-extension agent registration is deferred. | | **Subagent agent definition** | A markdown file with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. The frontmatter is the registry contract; the body is the subagent's standing instructions. | -| **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/pi-extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active runtime bundle; falls through to Pi default compaction on auth/empty-output/unexpected errors. | -| **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | +| **Auto-compaction extension** | The Brunch-owned `session_before_compact` extension (`src/tui-client/.pi/extensions/auto-compaction.ts`) that renders the preserved anchor set as a deterministic markdown header and prepends it to an LLM-generated narrative summary. Resolves its summarization model through the active runtime bundle; falls through to Pi default compaction on auth/empty-output/unexpected errors. | +| **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | | **Anchor contract** | The data inside the preserved-anchor JSON config — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | | **World update** | `worldUpdate` custom message synthesised in `prepareNextTurn` summarising relevant graph changes since the session's `lastSeenLsn`. | | **Mention ledger** | Per-session `(entity_id, snapshotted_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | @@ -537,8 +537,8 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | -| I28-L | Inner — TypeBox schema validation of [src/pi-extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/pi-extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | -| I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/pi-extensions/subagents/agents/*.md` frontmatter and `src/pi-extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | +| I28-L | Inner — TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | +| I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/tui-client/.pi/extensions/subagents/agents/*.md` frontmatter and `src/tui-client/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | | I32-L | FE-744 public-RPC elicitation parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic ten-turn agent-as-user run over Brunch JSON-RPC only, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | diff --git a/package.json b/package.json index 82f440e4..0e8491f7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "scripts": { "dev": "tsx src/brunch.ts", "build": "tsc -p tsconfig.build.json && npm run build:pi-assets && npm run build:web", - "build:pi-assets": "mkdir -p dist/pi-components/workspace-dialog && cp -R src/pi-components/workspace-dialog/assets dist/pi-components/workspace-dialog/", + "build:pi-assets": "mkdir -p dist/tui-client/.pi/components/workspace-dialog && cp -R src/tui-client/.pi/components/workspace-dialog/assets dist/tui-client/.pi/components/workspace-dialog/", "build:web": "vite build", "test": "vitest --run", "test:structured-question-rpc-proof": "vitest --run src/structured-question-rpc-proof.test.ts", diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index bf2be7c5..3bde51cb 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -23,7 +23,7 @@ import { chromeStateForWorkspace, createBrunchPiExtensionShell, } from "./pi-extensions.js" -import { runWorkspaceDialogPreflight } from "./pi-components/workspace-dialog.js" +import { runWorkspaceDialogPreflight } from "./tui-client/.pi/components/workspace-dialog.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, chromeStateForWorkspace, @@ -36,7 +36,7 @@ export { type BrunchChromeState, type BrunchChromeWorkerStatus, } from "./pi-extensions.js" -export { runWorkspaceDialogPreflight } from "./pi-components/workspace-dialog.js" +export { runWorkspaceDialogPreflight } from "./tui-client/.pi/components/workspace-dialog.js" export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index 4ba47186..d9581dd1 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -3,37 +3,37 @@ import { type ExtensionFactory, } from "@earendil-works/pi-coding-agent" -import { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" -import { registerBrunchBranchPolicyHandlers } from "./pi-extensions/command-policy.js" +import { registerBrunchAlternatives } from "./tui-client/.pi/extensions/alternatives.js" +import { registerBrunchBranchPolicyHandlers } from "./tui-client/.pi/extensions/command-policy.js" import { FIXTURE_GRAPH_MENTION_SOURCE, registerBrunchMentionAutocomplete, type GraphMentionSource, -} from "./pi-extensions/mention-autocomplete.js" -import { registerBrunchOperationalModePolicy } from "./pi-extensions/operational-mode.js" +} from "./tui-client/.pi/extensions/mention-autocomplete.js" +import { registerBrunchOperationalModePolicy } from "./tui-client/.pi/extensions/operational-mode.js" import registerBrunchStructuredExchange from "./tui-client/.pi/extensions/structured-exchange/index.js" -import { registerBrunchStructuredQuestion } from "./pi-extensions/structured-question.js" +import { registerBrunchStructuredQuestion } from "./tui-client/.pi/extensions/structured-question.js" import { renderBrunchChrome, type BrunchChromeState, -} from "./pi-extensions/chrome.js" +} from "./tui-client/.pi/extensions/chrome.js" import { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./pi-extensions/session-lifecycle.js" +} from "./tui-client/.pi/extensions/session-lifecycle.js" import { registerBrunchWorkspaceDialog, type BrunchSpecSessionPickerOptions, -} from "./pi-extensions/workspace-dialog.js" +} from "./tui-client/.pi/extensions/workspace-dialog.js" -export { registerBrunchAlternatives } from "./pi-extensions/alternatives.js" -export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./pi-extensions/command-policy.js" +export { registerBrunchAlternatives } from "./tui-client/.pi/extensions/alternatives.js" +export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./tui-client/.pi/extensions/command-policy.js" export { registerBrunchMentionAutocomplete, type GraphMentionCandidate, type GraphMentionSource, -} from "./pi-extensions/mention-autocomplete.js" +} from "./tui-client/.pi/extensions/mention-autocomplete.js" export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -51,7 +51,7 @@ export { type OperationalModeDefinition, type OperationalModeId, type ResolvedBrunchAgentState, -} from "./pi-extensions/operational-mode.js" +} from "./tui-client/.pi/extensions/operational-mode.js" export { chromeStateForWorkspace, projectBrunchChromeFooterLines, @@ -62,12 +62,12 @@ export { type BrunchChromeState, type BrunchChromeUi, type BrunchChromeWorkerStatus, -} from "./pi-extensions/chrome.js" +} from "./tui-client/.pi/extensions/chrome.js" export { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./pi-extensions/session-lifecycle.js" +} from "./tui-client/.pi/extensions/session-lifecycle.js" export { STRUCTURED_QUESTION_TOOL, answerStructuredQuestionWithTui, @@ -77,7 +77,7 @@ export { registerBrunchStructuredQuestion, structuredQuestionResultFromEditor, type StructuredQuestionTuiResponse, -} from "./pi-extensions/structured-question.js" +} from "./tui-client/.pi/extensions/structured-question.js" export { BRUNCH_WORKSPACE_COMMAND, BRUNCH_WORKSPACE_SHORTCUT, @@ -85,7 +85,7 @@ export { runBrunchWorkspaceAction, runBrunchWorkspaceCommand, type BrunchSpecSessionPickerOptions, -} from "./pi-extensions/workspace-dialog.js" +} from "./tui-client/.pi/extensions/workspace-dialog.js" export interface BrunchPiExtensionShellOptions extends BrunchSpecSessionPickerOptions { diff --git a/src/structured-question-rpc-proof.ts b/src/structured-question-rpc-proof.ts index edf403d2..95d09268 100644 --- a/src/structured-question-rpc-proof.ts +++ b/src/structured-question-rpc-proof.ts @@ -114,7 +114,9 @@ export async function runStructuredQuestionRpcProof( async function writeProofExtension(cwd: string): Promise<string> { const extensionPath = join(cwd, "structured-question-rpc-proof-extension.ts") - const adapterPath = resolve("src/pi-extensions/structured-question.ts") + const adapterPath = resolve( + "src/tui-client/.pi/extensions/structured-question.ts", + ) const content = ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { diff --git a/src/pi-components/cards.ts b/src/tui-client/.pi/components/cards.ts similarity index 100% rename from src/pi-components/cards.ts rename to src/tui-client/.pi/components/cards.ts diff --git a/src/pi-components/workspace-dialog.ts b/src/tui-client/.pi/components/workspace-dialog.ts similarity index 100% rename from src/pi-components/workspace-dialog.ts rename to src/tui-client/.pi/components/workspace-dialog.ts diff --git a/src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi b/src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi similarity index 100% rename from src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi rename to src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18-240.ansi diff --git a/src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi b/src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi similarity index 100% rename from src/pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi rename to src/tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi diff --git a/src/pi-components/workspace-dialog/assets/brunch.png b/src/tui-client/.pi/components/workspace-dialog/assets/brunch.png similarity index 100% rename from src/pi-components/workspace-dialog/assets/brunch.png rename to src/tui-client/.pi/components/workspace-dialog/assets/brunch.png diff --git a/src/pi-components/workspace-dialog/component.ts b/src/tui-client/.pi/components/workspace-dialog/component.ts similarity index 98% rename from src/pi-components/workspace-dialog/component.ts rename to src/tui-client/.pi/components/workspace-dialog/component.ts index b2107115..9fb52202 100644 --- a/src/pi-components/workspace-dialog/component.ts +++ b/src/tui-client/.pi/components/workspace-dialog/component.ts @@ -15,7 +15,7 @@ import { import type { WorkspaceLaunchInventory, SpecSessionActivationDecision, -} from "../../workspace-session-coordinator.js" +} from "../../../../workspace-session-coordinator.js" import { buildWorkspaceSelectionView, selectWorkspaceSelectionOption, @@ -29,7 +29,7 @@ const CTRL_C = "\x03" const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") const ASSET_DIR = new URL("./assets/", import.meta.url) -const PACKAGE_JSON_URL = new URL("../../../package.json", import.meta.url) +const PACKAGE_JSON_URL = new URL("../../../../../package.json", import.meta.url) const LOCAL_BUILD_TIME = formatBuildTime(new Date()) // Letterform copied from: cfonts "brunch" -f tiny -c candy @@ -274,7 +274,7 @@ function readPackage(): PackageJson { function getGitSha(): string { try { return execSync("git rev-parse --short=7 HEAD", { - cwd: fileURLToPath(new URL("../../../", import.meta.url)), + cwd: fileURLToPath(new URL("../../../../../", import.meta.url)), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim() diff --git a/src/pi-components/workspace-dialog/index.ts b/src/tui-client/.pi/components/workspace-dialog/index.ts similarity index 100% rename from src/pi-components/workspace-dialog/index.ts rename to src/tui-client/.pi/components/workspace-dialog/index.ts diff --git a/src/pi-components/workspace-dialog/model.ts b/src/tui-client/.pi/components/workspace-dialog/model.ts similarity index 99% rename from src/pi-components/workspace-dialog/model.ts rename to src/tui-client/.pi/components/workspace-dialog/model.ts index 7c022c39..2e19b102 100644 --- a/src/pi-components/workspace-dialog/model.ts +++ b/src/tui-client/.pi/components/workspace-dialog/model.ts @@ -2,7 +2,7 @@ import type { WorkspaceLaunchInventory, WorkspaceLaunchSession, SpecSessionActivationDecision, -} from "../../workspace-session-coordinator.js" +} from "../../../../workspace-session-coordinator.js" export type WorkspaceSelectionStage = { stage: "home" } | { stage: "newSpecTitle" diff --git a/src/pi-components/workspace-dialog/preflight.ts b/src/tui-client/.pi/components/workspace-dialog/preflight.ts similarity index 97% rename from src/pi-components/workspace-dialog/preflight.ts rename to src/tui-client/.pi/components/workspace-dialog/preflight.ts index 01a7cae6..b8e9dd9c 100644 --- a/src/pi-components/workspace-dialog/preflight.ts +++ b/src/tui-client/.pi/components/workspace-dialog/preflight.ts @@ -4,7 +4,7 @@ import { ProcessTerminal, TUI, type Terminal } from "@earendil-works/pi-tui" import type { WorkspaceLaunchInventory, SpecSessionActivationDecision, -} from "../../workspace-session-coordinator.js" +} from "../../../../workspace-session-coordinator.js" import { WORKSPACE_DIALOG_WIDTH, createWorkspaceDialogComponent, diff --git a/src/pi-extensions/alternatives.ts b/src/tui-client/.pi/extensions/alternatives.ts similarity index 98% rename from src/pi-extensions/alternatives.ts rename to src/tui-client/.pi/extensions/alternatives.ts index f0c8cdd3..b4608df2 100644 --- a/src/pi-extensions/alternatives.ts +++ b/src/tui-client/.pi/extensions/alternatives.ts @@ -15,11 +15,7 @@ import { Container, Text } from "@earendil-works/pi-tui" import { StringEnum } from "@earendil-works/pi-ai" import { Type } from "typebox" -import { - CardComponent, - ResponsiveColumns, - chunk, -} from "../pi-components/cards.js" +import { CardComponent, ResponsiveColumns, chunk } from "../components/cards.js" // ── Types & schema ───────────────────────────────────────────────────── const FLAVOR = StringEnum(["accent", "success", "warning", "muted"] as const) diff --git a/src/pi-extensions/auto-compaction-anchors.json b/src/tui-client/.pi/extensions/auto-compaction-anchors.json similarity index 100% rename from src/pi-extensions/auto-compaction-anchors.json rename to src/tui-client/.pi/extensions/auto-compaction-anchors.json diff --git a/src/pi-extensions/chrome.ts b/src/tui-client/.pi/extensions/chrome.ts similarity index 99% rename from src/pi-extensions/chrome.ts rename to src/tui-client/.pi/extensions/chrome.ts index 97731718..955b188e 100644 --- a/src/pi-extensions/chrome.ts +++ b/src/tui-client/.pi/extensions/chrome.ts @@ -4,7 +4,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" import type { WorkspaceSessionChromeState, WorkspaceSessionReadyState, -} from "../workspace-session-coordinator.js" +} from "../../../workspace-session-coordinator.js" export type BrunchChromeStage = "idle" | "streaming" | "observer-review" export type BrunchChromeWorkerStatus = "idle" | "queued" | "running" | "blocked" diff --git a/src/pi-extensions/command-policy.ts b/src/tui-client/.pi/extensions/command-policy.ts similarity index 100% rename from src/pi-extensions/command-policy.ts rename to src/tui-client/.pi/extensions/command-policy.ts diff --git a/src/pi-extensions/mention-autocomplete.ts b/src/tui-client/.pi/extensions/mention-autocomplete.ts similarity index 100% rename from src/pi-extensions/mention-autocomplete.ts rename to src/tui-client/.pi/extensions/mention-autocomplete.ts diff --git a/src/pi-extensions/operational-mode.ts b/src/tui-client/.pi/extensions/operational-mode.ts similarity index 100% rename from src/pi-extensions/operational-mode.ts rename to src/tui-client/.pi/extensions/operational-mode.ts diff --git a/src/pi-extensions/session-lifecycle.ts b/src/tui-client/.pi/extensions/session-lifecycle.ts similarity index 100% rename from src/pi-extensions/session-lifecycle.ts rename to src/tui-client/.pi/extensions/session-lifecycle.ts diff --git a/src/pi-extensions/structured-question.ts b/src/tui-client/.pi/extensions/structured-question.ts similarity index 99% rename from src/pi-extensions/structured-question.ts rename to src/tui-client/.pi/extensions/structured-question.ts index 4648dc03..523fc98f 100644 --- a/src/pi-extensions/structured-question.ts +++ b/src/tui-client/.pi/extensions/structured-question.ts @@ -15,7 +15,7 @@ import { type StructuredQuestionParams, type StructuredQuestionStatus, type StructuredQuestionToolResult, -} from "../structured-question.js" +} from "../../../structured-question.js" export const STRUCTURED_QUESTION_TOOL = "brunch_structured_question" diff --git a/src/pi-extensions/subagents/config.json b/src/tui-client/.pi/extensions/subagents/config.json similarity index 100% rename from src/pi-extensions/subagents/config.json rename to src/tui-client/.pi/extensions/subagents/config.json diff --git a/src/pi-extensions/workspace-dialog.ts b/src/tui-client/.pi/extensions/workspace-dialog.ts similarity index 97% rename from src/pi-extensions/workspace-dialog.ts rename to src/tui-client/.pi/extensions/workspace-dialog.ts index 15e277d4..bff8c2ec 100644 --- a/src/pi-extensions/workspace-dialog.ts +++ b/src/tui-client/.pi/extensions/workspace-dialog.ts @@ -7,11 +7,11 @@ import { type WorkspaceSessionReadyState, type SpecSessionActivationCoordinator, type SpecSessionActivationDecision, -} from "../workspace-session-coordinator.js" +} from "../../../workspace-session-coordinator.js" import { WORKSPACE_DIALOG_WIDTH, createWorkspaceDialogComponent, -} from "../pi-components/workspace-dialog/index.js" +} from "../components/workspace-dialog/index.js" import { chromeStateForWorkspace, renderBrunchChrome } from "./chrome.js" export const BRUNCH_WORKSPACE_COMMAND = "brunch" diff --git a/src/pi-extensions/chrome.test.ts b/src/tui-client/chrome.test.ts similarity index 99% rename from src/pi-extensions/chrome.test.ts rename to src/tui-client/chrome.test.ts index a13da6a7..271b2e17 100644 --- a/src/pi-extensions/chrome.test.ts +++ b/src/tui-client/chrome.test.ts @@ -9,7 +9,7 @@ import { formatChromeWidgetLines, projectBrunchChromeFooterLines, renderBrunchChrome, -} from "./chrome.js" +} from "./.pi/extensions/chrome.js" describe("Brunch chrome projection", () => { it("uses activated session state instead of fabricating unbound", async () => { diff --git a/src/pi-extensions/mention-autocomplete.test.ts b/src/tui-client/mention-autocomplete.test.ts similarity index 98% rename from src/pi-extensions/mention-autocomplete.test.ts rename to src/tui-client/mention-autocomplete.test.ts index 7e1ec0a2..7ebf45df 100644 --- a/src/pi-extensions/mention-autocomplete.test.ts +++ b/src/tui-client/mention-autocomplete.test.ts @@ -6,7 +6,7 @@ import { extractHashPrefix, registerBrunchMentionAutocomplete, type GraphMentionSource, -} from "./mention-autocomplete.js" +} from "./.pi/extensions/mention-autocomplete.js" describe("Brunch mention autocomplete", () => { it("adds graph mention prompt guidance", async () => { diff --git a/src/pi-extensions/operational-mode.test.ts b/src/tui-client/operational-mode.test.ts similarity index 99% rename from src/pi-extensions/operational-mode.test.ts rename to src/tui-client/operational-mode.test.ts index e623b158..09a0e388 100644 --- a/src/pi-extensions/operational-mode.test.ts +++ b/src/tui-client/operational-mode.test.ts @@ -15,7 +15,7 @@ import { registerBrunchOperationalModePolicy, type BrunchAgentState, type BrunchAgentStateEntryData, -} from "./operational-mode.js" +} from "./.pi/extensions/operational-mode.js" function runtimeEntry( state: BrunchAgentState, diff --git a/src/pi-extensions/structured-question.test.ts b/src/tui-client/structured-question.test.ts similarity index 99% rename from src/pi-extensions/structured-question.test.ts rename to src/tui-client/structured-question.test.ts index 7d32501b..16232ada 100644 --- a/src/pi-extensions/structured-question.test.ts +++ b/src/tui-client/structured-question.test.ts @@ -9,7 +9,7 @@ import { registerBrunchStructuredQuestion, structuredQuestionResultFromEditor, type StructuredQuestionTuiResponse, -} from "./structured-question.js" +} from "./.pi/extensions/structured-question.js" import type { StructuredQuestionParams } from "../structured-question.js" interface EditorOptionForTest { diff --git a/src/workspace-dialog.test.ts b/src/workspace-dialog.test.ts index 30c2c617..543b2f83 100644 --- a/src/workspace-dialog.test.ts +++ b/src/workspace-dialog.test.ts @@ -9,7 +9,7 @@ import { createWorkspaceDialogComponent, selectWorkspaceSelectionOption, runWorkspaceDialogPreflight, -} from "./pi-components/workspace-dialog/index.js" +} from "./tui-client/.pi/components/workspace-dialog/index.js" import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" describe("spec/session picker", () => { @@ -307,7 +307,7 @@ describe("spec/session picker", () => { it("keeps logo assets colocated with the private picker component", async () => { const source = await readFile( new URL( - "./pi-components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", + "./tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", import.meta.url, ), "utf8", From c443a7123db1d590086ee10a26573d78ca91235c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 17:29:05 +0200 Subject: [PATCH 117/164] Colocate TUI client tests under pi test directory --- memory/PLAN.md | 2 +- memory/SPEC.md | 2 +- .../__tests__}/ask-user-question-extension.test.ts | 2 +- src/tui-client/{ => .pi/__tests__}/chrome.test.ts | 4 ++-- .../{ => .pi/__tests__}/mention-autocomplete.test.ts | 2 +- .../{ => .pi/__tests__}/operational-mode.test.ts | 2 +- .../.pi/__tests__}/structured-exchange.test.ts | 2 +- .../{ => .pi/__tests__}/structured-question.test.ts | 4 ++-- .../.pi/__tests__}/workspace-dialog.test.ts | 11 +++++++---- 9 files changed, 17 insertions(+), 14 deletions(-) rename src/{ => tui-client/.pi/__tests__}/ask-user-question-extension.test.ts (97%) rename src/tui-client/{ => .pi/__tests__}/chrome.test.ts (98%) rename src/tui-client/{ => .pi/__tests__}/mention-autocomplete.test.ts (98%) rename src/tui-client/{ => .pi/__tests__}/operational-mode.test.ts (99%) rename src/{ => tui-client/.pi/__tests__}/structured-exchange.test.ts (99%) rename src/tui-client/{ => .pi/__tests__}/structured-question.test.ts (98%) rename src/{ => tui-client/.pi/__tests__}/workspace-dialog.test.ts (97%) diff --git a/memory/PLAN.md b/memory/PLAN.md index 673c8c3b..c6e7276f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests outside `.pi/` directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index f68a7072..a739f127 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -127,7 +127,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under any `.pi/` directory; keep tests outside the discoverable Pi resource tree. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. diff --git a/src/ask-user-question-extension.test.ts b/src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts similarity index 97% rename from src/ask-user-question-extension.test.ts rename to src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts index 21658eaa..0920884b 100644 --- a/src/ask-user-question-extension.test.ts +++ b/src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts @@ -1,6 +1,6 @@ import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import askUserQuestion from "./tui-client/.pi/extensions/structured-exchange/index.js" +import askUserQuestion from "../extensions/structured-exchange/index.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, diff --git a/src/tui-client/chrome.test.ts b/src/tui-client/.pi/__tests__/chrome.test.ts similarity index 98% rename from src/tui-client/chrome.test.ts rename to src/tui-client/.pi/__tests__/chrome.test.ts index 271b2e17..618af69f 100644 --- a/src/tui-client/chrome.test.ts +++ b/src/tui-client/.pi/__tests__/chrome.test.ts @@ -2,14 +2,14 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" import { describe, expect, it } from "vitest" -import type { WorkspaceSessionReadyState } from "../workspace-session-coordinator.js" +import type { WorkspaceSessionReadyState } from "../../../workspace-session-coordinator.js" import { chromeStateForWorkspace, formatBrunchChromeHeaderLines, formatChromeWidgetLines, projectBrunchChromeFooterLines, renderBrunchChrome, -} from "./.pi/extensions/chrome.js" +} from "../extensions/chrome.js" describe("Brunch chrome projection", () => { it("uses activated session state instead of fabricating unbound", async () => { diff --git a/src/tui-client/mention-autocomplete.test.ts b/src/tui-client/.pi/__tests__/mention-autocomplete.test.ts similarity index 98% rename from src/tui-client/mention-autocomplete.test.ts rename to src/tui-client/.pi/__tests__/mention-autocomplete.test.ts index 7ebf45df..f4f419b9 100644 --- a/src/tui-client/mention-autocomplete.test.ts +++ b/src/tui-client/.pi/__tests__/mention-autocomplete.test.ts @@ -6,7 +6,7 @@ import { extractHashPrefix, registerBrunchMentionAutocomplete, type GraphMentionSource, -} from "./.pi/extensions/mention-autocomplete.js" +} from "../extensions/mention-autocomplete.js" describe("Brunch mention autocomplete", () => { it("adds graph mention prompt guidance", async () => { diff --git a/src/tui-client/operational-mode.test.ts b/src/tui-client/.pi/__tests__/operational-mode.test.ts similarity index 99% rename from src/tui-client/operational-mode.test.ts rename to src/tui-client/.pi/__tests__/operational-mode.test.ts index 09a0e388..7d89dfee 100644 --- a/src/tui-client/operational-mode.test.ts +++ b/src/tui-client/.pi/__tests__/operational-mode.test.ts @@ -15,7 +15,7 @@ import { registerBrunchOperationalModePolicy, type BrunchAgentState, type BrunchAgentStateEntryData, -} from "./.pi/extensions/operational-mode.js" +} from "../extensions/operational-mode.js" function runtimeEntry( state: BrunchAgentState, diff --git a/src/structured-exchange.test.ts b/src/tui-client/.pi/__tests__/structured-exchange.test.ts similarity index 99% rename from src/structured-exchange.test.ts rename to src/tui-client/.pi/__tests__/structured-exchange.test.ts index fe989799..c3381699 100644 --- a/src/structured-exchange.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import registerStructuredExchange, { buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, -} from "./tui-client/.pi/extensions/structured-exchange/index.js" +} from "../extensions/structured-exchange/index.js" interface ToolTextContent { type: "text" diff --git a/src/tui-client/structured-question.test.ts b/src/tui-client/.pi/__tests__/structured-question.test.ts similarity index 98% rename from src/tui-client/structured-question.test.ts rename to src/tui-client/.pi/__tests__/structured-question.test.ts index 16232ada..3216cdf6 100644 --- a/src/tui-client/structured-question.test.ts +++ b/src/tui-client/.pi/__tests__/structured-question.test.ts @@ -9,8 +9,8 @@ import { registerBrunchStructuredQuestion, structuredQuestionResultFromEditor, type StructuredQuestionTuiResponse, -} from "./.pi/extensions/structured-question.js" -import type { StructuredQuestionParams } from "../structured-question.js" +} from "../extensions/structured-question.js" +import type { StructuredQuestionParams } from "../../../structured-question.js" interface EditorOptionForTest { id: string diff --git a/src/workspace-dialog.test.ts b/src/tui-client/.pi/__tests__/workspace-dialog.test.ts similarity index 97% rename from src/workspace-dialog.test.ts rename to src/tui-client/.pi/__tests__/workspace-dialog.test.ts index 543b2f83..47ce3227 100644 --- a/src/workspace-dialog.test.ts +++ b/src/tui-client/.pi/__tests__/workspace-dialog.test.ts @@ -9,8 +9,8 @@ import { createWorkspaceDialogComponent, selectWorkspaceSelectionOption, runWorkspaceDialogPreflight, -} from "./tui-client/.pi/components/workspace-dialog/index.js" -import type { WorkspaceLaunchInventory } from "./workspace-session-coordinator.js" +} from "../components/workspace-dialog/index.js" +import type { WorkspaceLaunchInventory } from "../../../workspace-session-coordinator.js" describe("spec/session picker", () => { it("builds a hierarchical spec/session selection home without per-spec top-level actions", () => { @@ -307,7 +307,7 @@ describe("spec/session picker", () => { it("keeps logo assets colocated with the private picker component", async () => { const source = await readFile( new URL( - "./tui-client/.pi/components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", + "../components/workspace-dialog/assets/brunch-logo-quad-56x18.ansi", import.meta.url, ), "utf8", @@ -318,7 +318,10 @@ describe("spec/session picker", () => { it("declares pi-tui as a direct dependency", async () => { const manifest = JSON.parse( - await readFile(new URL("../package.json", import.meta.url), "utf8"), + await readFile( + new URL("../../../../package.json", import.meta.url), + "utf8", + ), ) as { dependencies?: Record<string, string> } expect(manifest.dependencies).toHaveProperty("@earendil-works/pi-tui") From 44ad6b132e0c0f9c0f3d71559d76c9392021f8cf Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 18:13:30 +0200 Subject: [PATCH 118/164] Consolidate structured exchange tool --- docs/architecture/fixture-strategy.md | 4 +- docs/architecture/pi-seam-extensions.md | 6 +- ...-ui-extension-patterns-provisional-plan.md | 14 +- docs/architecture/pi-ui-extension-patterns.md | 12 +- memory/PLAN.md | 10 +- memory/SPEC.md | 26 +- memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md | 4 +- package.json | 1 - src/brunch-tui.test.ts | 10 +- src/elicitation-exchange.test.ts | 92 ++--- src/elicitation-exchange.ts | 10 +- src/pi-extensions.ts | 12 - src/structured-exchange-rpc-proof.ts | 4 +- src/structured-exchange.ts | 72 ++++ src/structured-question-rpc-proof.test.ts | 48 --- src/structured-question-rpc-proof.ts | 327 ----------------- src/structured-question.test.ts | 258 -------------- src/structured-question.ts | 260 -------------- .../auto-discovered-extensions.test.ts | 29 ++ .../.pi/__tests__/operational-mode.test.ts | 33 +- ... => structured-exchange-extension.test.ts} | 8 +- .../.pi/__tests__/structured-exchange.test.ts | 11 + .../.pi/__tests__/structured-question.test.ts | 319 ----------------- src/tui-client/.pi/extensions/alternatives.ts | 2 + src/tui-client/.pi/extensions/chrome.ts | 7 +- .../.pi/extensions/command-policy.ts | 2 + .../.pi/extensions/mention-autocomplete.ts | 2 + .../.pi/extensions/operational-mode.ts | 37 +- .../.pi/extensions/session-lifecycle.ts | 2 + .../extensions/structured-exchange/index.ts | 93 +++-- .../.pi/extensions/structured-question.ts | 334 ------------------ .../.pi/extensions/workspace-dialog.ts | 12 + 32 files changed, 332 insertions(+), 1729 deletions(-) create mode 100644 src/structured-exchange.ts delete mode 100644 src/structured-question-rpc-proof.test.ts delete mode 100644 src/structured-question-rpc-proof.ts delete mode 100644 src/structured-question.test.ts delete mode 100644 src/structured-question.ts create mode 100644 src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts rename src/tui-client/.pi/__tests__/{ask-user-question-extension.test.ts => structured-exchange-extension.test.ts} (93%) delete mode 100644 src/tui-client/.pi/__tests__/structured-question.test.ts delete mode 100644 src/tui-client/.pi/extensions/structured-question.ts diff --git a/docs/architecture/fixture-strategy.md b/docs/architecture/fixture-strategy.md index aa2eb0f7..d6828cb9 100644 --- a/docs/architecture/fixture-strategy.md +++ b/docs/architecture/fixture-strategy.md @@ -159,7 +159,7 @@ A run for brief #7 that terminates with kernels active but with none of `product The agent-as-user is a thin driver that exercises the JSON-RPC stdio surface end to end. It does three things: 1. Opens a JSON-RPC stdio connection to `brunch --mode rpc`. -2. Subscribes to Brunch's pending structured-interaction stream (structured-question tool calls/results and product-native offer/proposal entries per [pi-seam-extensions §4](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#4-assistant--and-system-offer-first-structured-interaction)). +2. Subscribes to Brunch's pending structured-interaction stream (structured-exchange tool calls/results and product-native offer/proposal entries per [pi-seam-extensions §4](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#4-assistant--and-system-offer-first-structured-interaction)). 3. For each pending interaction, calls an LLM with the brief, the persona dials, and the interaction payload; collects a terminal structured response; posts it back over Brunch RPC (or through the private Pi-RPC extension UI relay when the driver is proving that seam). ### Termination conditions @@ -200,7 +200,7 @@ A captured run produces four artefacts under `.brunch-fixtures/<brief-id>/<run-i | File | Contents | | --- | --- | -| `<run-id>.jsonl` | The full pi JSONL session transcript including structured-question tool results and Brunch custom entries (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.elicitor_intent_hint`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.kernel_activation`, `brunch.side_task_result`, `worldUpdate`) | +| `<run-id>.jsonl` | The full pi JSONL session transcript including structured-exchange tool results and Brunch custom entries (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.elicitor_intent_hint`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.kernel_activation`, `brunch.side_task_result`, `worldUpdate`) | | `<run-id>.graph.json` | A snapshot of all spec-workspace graph planes at run termination: nodes, edges, per-entity versions, current graph LSN | | `<run-id>.coherence.json` | Coherence verdict at termination, including per-plane status and any open violations | | `<run-id>.meta.json` | Run metadata: brief id, persona dials, model, timestamps, total turns, total tokens, terminal reason, agent-as-user prompt hash | diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index c6f96e56..8a04b174 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -145,11 +145,11 @@ Every Brunch session should open with a concrete action or answer surface rather ### Brunch-owned work -- A structured-question result details payload carrying enough projection data to stand alone: schema/version, status (`answered | skipped | cancelled | unavailable`), mode, prompt/questions, options, answers, and transport metadata. +- A structured-exchange result details payload carrying enough projection data to stand alone: schema/version, status (`answered | skipped | cancelled | unavailable`), mode, prompt/questions, options, answers, and transport metadata. - A Brunch-owned TUI helper built on Pi custom UI patterns for radio, checkbox, questionnaire, and optional freeform input. - JSON-prefill / validation helpers for RPC editor fallback. This is a compatibility seam over Pi RPC, not a second Brunch product API. - A private Pi RPC adapter that translates `extension_ui_request(editor)` into product-shaped pending elicitation state for Brunch public clients, then translates the product response back into Pi's documented `extension_ui_response`. -- Elicitation-exchange projection that treats terminal structured-question toolResults as response-side entries when their details carry the typed Brunch payload; ordinary toolResults remain prompt-side by default. +- Elicitation-exchange projection that treats terminal structured-exchange toolResults as response-side entries when their details carry the typed Brunch payload; ordinary toolResults remain prompt-side by default. - Brunch custom entry schemas for product-native offers that are not ordinary questions, such as `brunch.establishment_offer`, `brunch.review_set_proposal`, and later review-cycle responses. ### Capture-aware response payload @@ -530,7 +530,7 @@ Concretely, Flue has **no equivalent** for any of: - `prepareNextTurn` injection of `worldUpdate` between turns. - `pi.appendEntry({ deliverAs: "nextTurn" })` for side-chain result delivery. Flue's `session.task()` is awaited inline. -- Custom-message/tool-result transcript types plus renderers for Brunch structured interaction state (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.side_task_result`, and structured-question toolResult details). +- Custom-message/tool-result transcript types plus renderers for Brunch structured interaction state (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.side_task_result`, and structured-exchange toolResult details). - `pi.registerCommand` for `/lens`, `/spec`, `/compact`-style affordances. - `ExtensionUIContext.select | confirm | input | custom` for confirmation-gated writes and overlay UIs. - `pi-tui` primitives, including `SessionSelectorComponent` as a model for `SpecSelectorComponent`. diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md index 7128f6df..02398093 100644 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md @@ -23,7 +23,7 @@ The latest planning decision narrows the first proof away from a Brunch-only `br ## Target seam to prove -### Structured-question result + JSON-editor RPC fallback +### Structured-exchange result + JSON-editor RPC fallback 1. A registered Pi tool asks a structured Brunch question or questionnaire. 2. The assistant tool call is preserved as prompt-side transcript context; it is not the only semantic source for projection. @@ -36,22 +36,22 @@ The latest planning decision narrows the first proof away from a Brunch-only `br 4. In raw Pi RPC mode, complex shapes degrade through `ctx.ui.editor()` with schema-tagged JSON prefill; simple shapes may use Pi-supported `select`, `confirm`, or `input` where sufficient. 5. A Brunch-aware public client can render the pending interaction as a product form and translate the answer back into Pi's documented `extension_ui_response`. 6. The tool returns one terminal result whose `content` is generated from the same details and whose `details` are self-contained: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. -7. Elicitation-exchange projection classifies terminal structured-question toolResults as response-side entries, while ordinary toolResults remain prompt-side unless typed markers say otherwise. +7. Elicitation-exchange projection classifies terminal structured-exchange toolResults as response-side entries, while ordinary toolResults remain prompt-side unless typed markers say otherwise. 8. No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. ## Active slice candidate -**Name:** Structured-question result + JSON-editor RPC fallback +**Name:** Structured-exchange result + JSON-editor RPC fallback **Goal:** Prove that a transcript-native structured question can replace ambient free input in TUI, stay controllable over Pi RPC, and persist a response payload that Brunch can project without rehydrating semantics solely from assistant tool-call arguments. **Likely implementation shape:** -- Define a minimal structured-question result details payload with `schema`, `status`, `mode`, `prompt` or `questions`, `options`, `answers`, and `transport`. +- Define a minimal structured-exchange result details payload with `schema`, `status`, `mode`, `prompt` or `questions`, `options`, `answers`, and `transport`. - Add a Brunch-owned TUI helper modeled on Pi's `question.ts` / `questionnaire.ts` examples. - Add JSON-prefill / validation helpers for RPC editor fallback. - Add a Brunch Pi-RPC relay shim that maps Pi `extension_ui_request(editor)` to public Brunch pending-elicitation events/methods and maps the product answer back to `extension_ui_response`. -- Update elicitation-exchange projection to recognize typed terminal structured-question toolResults as response-side entries. +- Update elicitation-exchange projection to recognize typed terminal structured-exchange toolResults as response-side entries. **Acceptance:** @@ -80,7 +80,7 @@ The latest planning decision narrows the first proof away from a Brunch-only `br ## Open questions -- Which details schema name/version should become canonical for structured-question toolResults? +- Which details schema name/version should become canonical for structured-exchange toolResults? - Does every structured toolResult carry all options, or can simple cases store only selected options while richer projection references a prompt-side entry? Current SPEC posture says self-contained enough for projection, so default to carrying all prompt/question/option data until evidence says it is too heavy. - Should unavailable/no-UI contexts return `status: "unavailable"` instead of an error-shaped content string? - What is the thinnest Brunch method/event family for pending elicitation discovery and response submission: `elicitation.pending/respond`, `agent.ui.*`, or a private relay under `agent.*`? @@ -88,4 +88,4 @@ The latest planning decision narrows the first proof away from a Brunch-only `br ## Retirement rule -Retire this file only after the structured-question / RPC-relay loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. +Retire this file only after the structured-exchange / RPC-relay loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 986e8599..73b1dcb2 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -14,7 +14,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | | Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | | In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | -| Structured-question response loop | partially proven; product relay pending | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests | +| Structured-exchange response loop | partially proven; product relay pending | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests | ## Evidence inventory @@ -24,7 +24,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. -- **Live structured-question RPC oracle:** `npm run test:structured-question-rpc-proof` launches a real Pi RPC subprocess with a minimal Brunch structured-question proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_question.result` payload as the TUI/helper path. +- **Live structured-exchange RPC oracle:** `npm run test -- src/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. ## Command inventory and containment matrix @@ -226,9 +226,9 @@ allowedBuiltInCommands: ["compact", "reload", "quit"] The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. -## Structured-question / RPC-relay gap +## Structured-exchange / RPC-relay gap -The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop: the structured-question helper produces self-contained terminal result details, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, and elicitation-exchange projection classifies terminal structured-question `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gap is the public Brunch product relay: exposing pending Pi extension-UI requests as product-shaped RPC state/events for web/CLI clients, then translating product responses back into Pi's documented `extension_ui_response` messages. +The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop: the structured-exchange helper produces self-contained terminal result details, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gap is the public Brunch product relay: exposing pending Pi extension-UI requests as product-shaped RPC state/events for web/CLI clients, then translating product responses back into Pi's documented `extension_ui_response` messages. Pi source/docs already give strong evidence for the primitive: @@ -244,9 +244,9 @@ The seam Brunch must still prove is the public product relay around that composi | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | | Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | -| Registered structured-question tool seam | Brunch result-builder/schema tests cover self-contained `toolResult.details`; exchange projection now classifies terminal structured-question results as response-side entries. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side. | +| Registered structured-exchange tool seam | Brunch result-builder/schema tests cover self-contained `toolResult.details`; exchange projection now classifies terminal structured-exchange results as response-side entries. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side. | | TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for text, single-select, multi-select, questionnaire, and terminal statuses. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | -| JSON-editor RPC fallback | Brunch helper tests and `npm run test:structured-question-rpc-proof` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | +| JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | | Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | diff --git a/memory/PLAN.md b/memory/PLAN.md index c6e7276f..69cbc40f 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -114,8 +114,8 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** structural - **Status:** not-started - **Objective:** Brunch installs graph tools through pi's extension seams; agent graph operations, elicitor post-exchange capture writes, reviewer-attributed advisory writes, review-set batch acceptances, spec readiness grade/posture updates, and the transcript-native establishment/intent-hint surfaces all route exclusively through the Brunch-owned command layer and shared event substrate; web, TUI, and agent all observe the same changes. -- **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-question preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. -- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-question `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first batch-proposal fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. +- **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-exchange preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. +- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first batch-proposal fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) @@ -217,15 +217,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-question schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, and the discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts` have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, and the discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts` have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured question/questionnaire affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `ask_user_question`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `ask_user_question` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `structured_exchange`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index a739f127..ed8ad7a3 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -73,7 +73,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-question tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -128,7 +128,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. -- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary and extension-local mutable state as authority for agent behavior. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, or modeling read-only posture as a volatile allowlist of every safe tool. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. @@ -218,8 +218,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. - **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured questions and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies causal/positional context, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured question. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries or as ephemeral dialog results detached from transcript truth. -- **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-question tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. -- **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-question toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-question results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. +- **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. +- **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent role, not separate roles or operational modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); roles (`elicitor`, `reviewer`, `reconciler`, and any deferred observer/auditor roles) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Capture, review, and future audit routing may filter on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent role into one vocabulary axis. - **D26-L — Elicitation flows split by capture and commitment mechanism, not by a hard extractive/generative phase boundary.** Single-exchange flows (`step-by-step`, many `disambiguate-via-examples` prompts, and ordinary structured questions) are captured synchronously by the elicitor post-exchange per D18-L. Batch-proposal flows (`propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`) carry structured entity-draft payloads at proposal time and become durable only through review-set approval. Design/oracle lenses may appear during ordinary elicitation before any commitment posture; later commitment posture changes what can be pinned, not what topics may be explored. Depends on: D18-L, D25-L, D45-L. Supersedes: a single uniform "agent asks questions" mental model and the observer-owned extractive vs elicitor-owned generative split as the primary architecture. @@ -228,7 +228,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows is more useful than per-flow improvisation) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. - **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. - **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. -- **D47-L — Structured-question `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-question payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. +- **D47-L — Structured-exchange `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-exchange payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. - **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/tui-client/.pi/extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. @@ -262,14 +262,14 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-question/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, optional note, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 structured-question result schema/builder tests cover self-contained `toolResult.details` and model-readable `content` for text/single/multi/questionnaire plus terminal statuses; TUI adapter tests cover input replacement and builder reuse; JSON-over-editor helper tests cover schema-tagged prefill, validation, and deterministic invalid-response handling; `npm run test:structured-question-rpc-proof` live-proves Pi RPC `extension_ui_request(editor)` / `extension_ui_response(value)` at the adapter layer; elicitation-exchange projection tests cover terminal structured-question tool results as response-side JSONL entries while ordinary tool results remain prompt-side. Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-exchange/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, optional note, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 now has one provider-valid `structured_exchange` Pi tool exported from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover self-contained `toolResult.details` and model-readable `content` for text/single-select/multi-select with terminal `answered`/`cancelled`/`unavailable` statuses, option notes, TUI custom UI, JSON-over-editor fallback, and response-side elicitation-exchange projection. The older provider-invalid `brunch_structured_question`/top-level-union tool path was retired. Questionnaire and `skipped` support remain product requirements rather than current extension coverage; Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | -| I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-question preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | +| I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | | I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | planned (FE-744 `rpc.discover` contract tests, pending/respond lifecycle tests, ten-turn public-RPC elicitation parity proof, and transcript-projection parity oracle) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | @@ -285,7 +285,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **Plan execution & Petri-net compatibility.** Plan-graph compiled alongside an execution petri-net carrying colored tokens that refer back to plan nodes by ID. Currently exploratory; not part of POC scope. - **Context subsystem.** Acknowledged as large-scope; deferred. Brunch may stub minimal structure (e.g. an explicit per-turn `Context` namespace under `prepareNextTurn`) without implementing the full subsystem. - **Capability tiers** (distinct from authority tiers). A future second axis classifying what an agent *can* do versus what it *may* do. Stub deferred. -- **Candidate artefacts.** Pre-graph, agent-proposed nodes/edges awaiting user adjudication. Low-confidence elicitor or future auditor findings may eventually flow here or into reconciliation needs, but the POC keeps ordinary low-confidence implications in structured-question preface/question material until pressure justifies a more generic candidate/work-item substrate. +- **Candidate artefacts.** Pre-graph, agent-proposed nodes/edges awaiting user adjudication. Low-confidence elicitor or future auditor findings may eventually flow here or into reconciliation needs, but the POC keeps ordinary low-confidence implications in structured-exchange preface/question material until pressure justifies a more generic candidate/work-item substrate. ### Adoption patterns from Flue @@ -384,16 +384,16 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Canonical store** | The persistence surface that owns a fact: Pi JSONL for session transcript truth, `.brunch/state.json` for lightweight workspace binding state, SQLite graph/change log for graph truth and coherence substrates. | | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | -| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-question results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-question toolResult details). This is the default post-exchange capture unit. | +| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-exchange results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-exchange toolResult details). This is the default post-exchange capture unit. | | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | | **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `elicitation.respond`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | -| **Structured-question preface** | Plain prose in a structured-question payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | +| **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | -| **Question result details** | The self-contained structured payload in a structured-question/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | -| **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-question tools. It is transcript truth, not an ephemeral UI return value. | +| **Question result details** | The self-contained structured payload in a structured-exchange/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | +| **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-exchange tools. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | | **Deferred observer/auditor job** | Optional durable async work item keyed by session id and elicitation-exchange entry-range ids. If introduced, it audits or backfills exchange analysis and survives process restart, but it is not the primary path for next-turn graph freshness. | @@ -534,7 +534,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | -| I23-L | FE-744 structured-question tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | +| I23-L | FE-744 structured-exchange tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I28-L | Inner — TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | diff --git a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md index fbf74689..e5cd2afc 100644 --- a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md +++ b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md @@ -34,7 +34,7 @@ A throwaway structured-exchange prototype answers whether one inline just-in-tim ```text → local prototype command or narrowly marked prototype branch in structured-exchange tests -→ option-selection state machine mirroring ask_user_question single/multi modes +→ option-selection state machine mirroring structured_exchange single/multi modes → TUI-like render/input loop with picker focus and inline editor focus → payload projection examples for OptionAnswer / OtherAnswer / note → prototype verdict captured in this file or handoff @@ -142,7 +142,7 @@ Option-selection structured exchanges use one inline just-in-time editor whose p ### Boundary Crossings ```text -→ ask_user_question tool execution +→ structured_exchange tool execution → ctx.ui.custom option component state machine → pi-tui Editor rendered inline beneath the picker → OptionAnswer / OtherAnswer / note result details diff --git a/package.json b/package.json index 0e8491f7..b0853b97 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "build:pi-assets": "mkdir -p dist/tui-client/.pi/components/workspace-dialog && cp -R src/tui-client/.pi/components/workspace-dialog/assets dist/tui-client/.pi/components/workspace-dialog/", "build:web": "vite build", "test": "vitest --run", - "test:structured-question-rpc-proof": "vitest --run src/structured-question-rpc-proof.test.ts", "test:watch": "vitest", "lint": "oxlint src .pi/extensions", "lint:fix": "oxlint --fix src .pi/extensions", diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 32d75ebf..5b11c2e4 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -295,8 +295,7 @@ describe("Brunch TUI boot", () => { "find", "ls", "present_alternatives", - "brunch_structured_question", - "ask_user_question", + "structured_exchange", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( "Open the Brunch spec/session picker", @@ -691,8 +690,9 @@ describe("Brunch TUI boot", () => { "grep", "find", "ls", - "ask_user_question", + "structured_exchange", "bash", + "edit", "write", ].map((name) => ({ name, @@ -706,7 +706,7 @@ describe("Brunch TUI boot", () => { expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) await events.session_start?.({} as never) expect(activeTools).toEqual([ - ["read", "grep", "find", "ls", "ask_user_question"], + ["read", "grep", "find", "ls", "structured_exchange"], ]) await expect( Promise.resolve( @@ -714,7 +714,7 @@ describe("Brunch TUI boot", () => { ), ).resolves.toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, ask_user_question.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, structured_exchange.", ), }) await expect( diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index d5b4bc08..bdb32db6 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest" import { SessionManager } from "@earendil-works/pi-coding-agent" import { createSessionBindingData } from "./session-binding.js" -import { buildStructuredQuestionResult } from "./structured-question.js" +import { STRUCTURED_EXCHANGE_RESULT_SCHEMA } from "./structured-exchange.js" import { assistantMessage, userMessage } from "./test-helpers.js" import { loadJsonlTranscriptEntries, @@ -39,47 +39,50 @@ const toolResult = { isError: false, }, } -const structuredQuestionToolResult = { +const structuredExchangeToolResult = { id: "sq1", type: "message", message: { role: "toolResult", - toolCallId: "call-sq-1", - toolName: "brunch_structured_question", - content: [{ type: "text", text: "Domain?: Developer tooling" }], - details: buildStructuredQuestionResult({ - params: { - id: "domain", - mode: "text", - prompt: "Domain?", - }, + toolCallId: "call-exchange-1", + toolName: "structured_exchange", + content: [{ type: "text", text: "User answered: Developer tooling" }], + details: { + schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, + schemaVersion: 1, status: "answered", + question: "Domain?", + mode: "text", answers: [ - { questionId: "domain", mode: "text", value: "Developer tooling" }, + { + type: "text", + label: "Developer tooling", + value: "Developer tooling", + }, ], transport: { surface: "rpc-editor" }, - }).details, + }, isError: false, }, } -const unavailableStructuredQuestionToolResult = { +const unavailableStructuredExchangeToolResult = { id: "sq-unavailable", type: "message", message: { role: "toolResult", - toolCallId: "call-sq-2", - toolName: "brunch_structured_question", - content: [{ type: "text", text: "Structured question unavailable." }], - details: buildStructuredQuestionResult({ - params: { - id: "domain", - mode: "text", - prompt: "Domain?", - }, + toolCallId: "call-exchange-2", + toolName: "structured_exchange", + content: [{ type: "text", text: "Structured exchange unavailable." }], + details: { + schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, + schemaVersion: 1, status: "unavailable", + question: "Domain?", + mode: "text", + answers: [], transport: { surface: "headless" }, - message: "Structured question UI is unavailable.", - }).details, + message: "Structured exchange UI is unavailable.", + }, isError: false, }, } @@ -213,10 +216,10 @@ describe("elicitation exchange projection", () => { }) }) - it("classifies terminal structured-question tool results as response-side entries", () => { + it("classifies terminal structured-exchange tool results as response-side entries", () => { const projection = projectElicitationExchanges([ assistant, - structuredQuestionToolResult, + structuredExchangeToolResult, ]) expect(projection.exchanges[0]?.promptEntryIds).toEqual(["a1"]) @@ -228,10 +231,10 @@ describe("elicitation exchange projection", () => { expect(projection.openPrompt).toBeNull() }) - it("keeps non-terminal structured-question tool results on the prompt side", () => { + it("keeps non-terminal structured-exchange tool results on the prompt side", () => { const projection = projectElicitationExchanges([ assistant, - unavailableStructuredQuestionToolResult, + unavailableStructuredExchangeToolResult, ]) expect(projection.exchanges).toEqual([]) @@ -299,30 +302,33 @@ describe("elicitation exchange projection", () => { ) }) - it("loads and projects terminal structured-question tool results as JSONL responses", async () => { - const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-structured-question-")) + it("loads and projects terminal structured-exchange tool results as JSONL responses", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-pi-structured-exchange-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) appendBinding(manager) manager.appendMessage( - assistantMessage("Please answer the structured question."), + assistantMessage("Please answer the structured exchange."), ) manager.appendMessage({ role: "toolResult", - toolCallId: "call-sq-jsonl", - toolName: "brunch_structured_question", - content: [{ type: "text", text: "Domain?: Developer tooling" }], - details: buildStructuredQuestionResult({ - params: { - id: "domain", - mode: "text", - prompt: "Domain?", - }, + toolCallId: "call-exchange-jsonl", + toolName: "structured_exchange", + content: [{ type: "text", text: "User answered: Developer tooling" }], + details: { + schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, + schemaVersion: 1, status: "answered", + question: "Domain?", + mode: "text", answers: [ - { questionId: "domain", mode: "text", value: "Developer tooling" }, + { + type: "text", + label: "Developer tooling", + value: "Developer tooling", + }, ], transport: { surface: "rpc-editor" }, - }).details, + }, isError: false, timestamp: 0, }) diff --git a/src/elicitation-exchange.ts b/src/elicitation-exchange.ts index 09340a64..f50a939a 100644 --- a/src/elicitation-exchange.ts +++ b/src/elicitation-exchange.ts @@ -12,7 +12,7 @@ import { readBrunchSessionEnvelope, type BrunchSessionEnvelope, } from "./brunch-session-envelope.js" -import { isTerminalStructuredQuestionResultDetails } from "./structured-question.js" +import { isTerminalStructuredExchangeResultDetails } from "./structured-exchange.js" const PROMPT_SIDE_CUSTOM_TYPES = new Set([ "brunch.elicitation_prompt", @@ -227,7 +227,7 @@ function isPromptSideEntry(entry: SessionEntry): boolean { } const role = roleOf(entry) - if (role === "toolResult" && isTerminalStructuredQuestionToolResult(entry)) { + if (role === "toolResult" && isTerminalStructuredExchangeToolResult(entry)) { return false } return role === "assistant" || role === "toolResult" @@ -237,7 +237,7 @@ function isResponseSideEntry(entry: SessionEntry): boolean { if (roleOf(entry) === "user") { return true } - if (isTerminalStructuredQuestionToolResult(entry)) { + if (isTerminalStructuredExchangeToolResult(entry)) { return true } return ( @@ -246,11 +246,11 @@ function isResponseSideEntry(entry: SessionEntry): boolean { ) } -function isTerminalStructuredQuestionToolResult(entry: SessionEntry): boolean { +function isTerminalStructuredExchangeToolResult(entry: SessionEntry): boolean { return ( isMessageEntry(entry) && entry.message.role === "toolResult" && - isTerminalStructuredQuestionResultDetails( + isTerminalStructuredExchangeResultDetails( (entry.message as { details?: unknown }).details, ) ) diff --git a/src/pi-extensions.ts b/src/pi-extensions.ts index d9581dd1..d4fbd5a8 100644 --- a/src/pi-extensions.ts +++ b/src/pi-extensions.ts @@ -12,7 +12,6 @@ import { } from "./tui-client/.pi/extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./tui-client/.pi/extensions/operational-mode.js" import registerBrunchStructuredExchange from "./tui-client/.pi/extensions/structured-exchange/index.js" -import { registerBrunchStructuredQuestion } from "./tui-client/.pi/extensions/structured-question.js" import { renderBrunchChrome, type BrunchChromeState, @@ -68,16 +67,6 @@ export { registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, } from "./tui-client/.pi/extensions/session-lifecycle.js" -export { - STRUCTURED_QUESTION_TOOL, - answerStructuredQuestionWithTui, - buildStructuredQuestionEditorPrefill, - createStructuredQuestionTuiComponent, - parseStructuredQuestionEditorResponse, - registerBrunchStructuredQuestion, - structuredQuestionResultFromEditor, - type StructuredQuestionTuiResponse, -} from "./tui-client/.pi/extensions/structured-question.js" export { BRUNCH_WORKSPACE_COMMAND, BRUNCH_WORKSPACE_SHORTCUT, @@ -113,7 +102,6 @@ export function createBrunchPiExtensionShell( options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE, ) registerBrunchAlternatives(pi) - registerBrunchStructuredQuestion(pi) registerBrunchStructuredExchange(pi) registerBrunchWorkspaceDialog(pi, options) } diff --git a/src/structured-exchange-rpc-proof.ts b/src/structured-exchange-rpc-proof.ts index 5827b6f3..030a4df2 100644 --- a/src/structured-exchange-rpc-proof.ts +++ b/src/structured-exchange-rpc-proof.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import type { AskUserQuestionResultDetails } from "./tui-client/.pi/extensions/structured-exchange/index.js" +import type { StructuredExchangeToolResultDetails } from "./tui-client/.pi/extensions/structured-exchange/index.js" interface ProbeMetadata { name: string @@ -16,7 +16,7 @@ interface FrictionReport { frictions: string[] } -interface TerminalDetails extends AskUserQuestionResultDetails { +interface TerminalDetails extends StructuredExchangeToolResultDetails { probe: ProbeMetadata frictionReport: FrictionReport } diff --git a/src/structured-exchange.ts b/src/structured-exchange.ts new file mode 100644 index 00000000..8330e88e --- /dev/null +++ b/src/structured-exchange.ts @@ -0,0 +1,72 @@ +export const STRUCTURED_EXCHANGE_RESULT_SCHEMA = + "brunch.structured_exchange.result" as const + +export type StructuredExchangeStatus = "answered" | "cancelled" | "unavailable" +export type StructuredExchangeMode = "text" | "single-select" | "multi-select" + +export interface StructuredExchangeOption { + label: string + value: string + description?: string +} + +export type StructuredExchangeAnswer = { + type: "text" + label: string + value: string +} | { + type: "option" + label: string + value: string + index: number +} | { + type: "other" + label: string + value: string +} + +export interface StructuredExchangeResultDetails { + schema: typeof STRUCTURED_EXCHANGE_RESULT_SCHEMA + schemaVersion: 1 + status: StructuredExchangeStatus + question: string + context?: string + mode: StructuredExchangeMode + options?: StructuredExchangeOption[] + answers: StructuredExchangeAnswer[] + rejectedOptions?: StructuredExchangeOption[] + note?: string + transport?: { surface: "tui-custom" | "rpc-editor" | "headless" } + message?: string +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +export function isStructuredExchangeResultDetails( + value: unknown, +): value is StructuredExchangeResultDetails { + if (!isRecord(value)) return false + return ( + value.schema === STRUCTURED_EXCHANGE_RESULT_SCHEMA && + value.schemaVersion === 1 && + (value.status === "answered" || + value.status === "cancelled" || + value.status === "unavailable") && + typeof value.question === "string" && + (value.mode === "text" || + value.mode === "single-select" || + value.mode === "multi-select") && + Array.isArray(value.answers) + ) +} + +export function isTerminalStructuredExchangeResultDetails( + value: unknown, +): value is StructuredExchangeResultDetails { + return ( + isStructuredExchangeResultDetails(value) && + (value.status === "answered" || value.status === "cancelled") + ) +} diff --git a/src/structured-question-rpc-proof.test.ts b/src/structured-question-rpc-proof.test.ts deleted file mode 100644 index 8e3e9be7..00000000 --- a/src/structured-question-rpc-proof.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { runStructuredQuestionRpcProof } from "./structured-question-rpc-proof.js" - -describe("structured-question RPC proof", () => { - it("round-trips an editor fallback through Pi RPC extension UI", async () => { - const proof = await runStructuredQuestionRpcProof() - - expect(proof.editorRequest).toMatchObject({ - type: "extension_ui_request", - method: "editor", - title: "Answer structured question as JSON", - }) - expect(JSON.parse(proof.editorRequest.prefill ?? "{}")).toMatchObject({ - schema: "brunch.structured_question.editor", - schemaVersion: 1, - response: { status: "skipped" }, - params: { - id: "q-rpc-proof", - mode: "text", - prompt: "What did the RPC proof answer?", - }, - }) - expect(proof.details).toMatchObject({ - schema: "brunch.structured_question.result", - schemaVersion: 1, - status: "answered", - mode: "text", - prompt: "What did the RPC proof answer?", - questions: [ - { - id: "q-rpc-proof", - mode: "text", - prompt: "What did the RPC proof answer?", - }, - ], - answers: [ - { - questionId: "q-rpc-proof", - mode: "text", - value: "RPC editor fallback works", - }, - ], - transport: { surface: "rpc-editor" }, - }) - expect(proof.sessionFile).toContain(".brunch/sessions") - }, 20_000) -}) diff --git a/src/structured-question-rpc-proof.ts b/src/structured-question-rpc-proof.ts deleted file mode 100644 index 95d09268..00000000 --- a/src/structured-question-rpc-proof.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" - -import { Value } from "typebox/value" - -import { - StructuredQuestionResultDetailsSchema, - type StructuredQuestionResultDetails, -} from "./structured-question.js" - -export interface StructuredQuestionRpcProofResult { - editorRequest: { - type: "extension_ui_request" - id: string - method: "editor" - title?: string - prefill?: string - } - details: StructuredQuestionResultDetails - sessionFile: string - stdout: unknown[] -} - -interface StructuredQuestionRpcProofOptions { - cwd?: string - timeoutMs?: number -} - -const PROOF_CUSTOM_TYPE = "brunch.structured_question_rpc_proof_result" - -export async function runStructuredQuestionRpcProof( - options: StructuredQuestionRpcProofOptions = {}, -): Promise<StructuredQuestionRpcProofResult> { - const cwd = - options.cwd ?? (await mkdtemp(join(tmpdir(), "brunch-rpc-proof-"))) - const timeoutMs = options.timeoutMs ?? 10_000 - const extensionPath = await writeProofExtension(cwd) - const sessionDir = join(cwd, ".brunch", "sessions") - await mkdir(sessionDir, { recursive: true }) - - const child = spawn( - process.execPath, - [ - piCliPath(), - "--mode", - "rpc", - "--no-extensions", - "--extension", - extensionPath, - "--session-dir", - sessionDir, - ], - { - cwd, - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, NO_COLOR: "1" }, - }, - ) - - const client = new RpcProbeClient(child, timeoutMs) - try { - const promptAccepted = client.waitFor( - (event): event is RpcResponse => - isRpcResponse(event) && event.command === "prompt", - ) - child.stdin.write( - `${JSON.stringify({ id: "proof", type: "prompt", message: "/brunch-structured-question-rpc-proof" })}\n`, - ) - - const editorRequest = await client.waitFor( - (event): event is StructuredQuestionRpcProofResult["editorRequest"] => - isEditorRequest(event), - ) - child.stdin.write( - `${JSON.stringify({ - type: "extension_ui_response", - id: editorRequest.id, - value: answeredEditorPayload(editorRequest.prefill), - })}\n`, - ) - - const promptResponse = await promptAccepted - if (!promptResponse.success) { - throw new Error( - `Proof command failed: ${promptResponse.error ?? "unknown error"}`, - ) - } - - const stateResponse = client.waitFor( - (event): event is RpcResponse<{ sessionFile?: string }> => - isRpcResponse(event) && event.id === "state", - ) - child.stdin.write(`${JSON.stringify({ id: "state", type: "get_state" })}\n`) - const state = await stateResponse - const sessionFile = state.data?.sessionFile - if (!state.success || typeof sessionFile !== "string") { - throw new Error("RPC proof did not expose a persisted session file") - } - - const details = await readProofDetails(sessionFile) - return { - editorRequest, - details, - sessionFile, - stdout: client.events, - } - } finally { - client.dispose() - } -} - -async function writeProofExtension(cwd: string): Promise<string> { - const extensionPath = join(cwd, "structured-question-rpc-proof-extension.ts") - const adapterPath = resolve( - "src/tui-client/.pi/extensions/structured-question.ts", - ) - const content = ` - import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" - import { - buildStructuredQuestionEditorPrefill, - structuredQuestionResultFromEditor, - } from ${JSON.stringify(adapterPath)} - - const params = { - id: "q-rpc-proof", - mode: "text", - prompt: "What did the RPC proof answer?", - required: true, - } as const - - export default function(pi: ExtensionAPI): void { - pi.registerCommand("brunch-structured-question-rpc-proof", { - description: "Exercise Brunch structured-question RPC editor fallback.", - handler: async (_args, ctx) => { - const edited = await ctx.ui.editor( - "Answer structured question as JSON", - buildStructuredQuestionEditorPrefill(params), - ) - const result = structuredQuestionResultFromEditor(params, edited) - ctx.sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "Structured-question RPC proof completed." }], - api: "openai-completions", - provider: "openai", - model: "test-model", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }) - pi.appendEntry(${JSON.stringify(PROOF_CUSTOM_TYPE)}, result.details) - ctx.ui.notify(result.content[0]?.text ?? "Structured question completed.", "info") - }, - }) - } - ` - await writeFile(extensionPath, content, "utf8") - return extensionPath -} - -function answeredEditorPayload(prefill: string | undefined): string { - if (!prefill) throw new Error("RPC editor request did not include a prefill") - const payload = JSON.parse(prefill) as { - response?: unknown - } - payload.response = { - status: "answered", - answers: [ - { - questionId: "q-rpc-proof", - mode: "text", - value: "RPC editor fallback works", - }, - ], - } - return `${JSON.stringify(payload, null, 2)}\n` -} - -async function readProofDetails( - sessionFile: string, -): Promise<StructuredQuestionResultDetails> { - const entries = (await readFile(sessionFile, "utf8")) - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line) as unknown) - const proofEntry = entries.find( - (entry): entry is ProofResultEntry => - typeof entry === "object" && - entry !== null && - (entry as { customType?: unknown }).customType === PROOF_CUSTOM_TYPE && - "data" in entry, - ) - if (!proofEntry) { - throw new Error("RPC proof result entry was not written to the session") - } - return Value.Parse(StructuredQuestionResultDetailsSchema, proofEntry.data) -} - -function piCliPath(): string { - return fileURLToPath( - new URL( - "../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", - import.meta.url, - ), - ) -} - -interface RpcResponse<T = unknown> { - type: "response" - id?: string - command: string - success: boolean - data?: T - error?: string -} - -interface ProofResultEntry { - customType: string - data: unknown -} - -function isRpcResponse(value: unknown): value is RpcResponse { - return ( - typeof value === "object" && - value !== null && - (value as { type?: unknown }).type === "response" && - typeof (value as { command?: unknown }).command === "string" && - typeof (value as { success?: unknown }).success === "boolean" - ) -} - -function isEditorRequest( - value: unknown, -): value is StructuredQuestionRpcProofResult["editorRequest"] { - return ( - typeof value === "object" && - value !== null && - (value as { type?: unknown }).type === "extension_ui_request" && - typeof (value as { id?: unknown }).id === "string" && - (value as { method?: unknown }).method === "editor" - ) -} - -class RpcProbeClient { - readonly events: unknown[] = [] - readonly #child: ChildProcessWithoutNullStreams - readonly #timeoutMs: number - #stdout = "" - #stderr = "" - #waiters: Array<{ - predicate: (event: unknown) => boolean - resolve: (event: unknown) => void - }> = [] - - constructor(child: ChildProcessWithoutNullStreams, timeoutMs: number) { - this.#child = child - this.#timeoutMs = timeoutMs - child.stdout.on("data", (chunk) => this.#ingestStdout(String(chunk))) - child.stderr.on("data", (chunk) => { - this.#stderr += String(chunk) - }) - } - - waitFor<T,>(predicate: (event: unknown) => event is T): Promise<T> { - const existing = this.events.find(predicate) - if (existing) return Promise.resolve(existing) - - return new Promise<T>((resolve, reject) => { - const timeout = setTimeout( - () => { - reject( - new Error( - `Timed out waiting for RPC proof event. Stderr:\n${this.#stderr}`, - ), - ) - }, - this.#timeoutMs, - ) - this.#waiters.push({ - predicate, - resolve: (event) => { - clearTimeout(timeout) - resolve(event as T) - }, - }) - }) - } - - dispose(): void { - this.#child.kill("SIGTERM") - } - - #ingestStdout(chunk: string): void { - this.#stdout += chunk - while (true) { - const newline = this.#stdout.indexOf("\n") - if (newline === -1) return - const line = this.#stdout.slice(0, newline).replace(/\r$/, "") - this.#stdout = this.#stdout.slice(newline + 1) - if (line.trim().length === 0) continue - let event: unknown - try { - event = JSON.parse(line) - } catch { - continue - } - this.events.push(event) - const waiters = this.#waiters.slice() - for (const waiter of waiters) { - if (!waiter.predicate(event)) continue - this.#waiters = this.#waiters.filter( - (candidate) => candidate !== waiter, - ) - waiter.resolve(event) - } - } - } -} diff --git a/src/structured-question.test.ts b/src/structured-question.test.ts deleted file mode 100644 index 3c50157f..00000000 --- a/src/structured-question.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from "vitest" -import { Value } from "typebox/value" - -import { - StructuredQuestionResultDetailsSchema, - buildStructuredQuestionResult, - isTerminalStructuredQuestionResultDetails, - parseStructuredQuestionParams, - structuredQuestionSummary, - type StructuredQuestionAnswer, - type StructuredQuestionParams, -} from "./structured-question.js" - -const transport = { surface: "test" as const, requestId: "req-1" } - -describe("structured-question result model", () => { - it("builds self-contained text answer details and content", () => { - const params: StructuredQuestionParams = { - id: "q-domain", - mode: "text", - prompt: "What domain are we in?", - required: true, - } - - const result = buildStructuredQuestionResult({ - params, - status: "answered", - transport, - answers: [ - { questionId: "q-domain", mode: "text", value: "Local-first devtools" }, - ], - }) - - expect( - Value.Check(StructuredQuestionResultDetailsSchema, result.details), - ).toBe(true) - expect(result.details).toMatchObject({ - schema: "brunch.structured_question.result", - schemaVersion: 1, - status: "answered", - mode: "text", - prompt: "What domain are we in?", - questions: [{ id: "q-domain", mode: "text" }], - answers: [{ questionId: "q-domain", value: "Local-first devtools" }], - transport, - }) - expect(result.content).toEqual([ - { - type: "text", - text: "What domain are we in?: Local-first devtools", - }, - ]) - }) - - it("builds single-select details with options and optional freeform", () => { - const params: StructuredQuestionParams = { - id: "q-risk", - mode: "singleSelect", - prompt: "Which risk dominates?", - options: [ - { id: "ux", label: "UX ambiguity", description: "User cannot choose" }, - { id: "rpc", label: "RPC mismatch" }, - ], - allowFreeform: true, - } - - const result = buildStructuredQuestionResult({ - params, - status: "answered", - transport, - answers: [ - { - questionId: "q-risk", - mode: "singleSelect", - selectedOption: { id: "rpc", label: "RPC mismatch" }, - freeform: "Editor fallback must match TUI semantics.", - }, - ], - }) - - expect(result.details.questions[0]).toMatchObject({ - options: [ - { id: "ux", label: "UX ambiguity" }, - { id: "rpc", label: "RPC mismatch" }, - ], - allowFreeform: true, - }) - expect(result.details.answers[0]).toMatchObject({ - selectedOption: { id: "rpc", label: "RPC mismatch" }, - freeform: "Editor fallback must match TUI semantics.", - }) - expect(result.content[0]?.text).toBe( - "Which risk dominates?: RPC mismatch; freeform: Editor fallback must match TUI semantics.", - ) - }) - - it("builds multi-select details with selected option labels", () => { - const params: StructuredQuestionParams = { - id: "q-oracles", - mode: "multiSelect", - prompt: "Which oracles apply?", - options: [ - { id: "unit", label: "Unit" }, - { id: "rpc", label: "RPC contract" }, - { id: "pty", label: "PTY smoke" }, - ], - } - - const result = buildStructuredQuestionResult({ - params, - status: "answered", - transport, - answers: [ - { - questionId: "q-oracles", - mode: "multiSelect", - selectedOptions: [ - { id: "rpc", label: "RPC contract" }, - { id: "pty", label: "PTY smoke" }, - ], - }, - ], - }) - - expect(result.details.answers[0]).toMatchObject({ - mode: "multiSelect", - selectedOptions: [ - { id: "rpc", label: "RPC contract" }, - { id: "pty", label: "PTY smoke" }, - ], - }) - expect(result.content[0]?.text).toBe( - "Which oracles apply?: RPC contract, PTY smoke", - ) - }) - - it("builds questionnaire details with each prompt, option set, and answer", () => { - const params: StructuredQuestionParams = { - id: "q-grounding", - mode: "questionnaire", - prompt: "Grounding bundle", - questions: [ - { - id: "domain", - mode: "text", - prompt: "Domain?", - }, - { - id: "pressure", - mode: "singleSelect", - prompt: "Main pressure?", - options: [ - { id: "speed", label: "Speed" }, - { id: "trust", label: "Trust" }, - ], - }, - ], - } - const answers: StructuredQuestionAnswer[] = [ - { questionId: "domain", mode: "text", value: "Developer tooling" }, - { - questionId: "pressure", - mode: "singleSelect", - selectedOption: { id: "trust", label: "Trust" }, - }, - ] - - const result = buildStructuredQuestionResult({ - params, - status: "answered", - transport, - answers, - }) - - expect(result.details.mode).toBe("questionnaire") - expect(result.details.questions.map((question) => question.prompt)).toEqual( - ["Domain?", "Main pressure?"], - ) - expect(result.details.answers).toEqual(answers) - expect(result.content[0]?.text).toBe( - "Domain?: Developer tooling\nMain pressure?: Trust", - ) - }) - - it("builds terminal skipped, cancelled, and unavailable details without answers", () => { - const params = parseStructuredQuestionParams({ - id: "q-terminal", - mode: "text", - prompt: "Can you answer?", - }) - - for (const status of ["skipped", "cancelled", "unavailable"] as const) { - const result = buildStructuredQuestionResult({ - params, - status, - transport: { surface: "headless" }, - ...(status === "unavailable" ? { message: "UI unavailable" } : {}), - }) - - expect(result.details).toMatchObject({ - status, - answers: [], - questions: [{ id: "q-terminal", prompt: "Can you answer?" }], - transport: { surface: "headless" }, - }) - expect(structuredQuestionSummary(result.details)).toContain(status) - } - }) - - it("recognizes terminal structured-question result details without matching unrelated tool output", () => { - const params = parseStructuredQuestionParams({ - id: "q-terminal", - mode: "text", - prompt: "Can you answer?", - }) - const answered = buildStructuredQuestionResult({ - params, - status: "answered", - answers: [{ questionId: "q-terminal", mode: "text", value: "Yes" }], - transport, - }) - const skipped = buildStructuredQuestionResult({ - params, - status: "skipped", - transport, - }) - const cancelled = buildStructuredQuestionResult({ - params, - status: "cancelled", - transport, - }) - const unavailable = buildStructuredQuestionResult({ - params, - status: "unavailable", - transport: { surface: "headless" }, - message: "No UI surface is available.", - }) - - expect(isTerminalStructuredQuestionResultDetails(answered.details)).toBe( - true, - ) - expect(isTerminalStructuredQuestionResultDetails(skipped.details)).toBe( - true, - ) - expect(isTerminalStructuredQuestionResultDetails(cancelled.details)).toBe( - true, - ) - expect(isTerminalStructuredQuestionResultDetails(unavailable.details)).toBe( - false, - ) - expect( - isTerminalStructuredQuestionResultDetails({ - status: "answered", - content: [{ type: "text", text: "ordinary tool output" }], - }), - ).toBe(false) - }) -}) diff --git a/src/structured-question.ts b/src/structured-question.ts deleted file mode 100644 index 7a05cfb3..00000000 --- a/src/structured-question.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Type, type Static } from "typebox" -import { Value } from "typebox/value" - -const NonBlankStringSchema = Type.String({ minLength: 1, pattern: "\\S" }) - -export const StructuredQuestionOptionSchema = Type.Object( - { - id: NonBlankStringSchema, - label: NonBlankStringSchema, - description: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -) - -const TextQuestionSchema = Type.Object( - { - id: NonBlankStringSchema, - mode: Type.Literal("text"), - prompt: NonBlankStringSchema, - required: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -) - -const SingleSelectQuestionSchema = Type.Object( - { - id: NonBlankStringSchema, - mode: Type.Literal("singleSelect"), - prompt: NonBlankStringSchema, - options: Type.Array(StructuredQuestionOptionSchema, { minItems: 1 }), - allowFreeform: Type.Optional(Type.Boolean()), - required: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -) - -const MultiSelectQuestionSchema = Type.Object( - { - id: NonBlankStringSchema, - mode: Type.Literal("multiSelect"), - prompt: NonBlankStringSchema, - options: Type.Array(StructuredQuestionOptionSchema, { minItems: 1 }), - allowFreeform: Type.Optional(Type.Boolean()), - required: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -) - -export const StructuredQuestionSchema = Type.Union([ - TextQuestionSchema, - SingleSelectQuestionSchema, - MultiSelectQuestionSchema, -]) - -export const StructuredQuestionParamsSchema = Type.Union([ - TextQuestionSchema, - SingleSelectQuestionSchema, - MultiSelectQuestionSchema, - Type.Object( - { - id: NonBlankStringSchema, - mode: Type.Literal("questionnaire"), - prompt: NonBlankStringSchema, - questions: Type.Array(StructuredQuestionSchema, { minItems: 1 }), - }, - { additionalProperties: false }, - ), -]) - -const SelectedOptionSchema = Type.Object( - { - id: NonBlankStringSchema, - label: NonBlankStringSchema, - }, - { additionalProperties: false }, -) - -const TextAnswerSchema = Type.Object( - { - questionId: NonBlankStringSchema, - mode: Type.Literal("text"), - value: Type.String(), - }, - { additionalProperties: false }, -) - -const SingleSelectAnswerSchema = Type.Object( - { - questionId: NonBlankStringSchema, - mode: Type.Literal("singleSelect"), - selectedOption: Type.Optional(SelectedOptionSchema), - freeform: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -) - -const MultiSelectAnswerSchema = Type.Object( - { - questionId: NonBlankStringSchema, - mode: Type.Literal("multiSelect"), - selectedOptions: Type.Array(SelectedOptionSchema), - freeform: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -) - -export const StructuredQuestionAnswerSchema = Type.Union([ - TextAnswerSchema, - SingleSelectAnswerSchema, - MultiSelectAnswerSchema, -]) - -export const StructuredQuestionTransportSchema = Type.Object( - { - surface: Type.Union([ - Type.Literal("tui-custom"), - Type.Literal("rpc-editor"), - Type.Literal("rpc-dialog"), - Type.Literal("headless"), - Type.Literal("test"), - ]), - requestId: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -) - -export const StructuredQuestionResultDetailsSchema = Type.Object( - { - schema: Type.Literal("brunch.structured_question.result"), - schemaVersion: Type.Literal(1), - status: Type.Union([ - Type.Literal("answered"), - Type.Literal("skipped"), - Type.Literal("cancelled"), - Type.Literal("unavailable"), - ]), - mode: Type.Union([ - Type.Literal("text"), - Type.Literal("singleSelect"), - Type.Literal("multiSelect"), - Type.Literal("questionnaire"), - ]), - prompt: NonBlankStringSchema, - questions: Type.Array(StructuredQuestionSchema, { minItems: 1 }), - answers: Type.Array(StructuredQuestionAnswerSchema), - transport: StructuredQuestionTransportSchema, - message: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -) - -export type StructuredQuestionParams = Static<typeof StructuredQuestionParamsSchema> -export type StructuredQuestion = Static<typeof StructuredQuestionSchema> -export type StructuredQuestionAnswer = Static<typeof StructuredQuestionAnswerSchema> -export type StructuredQuestionTransport = Static<typeof StructuredQuestionTransportSchema> -export type StructuredQuestionResultDetails = Static<typeof StructuredQuestionResultDetailsSchema> -export type StructuredQuestionStatus = StructuredQuestionResultDetails["status"] - -export interface StructuredQuestionContentPart { - type: "text" - text: string -} - -export interface StructuredQuestionToolResult { - content: StructuredQuestionContentPart[] - details: StructuredQuestionResultDetails -} - -export function parseStructuredQuestionParams( - value: unknown, -): StructuredQuestionParams { - return Value.Parse(StructuredQuestionParamsSchema, value) -} - -export function isTerminalStructuredQuestionResultDetails( - value: unknown, -): value is StructuredQuestionResultDetails { - if (!Value.Check(StructuredQuestionResultDetailsSchema, value)) { - return false - } - return ( - value.status === "answered" || - value.status === "skipped" || - value.status === "cancelled" - ) -} - -export function buildStructuredQuestionResult(input: { - params: StructuredQuestionParams - status: StructuredQuestionStatus - answers?: StructuredQuestionAnswer[] - transport: StructuredQuestionTransport - message?: string -}): StructuredQuestionToolResult { - const details = Value.Parse(StructuredQuestionResultDetailsSchema, { - schema: "brunch.structured_question.result", - schemaVersion: 1, - status: input.status, - mode: input.params.mode, - prompt: input.params.prompt, - questions: questionsFromParams(input.params), - answers: input.answers ?? [], - transport: input.transport, - ...(input.message ? { message: input.message } : {}), - }) - return { - content: structuredQuestionContent(details), - details, - } -} - -export function structuredQuestionContent( - details: StructuredQuestionResultDetails, -): StructuredQuestionContentPart[] { - return [{ type: "text", text: structuredQuestionSummary(details) }] -} - -export function structuredQuestionSummary( - details: StructuredQuestionResultDetails, -): string { - if (details.status !== "answered") { - return details.message - ? `Structured question ${details.status}: ${details.message}` - : `Structured question ${details.status}.` - } - - if (details.answers.length === 0) return "Structured question answered." - - const lines = details.answers.map((answer) => { - const question = details.questions.find( - (candidate) => candidate.id === answer.questionId, - ) - const label = question ? question.prompt : answer.questionId - return `${label}: ${formatAnswer(answer)}` - }) - return lines.join("\n") -} - -function questionsFromParams( - params: StructuredQuestionParams, -): StructuredQuestion[] { - if (params.mode === "questionnaire") return params.questions - return [params] -} - -function formatAnswer(answer: StructuredQuestionAnswer): string { - if (answer.mode === "text") return answer.value || "(empty response)" - if (answer.mode === "singleSelect") { - const selected = answer.selectedOption?.label - const freeform = answer.freeform ? `freeform: ${answer.freeform}` : null - return [selected, freeform].filter(Boolean).join("; ") || "(no selection)" - } - const selected = answer.selectedOptions - .map((option) => option.label) - .join(", ") - const freeform = answer.freeform ? `freeform: ${answer.freeform}` : null - return ( - [selected || null, freeform].filter(Boolean).join("; ") || "(no selections)" - ) -} diff --git a/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts b/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts new file mode 100644 index 00000000..92905003 --- /dev/null +++ b/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" + +import alternatives from "../extensions/alternatives.js" +import chrome from "../extensions/chrome.js" +import commandPolicy from "../extensions/command-policy.js" +import mentionAutocomplete from "../extensions/mention-autocomplete.js" +import operationalMode from "../extensions/operational-mode.js" +import sessionLifecycle from "../extensions/session-lifecycle.js" +import structuredExchange from "../extensions/structured-exchange/index.js" +import workspaceDialog from "../extensions/workspace-dialog.js" + +const autoDiscoveredExtensions = { + "alternatives.ts": alternatives, + "chrome.ts": chrome, + "command-policy.ts": commandPolicy, + "mention-autocomplete.ts": mentionAutocomplete, + "operational-mode.ts": operationalMode, + "session-lifecycle.ts": sessionLifecycle, + "structured-exchange/index.ts": structuredExchange, + "workspace-dialog.ts": workspaceDialog, +} + +describe("Pi auto-discovered extensions", () => { + it("export default factory functions for src/tui-client /.pi iteration", () => { + for (const [path, factory] of Object.entries(autoDiscoveredExtensions)) { + expect(factory, path).toEqual(expect.any(Function)) + } + }) +}) diff --git a/src/tui-client/.pi/__tests__/operational-mode.test.ts b/src/tui-client/.pi/__tests__/operational-mode.test.ts index 7d89dfee..2d584542 100644 --- a/src/tui-client/.pi/__tests__/operational-mode.test.ts +++ b/src/tui-client/.pi/__tests__/operational-mode.test.ts @@ -123,7 +123,8 @@ describe("Brunch agent runtime-state projection", () => { "grep", "find", "ls", - "ask_user_question", + "structured_exchange", + "present_alternatives", "bash", "edit", "write", @@ -145,7 +146,14 @@ describe("Brunch agent runtime-state projection", () => { ) expect(activeTools).toEqual([ - ["read", "grep", "find", "ls", "ask_user_question"], + [ + "read", + "grep", + "find", + "ls", + "structured_exchange", + "present_alternatives", + ], ]) expect(promptResult).toMatchObject({ systemPrompt: expect.stringContaining("Operational mode: elicit."), @@ -160,15 +168,24 @@ describe("Brunch agent runtime-state projection", () => { }) expect(promptResult).toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, ask_user_question.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, structured_exchange, present_alternatives.", ), }) + for (const toolName of ["bash", "edit", "write"]) { + await expect( + Promise.resolve(events.tool_call?.({ toolName } as never)), + ).resolves.toMatchObject({ + block: true, + reason: expect.stringContaining( + `Brunch tool policy blocks "${toolName}"`, + ), + }) + } await expect( - Promise.resolve(events.tool_call?.({ toolName: "write" } as never)), - ).resolves.toMatchObject({ - block: true, - reason: expect.stringContaining('Brunch tool policy blocks "write"'), - }) + Promise.resolve( + events.tool_call?.({ toolName: "structured_exchange" } as never), + ), + ).resolves.toBeUndefined() expect(events.user_bash?.({ command: "rm -rf ." } as never)).toMatchObject({ result: { exitCode: 1, diff --git a/src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts similarity index 93% rename from src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts rename to src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts index 0920884b..145540e5 100644 --- a/src/tui-client/.pi/__tests__/ask-user-question-extension.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts @@ -1,6 +1,6 @@ import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import askUserQuestion from "../extensions/structured-exchange/index.js" +import registerStructuredExchange from "../extensions/structured-exchange/index.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, @@ -13,7 +13,7 @@ function stripAnsi(text: string): string { function registerAskUserQuestionTool() { let tool: any - askUserQuestion({ + registerStructuredExchange({ registerTool(definition: any) { tool = definition }, @@ -27,7 +27,7 @@ const theme = { bold: (text: string) => text, } -describe("ask_user_question experimental renderer", () => { +describe("structured_exchange renderer", () => { it("renders prompt markdown before the question without duplicating options", () => { const tool = registerAskUserQuestionTool() @@ -52,7 +52,7 @@ describe("ask_user_question experimental renderer", () => { expect(rendered).toContain("Which path should we take?") expect(rendered).not.toContain("First path") expect(rendered).not.toContain("Second path") - expect(rendered).not.toContain("ask_user_question") + expect(rendered).not.toContain("structured_exchange") }) it("keeps renderCall component reuse type-safe across partial renders", () => { diff --git a/src/tui-client/.pi/__tests__/structured-exchange.test.ts b/src/tui-client/.pi/__tests__/structured-exchange.test.ts index c3381699..8d9953f8 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import registerStructuredExchange, { + STRUCTURED_EXCHANGE_TOOL, buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, } from "../extensions/structured-exchange/index.js" @@ -21,6 +22,7 @@ interface RenderableText { interface RegisteredTool { name: string + parameters: unknown execute: ( toolCallId: string, params: Record<string, unknown>, @@ -109,6 +111,15 @@ function optionParams(multiSelect = false): Record<string, unknown> { } describe("structured exchange inline JIT editor", () => { + it("registers one provider-valid structured exchange question tool", () => { + const tool = registeredTool() + + expect(tool.name).toBe(STRUCTURED_EXCHANGE_TOOL) + expect(tool).toMatchObject({ + parameters: { type: "object" }, + }) + }) + it("renders one inline optional editor after a single-select listed option", async () => { const tool = registeredTool() const renders: string[] = [] diff --git a/src/tui-client/.pi/__tests__/structured-question.test.ts b/src/tui-client/.pi/__tests__/structured-question.test.ts deleted file mode 100644 index 3216cdf6..00000000 --- a/src/tui-client/.pi/__tests__/structured-question.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { - STRUCTURED_QUESTION_TOOL, - answerStructuredQuestionWithTui, - buildStructuredQuestionEditorPrefill, - createStructuredQuestionTuiComponent, - parseStructuredQuestionEditorResponse, - registerBrunchStructuredQuestion, - structuredQuestionResultFromEditor, - type StructuredQuestionTuiResponse, -} from "../extensions/structured-question.js" -import type { StructuredQuestionParams } from "../../../structured-question.js" - -interface EditorOptionForTest { - id: string - label: string -} - -interface EditorPayloadForTest { - schema: string - schemaVersion: number - mode: string - prompt: string - instructions: string[] - params: { options: EditorOptionForTest[] } - response: { status: string } -} - -describe("Brunch structured-question TUI adapter", () => { - it("registers a structured-question tool", () => { - const tools: Array<{ name: string }> = [] - - registerBrunchStructuredQuestion({ - registerTool: (tool: { name: string }) => tools.push({ name: tool.name }), - } as never) - - expect(tools).toEqual([{ name: STRUCTURED_QUESTION_TOOL }]) - }) - - it("returns unavailable details when rich UI is missing", async () => { - const result = await answerStructuredQuestionWithTui(textParams(), { - hasUI: false, - ui: {} as never, - }) - - expect(result.details).toMatchObject({ - status: "unavailable", - transport: { surface: "headless" }, - answers: [], - }) - expect(result.content[0]?.text).toContain("unavailable") - }) - - it("uses ctx.ui.custom and the shared result builder for text answers", async () => { - const result = await answerStructuredQuestionWithTui( - textParams(), - fakeContext({ - status: "answered", - answers: [ - { questionId: "q-text", mode: "text", value: "A typed answer" }, - ], - }), - ) - - expect(result.details).toMatchObject({ - status: "answered", - mode: "text", - answers: [{ value: "A typed answer" }], - transport: { surface: "tui-custom" }, - }) - expect(result.content[0]?.text).toBe("Say something: A typed answer") - }) - - it("uses ctx.ui.custom and the shared result builder for single and multi select answers", async () => { - const single = await answerStructuredQuestionWithTui( - singleParams(), - fakeContext({ - status: "answered", - answers: [ - { - questionId: "q-single", - mode: "singleSelect", - selectedOption: { id: "b", label: "Beta" }, - }, - ], - }), - ) - const multi = await answerStructuredQuestionWithTui( - multiParams(), - fakeContext({ - status: "answered", - answers: [ - { - questionId: "q-multi", - mode: "multiSelect", - selectedOptions: [ - { id: "a", label: "Alpha" }, - { id: "b", label: "Beta" }, - ], - }, - ], - }), - ) - - expect(single.details.answers[0]).toMatchObject({ - selectedOption: { id: "b", label: "Beta" }, - }) - expect(multi.details.answers[0]).toMatchObject({ - selectedOptions: [ - { id: "a", label: "Alpha" }, - { id: "b", label: "Beta" }, - ], - }) - }) - - it("builds schema-tagged JSON editor prefill for raw RPC fallback", () => { - const prefill = JSON.parse( - buildStructuredQuestionEditorPrefill(singleParams()), - ) as EditorPayloadForTest - - expect(prefill).toMatchObject({ - schema: "brunch.structured_question.editor", - schemaVersion: 1, - mode: "singleSelect", - prompt: "Pick one", - response: { status: "skipped" }, - }) - expect(prefill.instructions.join("\n")).toContain("response.answers") - expect(prefill.params.options).toEqual([ - { id: "a", label: "Alpha" }, - { id: "b", label: "Beta" }, - ]) - }) - - it("parses valid edited JSON into the same result-details shape as TUI", async () => { - const edited = JSON.parse( - buildStructuredQuestionEditorPrefill(singleParams()), - ) as Record<string, unknown> - edited.response = { - status: "answered", - answers: [ - { - questionId: "q-single", - mode: "singleSelect", - selectedOption: { id: "b", label: "Beta" }, - }, - ], - } - - const result = structuredQuestionResultFromEditor( - singleParams(), - JSON.stringify(edited), - ) - - expect( - parseStructuredQuestionEditorResponse(JSON.stringify(edited)), - ).toEqual(edited.response) - expect(result.details).toMatchObject({ - status: "answered", - mode: "singleSelect", - answers: [ - { - questionId: "q-single", - selectedOption: { id: "b", label: "Beta" }, - }, - ], - transport: { surface: "rpc-editor" }, - }) - }) - - it("fails malformed or schema-invalid edited JSON deterministically", () => { - expect(parseStructuredQuestionEditorResponse("not json")).toBeNull() - - const invalid = JSON.parse( - buildStructuredQuestionEditorPrefill(textParams()), - ) as Record<string, unknown> - invalid.response = { status: "answered", answers: [{ mode: "text" }] } - - const result = structuredQuestionResultFromEditor( - textParams(), - JSON.stringify(invalid), - ) - - expect(result.details).toMatchObject({ - status: "unavailable", - transport: { surface: "rpc-editor" }, - }) - expect(result.content[0]?.text).toContain("invalid JSON") - }) - - it("uses ctx.ui.editor when custom UI is unavailable", async () => { - const edited = JSON.parse( - buildStructuredQuestionEditorPrefill(textParams()), - ) as Record<string, unknown> - edited.response = { - status: "answered", - answers: [{ questionId: "q-text", mode: "text", value: "RPC answer" }], - } - - const result = await answerStructuredQuestionWithTui(textParams(), { - hasUI: true, - ui: { - editor: async () => JSON.stringify(edited), - } as never, - }) - - expect(result.details).toMatchObject({ - status: "answered", - transport: { surface: "rpc-editor" }, - answers: [{ value: "RPC answer" }], - }) - }) - - it("keeps required empty text answers in the input-replacing component", () => { - const decisions: StructuredQuestionTuiResponse[] = [] - const component = createStructuredQuestionTuiComponent( - textParams(), - (response) => decisions.push(response), - ) - - component.handleInput?.("\r") - expect(decisions).toEqual([]) - - for (const char of "Done") component.handleInput?.(char) - component.handleInput?.("\r") - - expect(decisions).toEqual([ - { - status: "answered", - answers: [{ questionId: "q-text", mode: "text", value: "Done" }], - }, - ]) - }) - - it("supports questionnaire answers through the input-replacing component", () => { - const decisions: StructuredQuestionTuiResponse[] = [] - const component = createStructuredQuestionTuiComponent( - questionnaireParams(), - (response) => decisions.push(response), - ) - - for (const char of "Domain") component.handleInput?.(char) - component.handleInput?.("\r") - component.handleInput?.("\r") - - expect(decisions).toEqual([ - { - status: "answered", - answers: [ - { questionId: "q-domain", mode: "text", value: "Domain" }, - { - questionId: "q-risk", - mode: "singleSelect", - selectedOption: { id: "a", label: "Alpha" }, - }, - ], - }, - ]) - }) -}) - -function fakeContext(response: StructuredQuestionTuiResponse) { - return { - hasUI: true, - ui: { - custom: async () => response, - }, - } as never -} - -function textParams(): StructuredQuestionParams { - return { - id: "q-text", - mode: "text", - prompt: "Say something", - } -} - -function singleParams(): StructuredQuestionParams { - return { - id: "q-single", - mode: "singleSelect", - prompt: "Pick one", - options: [ - { id: "a", label: "Alpha" }, - { id: "b", label: "Beta" }, - ], - } -} - -function multiParams(): StructuredQuestionParams { - return { - id: "q-multi", - mode: "multiSelect", - prompt: "Pick many", - options: [ - { id: "a", label: "Alpha" }, - { id: "b", label: "Beta" }, - ], - } -} - -function questionnaireParams(): StructuredQuestionParams { - return { - id: "q-all", - mode: "questionnaire", - prompt: "Questionnaire", - questions: [ - { id: "q-domain", mode: "text", prompt: "Domain" }, - { - id: "q-risk", - mode: "singleSelect", - prompt: "Risk", - options: [{ id: "a", label: "Alpha" }], - }, - ], - } -} diff --git a/src/tui-client/.pi/extensions/alternatives.ts b/src/tui-client/.pi/extensions/alternatives.ts index b4608df2..406c33cf 100644 --- a/src/tui-client/.pi/extensions/alternatives.ts +++ b/src/tui-client/.pi/extensions/alternatives.ts @@ -205,3 +205,5 @@ export function registerBrunchAlternatives(pi: ExtensionAPI) { }, }) } + +export default registerBrunchAlternatives diff --git a/src/tui-client/.pi/extensions/chrome.ts b/src/tui-client/.pi/extensions/chrome.ts index 955b188e..aa1ad41d 100644 --- a/src/tui-client/.pi/extensions/chrome.ts +++ b/src/tui-client/.pi/extensions/chrome.ts @@ -1,4 +1,7 @@ -import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" +import type { + ExtensionAPI, + ExtensionUIContext, +} from "@earendil-works/pi-coding-agent" import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" import type { @@ -158,6 +161,8 @@ export function renderBrunchChrome( ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } +export default function brunchChrome(_pi: ExtensionAPI): void {} + function formatSpec(chrome: BrunchChromeState): string { return chrome.spec?.title ?? "no spec selected" } diff --git a/src/tui-client/.pi/extensions/command-policy.ts b/src/tui-client/.pi/extensions/command-policy.ts index e2eaff13..4b8649b6 100644 --- a/src/tui-client/.pi/extensions/command-policy.ts +++ b/src/tui-client/.pi/extensions/command-policy.ts @@ -13,3 +13,5 @@ export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { return { cancel: true } }) } + +export default registerBrunchBranchPolicyHandlers diff --git a/src/tui-client/.pi/extensions/mention-autocomplete.ts b/src/tui-client/.pi/extensions/mention-autocomplete.ts index 5c3142a6..69176272 100644 --- a/src/tui-client/.pi/extensions/mention-autocomplete.ts +++ b/src/tui-client/.pi/extensions/mention-autocomplete.ts @@ -153,3 +153,5 @@ function candidateToAutocompleteItem( : {}), } } + +export default registerBrunchMentionAutocomplete diff --git a/src/tui-client/.pi/extensions/operational-mode.ts b/src/tui-client/.pi/extensions/operational-mode.ts index 6c507812..ef8c52ce 100644 --- a/src/tui-client/.pi/extensions/operational-mode.ts +++ b/src/tui-client/.pi/extensions/operational-mode.ts @@ -19,15 +19,8 @@ import { } from "@earendil-works/pi-coding-agent" import { Text } from "@earendil-works/pi-tui" -const READ_ONLY_TOOLS = [ - "read", - "grep", - "find", - "ls", - "present_alternatives", - "ask_user_question", -] as const -type ReadOnlyToolName = typeof READ_ONLY_TOOLS[number] +const ELICIT_BLOCKED_TOOLS = ["bash", "edit", "write"] as const +type ElicitBlockedToolName = typeof ELICIT_BLOCKED_TOOLS[number] export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = "brunch.agent_runtime_state" @@ -299,9 +292,12 @@ function shortenPath(path: string): string { return path } -function availableReadOnlyToolNames(pi: ExtensionAPI): ReadOnlyToolName[] { - const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name)) - return READ_ONLY_TOOLS.filter((name) => allToolNames.has(name)) +function elicitToolNames(pi: ExtensionAPI): string[] { + const blocked = new Set<string>(ELICIT_BLOCKED_TOOLS) + return pi + .getAllTools() + .map((tool) => tool.name) + .filter((name) => !blocked.has(name)) } interface SessionManagerLike { @@ -327,13 +323,19 @@ function supportsBrunchAgentStateEntries( function activeToolNamesForState( pi: ExtensionAPI, state: ResolvedBrunchAgentState, -): ReadOnlyToolName[] { +): string[] { if (state.operationalModeDefinition.toolPolicyId === "elicit-read-only") { - return availableReadOnlyToolNames(pi) + return elicitToolNames(pi) } return [] } +function isBlockedElicitTool( + toolName: string, +): toolName is ElicitBlockedToolName { + return ELICIT_BLOCKED_TOOLS.includes(toolName as ElicitBlockedToolName) +} + function applyBrunchToolPolicy( pi: ExtensionAPI, state: ResolvedBrunchAgentState, @@ -580,14 +582,13 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { }) pi.on("tool_call", async (event) => { - const allowedToolNames = new Set(availableReadOnlyToolNames(pi)) - if (allowedToolNames.has(event.toolName as ReadOnlyToolName)) return + if (!isBlockedElicitTool(event.toolName)) return return { block: true, reason: `Brunch tool policy blocks "${event.toolName}". ` + - `Allowed tools: ${Array.from(allowedToolNames).join(", ") || "none"}.`, + `Blocked tools in elicit mode: ${ELICIT_BLOCKED_TOOLS.join(", ")}.`, } }) @@ -600,3 +601,5 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { }, })) } + +export default registerBrunchOperationalModePolicy diff --git a/src/tui-client/.pi/extensions/session-lifecycle.ts b/src/tui-client/.pi/extensions/session-lifecycle.ts index 4e49f8c1..2b0d6d52 100644 --- a/src/tui-client/.pi/extensions/session-lifecycle.ts +++ b/src/tui-client/.pi/extensions/session-lifecycle.ts @@ -33,3 +33,5 @@ export function registerBrunchSessionBoundaryRefreshHandlers( } }) } + +export default registerBrunchSessionBoundaryRefreshHandlers diff --git a/src/tui-client/.pi/extensions/structured-exchange/index.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts index 20bd0e4e..53f8058b 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/index.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/index.ts @@ -13,11 +13,18 @@ import { } from "@earendil-works/pi-tui" import { Type } from "typebox" -export interface AskOption { - label: string - value: string - description?: string -} +import { + STRUCTURED_EXCHANGE_RESULT_SCHEMA, + type StructuredExchangeAnswer, + type StructuredExchangeMode, + type StructuredExchangeOption, + type StructuredExchangeResultDetails, + type StructuredExchangeStatus, +} from "../../../../structured-exchange.js" + +export const STRUCTURED_EXCHANGE_TOOL = "structured_exchange" + +export type AskOption = StructuredExchangeOption interface DisplayOption extends AskOption { id: string @@ -45,22 +52,11 @@ interface OtherAnswer { value: string } -type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer -type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable" -type AskUserQuestionMode = "text" | "single-select" | "multi-select" +type AskAnswer = StructuredExchangeAnswer +type StructuredExchangeToolStatus = StructuredExchangeStatus +type StructuredExchangeToolMode = StructuredExchangeMode -export interface AskUserQuestionResultDetails { - status: AskUserQuestionStatus - question: string - context?: string - mode: AskUserQuestionMode - options?: AskOption[] - answers: AskAnswer[] - rejectedOptions?: AskOption[] - note?: string - transport?: { surface: "tui-custom" | "rpc-editor" | "headless" } - message?: string -} +export type StructuredExchangeToolResultDetails = StructuredExchangeResultDetails interface OptionAnswerResult { answers: AskAnswer[] @@ -70,7 +66,7 @@ interface OptionAnswerResult { export interface StructuredExchangeEditorPrefillParams { question: string context?: string - mode: Exclude<AskUserQuestionMode, "text"> + mode: Exclude<StructuredExchangeToolMode, "text"> options: AskOption[] } @@ -98,7 +94,7 @@ const OptionSchema = Type.Object({ ), }) -const AskUserQuestionParams = Type.Object({ +const StructuredExchangeParams = Type.Object({ question: Type.String({ description: "The single question to ask the user. Ask exactly one question per tool call.", @@ -281,17 +277,19 @@ class PromptQuestionComponent implements Component { } function buildStructuredResult( - status: AskUserQuestionStatus, + status: StructuredExchangeToolStatus, question: string, - mode: AskUserQuestionMode, + mode: StructuredExchangeToolMode, answers: AskAnswer[], context?: string, message?: string, note?: string, options?: AskOption[], - transport?: AskUserQuestionResultDetails["transport"], -): AskUserQuestionResultDetails { - const result: AskUserQuestionResultDetails = { + transport?: StructuredExchangeToolResultDetails["transport"], +): StructuredExchangeToolResultDetails { + const result: StructuredExchangeToolResultDetails = { + schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, + schemaVersion: 1, status, question, mode, @@ -313,7 +311,7 @@ function buildStructuredResult( function cancelledResult( question: string, - mode: AskUserQuestionMode, + mode: StructuredExchangeToolMode, context?: string, ) { const message = "User cancelled the question" @@ -332,7 +330,7 @@ function cancelledResult( function unavailableResult( question: string, - mode: AskUserQuestionMode, + mode: StructuredExchangeToolMode, message: string, context?: string, ) { @@ -352,11 +350,11 @@ function unavailableResult( function buildResult( question: string, context: string | undefined, - mode: AskUserQuestionMode, + mode: StructuredExchangeToolMode, answers: AskAnswer[], note?: string, options?: AskOption[], - transport?: AskUserQuestionResultDetails["transport"], + transport?: StructuredExchangeToolResultDetails["transport"], ) { let text: string if (mode === "text") { @@ -459,7 +457,7 @@ export function structuredExchangeResultFromEditor( return unavailableResult( params.question, params.mode, - "ask_user_question editor fallback returned invalid JSON", + "structured_exchange editor fallback returned invalid JSON", params.context, ) } @@ -516,7 +514,7 @@ async function askOptionsWithEditor( ctx: any, question: string, context: string | undefined, - mode: Exclude<AskUserQuestionMode, "text">, + mode: Exclude<StructuredExchangeToolMode, "text">, options: AskOption[], ): Promise<OptionAnswerResult | null | "invalid"> { if (typeof ctx.ui.editor !== "function") return "invalid" @@ -969,27 +967,27 @@ function withUILock<T>(fn: () => Promise<T>): Promise<T> { return previous.then(fn).finally(() => release?.()) } -export default function askUserQuestion(pi: ExtensionAPI) { +export default function registerStructuredExchange(pi: ExtensionAPI) { pi.registerTool({ - name: "ask_user_question", - label: "ask_user_question", + name: STRUCTURED_EXCHANGE_TOOL, + label: "Structured exchange", renderShell: "self", description: "Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.", promptSnippet: "Ask exactly one clarifying, preference, confirmation, or decision question before continuing.", promptGuidelines: [ - "Use ask_user_question when a user decision would materially affect the next step.", - "Ask exactly one question per ask_user_question tool call.", - "Use ask_user_question with multiSelect: true only when multiple answers to the same question are valid.", - 'ask_user_question always lets the user select "Other" when options are provided.', + "Use structured_exchange when a user decision would materially affect the next step.", + "Ask exactly one question per structured_exchange tool call.", + "Use structured_exchange with multiSelect: true only when multiple answers to the same question are valid.", + 'structured_exchange always lets the user select "Other" when options are provided.', ], - parameters: AskUserQuestionParams, + parameters: StructuredExchangeParams, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const options = normalizeOptions(params.options) const context = params.details?.trim() || undefined - const mode: AskUserQuestionMode = + const mode: StructuredExchangeToolMode = options.length === 0 ? "text" : params.multiSelect @@ -1004,7 +1002,7 @@ export default function askUserQuestion(pi: ExtensionAPI) { return unavailableResult( params.question, mode, - "ask_user_question requires interactive mode UI", + "structured_exchange requires interactive mode UI", context, ) } @@ -1036,7 +1034,7 @@ export default function askUserQuestion(pi: ExtensionAPI) { return unavailableResult( params.question, mode, - "ask_user_question editor fallback returned invalid JSON", + "structured_exchange editor fallback returned invalid JSON", context, ) } @@ -1070,7 +1068,7 @@ export default function askUserQuestion(pi: ExtensionAPI) { return unavailableResult( params.question, mode, - "ask_user_question editor fallback returned invalid JSON", + "structured_exchange editor fallback returned invalid JSON", context, ) } @@ -1114,7 +1112,8 @@ export default function askUserQuestion(pi: ExtensionAPI) { }, renderResult(result, _options, theme, context) { - const details = result.details as AskUserQuestionResultDetails | undefined + const details = + result.details as StructuredExchangeToolResultDetails | undefined if (!details) { const first = result.content[0] return new Text(first?.type === "text" ? first.text : "", 0, 0) @@ -1132,7 +1131,7 @@ export default function askUserQuestion(pi: ExtensionAPI) { return new Text( theme.fg( "warning", - details.message || "ask_user_question unavailable", + details.message || "structured_exchange unavailable", ), 0, 0, diff --git a/src/tui-client/.pi/extensions/structured-question.ts b/src/tui-client/.pi/extensions/structured-question.ts deleted file mode 100644 index 523fc98f..00000000 --- a/src/tui-client/.pi/extensions/structured-question.ts +++ /dev/null @@ -1,334 +0,0 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@earendil-works/pi-coding-agent" -import { Key, matchesKey, type Component } from "@earendil-works/pi-tui" -import { Type } from "typebox" -import { Value } from "typebox/value" - -import { - StructuredQuestionAnswerSchema, - StructuredQuestionParamsSchema, - buildStructuredQuestionResult, - type StructuredQuestion, - type StructuredQuestionAnswer, - type StructuredQuestionParams, - type StructuredQuestionStatus, - type StructuredQuestionToolResult, -} from "../../../structured-question.js" - -export const STRUCTURED_QUESTION_TOOL = "brunch_structured_question" - -export interface StructuredQuestionTuiResponse { - status: Exclude<StructuredQuestionStatus, "unavailable"> - answers?: StructuredQuestionAnswer[] -} - -const StructuredQuestionModeSchema = Type.Union([ - Type.Literal("text"), - Type.Literal("singleSelect"), - Type.Literal("multiSelect"), - Type.Literal("questionnaire"), -]) - -const StructuredQuestionEditorResponseSchema = Type.Object( - { - status: Type.Union([ - Type.Literal("answered"), - Type.Literal("skipped"), - Type.Literal("cancelled"), - ]), - answers: Type.Optional(Type.Array(StructuredQuestionAnswerSchema)), - }, - { additionalProperties: false }, -) - -const StructuredQuestionEditorPayloadSchema = Type.Object( - { - schema: Type.Literal("brunch.structured_question.editor"), - schemaVersion: Type.Literal(1), - mode: StructuredQuestionModeSchema, - prompt: Type.String(), - instructions: Type.Array(Type.String()), - params: StructuredQuestionParamsSchema, - response: StructuredQuestionEditorResponseSchema, - }, - { additionalProperties: false }, -) - -export function registerBrunchStructuredQuestion(pi: ExtensionAPI): void { - if (typeof (pi as Partial<ExtensionAPI>).registerTool !== "function") { - return - } - pi.registerTool({ - name: STRUCTURED_QUESTION_TOOL, - label: "Structured question", - description: - "Ask the user a Brunch structured question and persist a self-contained structured result.", - parameters: StructuredQuestionParamsSchema, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return answerStructuredQuestionWithTui(params, ctx) - }, - }) -} - -export async function answerStructuredQuestionWithTui( - params: StructuredQuestionParams, - ctx: Pick<ExtensionContext, "hasUI" | "ui">, -): Promise<StructuredQuestionToolResult> { - if (!ctx.hasUI) { - return unavailableStructuredQuestionResult(params) - } - - if (typeof ctx.ui.custom === "function") { - const response = await ctx.ui.custom<StructuredQuestionTuiResponse>( - (_tui, _theme, _keybindings, done) => - createStructuredQuestionTuiComponent(params, done), - ) - - return buildStructuredQuestionResult({ - params, - status: response.status, - answers: response.status === "answered" ? (response.answers ?? []) : [], - transport: { surface: "tui-custom" }, - }) - } - - if (typeof ctx.ui.editor === "function") { - const edited = await ctx.ui.editor( - "Answer structured question as JSON", - buildStructuredQuestionEditorPrefill(params), - ) - return structuredQuestionResultFromEditor(params, edited) - } - - return unavailableStructuredQuestionResult(params) -} - -export function buildStructuredQuestionEditorPrefill( - params: StructuredQuestionParams, -): string { - return `${JSON.stringify( - Value.Parse(StructuredQuestionEditorPayloadSchema, { - schema: "brunch.structured_question.editor", - schemaVersion: 1, - mode: params.mode, - prompt: params.prompt, - instructions: [ - "Edit response.status to answered, skipped, or cancelled.", - "For answered responses, fill response.answers using the question ids and answer shapes shown by params.", - "Do not change schema, schemaVersion, params, prompt, or mode.", - ], - params, - response: { status: "skipped" }, - }), - null, - 2, - )}\n` -} - -export function parseStructuredQuestionEditorResponse( - edited: string | undefined, -): StructuredQuestionTuiResponse | null { - if (edited === undefined) return { status: "cancelled" } - try { - const payload = Value.Parse( - StructuredQuestionEditorPayloadSchema, - JSON.parse(edited), - ) - return payload.response - } catch { - return null - } -} - -export function structuredQuestionResultFromEditor( - params: StructuredQuestionParams, - edited: string | undefined, -): StructuredQuestionToolResult { - const response = parseStructuredQuestionEditorResponse(edited) - if (!response) { - return buildStructuredQuestionResult({ - params, - status: "unavailable", - transport: { surface: "rpc-editor" }, - message: - "Structured question editor response was invalid JSON or failed schema validation.", - }) - } - return buildStructuredQuestionResult({ - params, - status: response.status, - answers: response.status === "answered" ? (response.answers ?? []) : [], - transport: { surface: "rpc-editor" }, - }) -} - -function unavailableStructuredQuestionResult( - params: StructuredQuestionParams, -): StructuredQuestionToolResult { - return buildStructuredQuestionResult({ - params, - status: "unavailable", - transport: { surface: "headless" }, - message: "Structured question UI is unavailable.", - }) -} - -export function createStructuredQuestionTuiComponent( - params: StructuredQuestionParams, - done: (response: StructuredQuestionTuiResponse) => void, -): Component { - return new StructuredQuestionTuiComponent(params, done) -} - -class StructuredQuestionTuiComponent implements Component { - readonly #params: StructuredQuestionParams - readonly #questions: StructuredQuestion[] - readonly #done: (response: StructuredQuestionTuiResponse) => void - #questionIndex = 0 - #optionIndex = 0 - #text = "" - #selectedOptionIds = new Set<string>() - #answers: StructuredQuestionAnswer[] = [] - - constructor( - params: StructuredQuestionParams, - done: (response: StructuredQuestionTuiResponse) => void, - ) { - this.#params = params - this.#questions = - params.mode === "questionnaire" ? params.questions : [params] - this.#done = done - } - - handleInput(data: string): void { - const question = this.#currentQuestion() - if (!question) return - - if (matchesKey(data, Key.escape)) { - this.#done({ status: "cancelled" }) - return - } - - if (question.mode === "text") { - this.#handleTextInput(data, question) - return - } - - if (matchesKey(data, Key.up)) { - this.#optionIndex = Math.max(0, this.#optionIndex - 1) - return - } - if (matchesKey(data, Key.down)) { - this.#optionIndex = Math.min( - question.options.length - 1, - this.#optionIndex + 1, - ) - return - } - - if (question.mode === "multiSelect" && data === " ") { - const option = question.options[this.#optionIndex] - if (!option) return - if (this.#selectedOptionIds.has(option.id)) { - this.#selectedOptionIds.delete(option.id) - } else { - this.#selectedOptionIds.add(option.id) - } - return - } - - if (matchesKey(data, Key.enter)) { - if (question.mode === "singleSelect") { - const option = question.options[this.#optionIndex] - if (!option) return - this.#completeAnswer({ - questionId: question.id, - mode: "singleSelect", - selectedOption: { id: option.id, label: option.label }, - }) - return - } - const selectedOptions = question.options - .filter((option) => this.#selectedOptionIds.has(option.id)) - .map((option) => ({ id: option.id, label: option.label })) - if (selectedOptions.length === 0 && question.required !== false) return - this.#completeAnswer({ - questionId: question.id, - mode: "multiSelect", - selectedOptions, - }) - } - } - - render(_width: number): string[] { - const question = this.#currentQuestion() - if (!question) return ["Structured question"] - const prefix = - this.#params.mode === "questionnaire" - ? `Question ${this.#questionIndex + 1}/${this.#questions.length}: ` - : "" - const lines = [`${prefix}${question.prompt}`] - if (question.mode === "text") { - lines.push(`› ${this.#text}`) - lines.push("Enter submit • Esc cancel") - return lines - } - for (const [index, option] of question.options.entries()) { - const cursor = index === this.#optionIndex ? "›" : " " - const checked = - question.mode === "multiSelect" - ? this.#selectedOptionIds.has(option.id) - ? "[x]" - : "[ ]" - : `${index + 1}.` - lines.push(`${cursor} ${checked} ${option.label}`) - if (option.description) lines.push(` ${option.description}`) - } - lines.push( - question.mode === "multiSelect" - ? "Space toggle • Enter submit • Esc cancel" - : "↑↓ navigate • Enter select • Esc cancel", - ) - return lines - } - - invalidate(): void {} - - #handleTextInput(data: string, question: StructuredQuestion): void { - if (matchesKey(data, Key.backspace)) { - this.#text = this.#text.slice(0, -1) - return - } - if (matchesKey(data, Key.enter)) { - const value = this.#text.trim() - if (!value && question.required !== false) return - this.#completeAnswer({ questionId: question.id, mode: "text", value }) - return - } - if (data.length === 1 && data >= " " && data !== "\u007f") { - this.#text += data - } - } - - #completeAnswer(answer: StructuredQuestionAnswer): void { - if (this.#params.mode !== "questionnaire") { - this.#done({ status: "answered", answers: [answer] }) - return - } - this.#answers.push(answer) - if (this.#questionIndex < this.#questions.length - 1) { - this.#questionIndex += 1 - this.#optionIndex = 0 - this.#text = "" - this.#selectedOptionIds.clear() - return - } - this.#done({ status: "answered", answers: this.#answers }) - } - - #currentQuestion(): StructuredQuestion | undefined { - return this.#questions[this.#questionIndex] - } -} diff --git a/src/tui-client/.pi/extensions/workspace-dialog.ts b/src/tui-client/.pi/extensions/workspace-dialog.ts index bff8c2ec..97f86d7f 100644 --- a/src/tui-client/.pi/extensions/workspace-dialog.ts +++ b/src/tui-client/.pi/extensions/workspace-dialog.ts @@ -42,6 +42,18 @@ export function registerBrunchWorkspaceDialog( }) } +export default function brunchWorkspaceDialog(pi: ExtensionAPI): void { + pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { + description: "Open the Brunch spec/session picker", + handler: async (_args, ctx) => { + ctx.ui.notify( + "The Brunch workspace picker needs a product coordinator and is only available through the Brunch CLI.", + "warning", + ) + }, + }) +} + export async function runBrunchWorkspaceCommand( ctx: ExtensionCommandContext, coordinator: SpecSessionActivationCoordinator, From add52f94764e58ded5bf0bb29de9249a2af6935b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 18:17:45 +0200 Subject: [PATCH 119/164] Record durable structured exchange rendering model --- memory/SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/memory/SPEC.md b/memory/SPEC.md index ed8ad7a3..1185e149 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -217,7 +217,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured questions and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies causal/positional context, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured question. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries or as ephemeral dialog results detached from transcript truth. +- **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured exchanges and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies call identity and arguments, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Anything that must survive reload/resume must be derivable from persisted toolCall arguments or final toolResult `content`/`details`; `renderCall` is only a projection of stored args for call/header/progress display, and `renderResult` should be a pure projection of durable `content`/`details` plus row-local args. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, or relying on ephemeral dialog results detached from transcript truth. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -391,8 +391,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | -| **Structured question tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. | -| **Question result details** | The self-contained structured payload in a structured-exchange/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | +| **Structured exchange tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. Durable UI after reload/resume must be rebuilt from persisted toolCall args plus final toolResult content/details, not from render lifecycle state. | +| **Structured exchange result details** | The self-contained structured payload in a structured-exchange/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-exchange tools. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | From 04dada6b49c72bc0907516f34498f53530de9a7c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 18:19:47 +0200 Subject: [PATCH 120/164] pi local settings --- src/tui-client/.pi/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/tui-client/.pi/settings.json diff --git a/src/tui-client/.pi/settings.json b/src/tui-client/.pi/settings.json new file mode 100644 index 00000000..ce6cc161 --- /dev/null +++ b/src/tui-client/.pi/settings.json @@ -0,0 +1,6 @@ +{ + "extensions": [ + "-extensions/operational-mode.ts", + "-extensions/command-policy.ts" + ] +} \ No newline at end of file From fd73c6020bdb2fb462c0c32e3f44716a8cd18093 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 20:24:49 +0200 Subject: [PATCH 121/164] add topographic legibility as ln-review dimension --- .agents/skills/ln-review/SKILL.md | 42 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/.agents/skills/ln-review/SKILL.md b/.agents/skills/ln-review/SKILL.md index e3b826f8..b0e002e0 100644 --- a/.agents/skills/ln-review/SKILL.md +++ b/.agents/skills/ln-review/SKILL.md @@ -1,6 +1,6 @@ --- name: ln-review -description: "Audit code quality focusing on deep modules, naming, model hygiene, and architectural clarity. Use after a burst of development, when codebase structure needs assessment, or to make code more agent-navigable." +description: "Audit code quality focusing on deep modules, naming, model hygiene, topographic legibility, and architectural clarity. Use after a burst of development, when codebase structure needs assessment, or to make code more agent-navigable." argument-hint: "[area of codebase to review, or 'recent' for recently changed files]" --- @@ -20,21 +20,31 @@ If "recent" or unspecified, focus on recently modified files. Read `memory/SPEC.md` first when it exists. Use its lexicon for domain terms, and treat the live architecture register as the current decision record. Read `memory/PLAN.md` for active frontier context when the reviewed area touches active or near-horizon work. If ADRs or design docs exist in the touched area, respect them as supporting context, but do not introduce ADRs or sidecar decision logs by default; durable updates reconcile through `memory/SPEC.md` / `memory/PLAN.md`. +The lenses below are sub-passes. Apply each in turn; collect findings by category as you go. Each sub-pass owns one or more finding categories (named in parentheses). + +### Module depth (category: `depth`) + Apply Ousterhout's depth test: modules should have small interfaces hiding significant complexity. Modules that move together should live together — clusters of small files always used in concert are a single deep module waiting to be extracted. Use the deletion test for suspected shallow modules: if deleting the module makes complexity vanish, it was pass-through structure; if the same complexity reappears across multiple callers, the module was earning its keep. Prefer depth as leverage/locality, not line-count ratio. +### Seams and interfaces (categories: `seam`, `coupling`) + Treat the interface as the test surface. The interface is everything callers must know to use the module correctly: types, invariants, ordering constraints, error modes, required configuration, and performance characteristics. If callers or tests must reach past the interface to verify important behavior, the module shape is probably wrong. A good seam lets tests and callers cross the same public boundary. Apply seam discipline: one adapter usually means a hypothetical seam; two adapters make a real seam. Flag indirection introduced only for imagined future variation, especially when it spreads configuration, mocks, or ordering knowledge into callers. -When a finding is a deepening opportunity, present it as a candidate rather than a detailed design. Name the current shallow module shape, the deepened module that might replace it, what complexity would move behind the seam, and why that would improve locality, leverage, and the test surface. Do **not** propose detailed interfaces in `ln-review`; route selected deepening candidates to `ln-design` before scoping or refactoring. +When a finding here is a deepening opportunity, present it as a candidate rather than a detailed design. Name the current shallow module shape, the deepened module that might replace it, what complexity would move behind the seam, and why that would improve locality, leverage, and the test surface. Do **not** propose detailed interfaces in `ln-review`; route selected deepening candidates to `ln-design` before scoping or refactoring. + +### Core/shell boundary (category: `model`) Check the functional core / imperative shell boundary (Gary Bernhardt, "Boundaries"). Pure functions should stay pure. Flag when a pure function has acquired side effects or a growing parameter list — it has drifted into shell territory. +### Model integrity (category: `model`) + Make invalid states unrepresentable (Yaron Minsky). Split optional fields into distinct types. Use branded types for domain-distinct values. -### Oracle coverage +### Oracle coverage (category: `oracle-coverage`) If `memory/SPEC.md` §Oracle Strategy by Loop Tier exists, check whether recent work implemented the oracles declared by the relevant `memory/PLAN.md` frontier definition. If a full or light scope card is available in session context, use it as a higher-resolution slice supplement, not the primary source of truth. Look for: @@ -45,7 +55,7 @@ If `memory/SPEC.md` §Oracle Strategy by Loop Tier exists, check whether recent Collect gaps as numbered findings (category: `oracle-coverage`). -### Lexicon alignment +### Lexicon alignment (category: `naming`) If `memory/SPEC.md` exists, survey how §Lexicon terms (both method and domain) appear across: @@ -56,6 +66,26 @@ If `memory/SPEC.md` exists, survey how §Lexicon terms (both method and domain) Collect misalignments as numbered findings (category: `naming`) with the canonical term, where the deviation occurs, and what it should be. Format these so they can be passed directly to `ln-refactor`. +### Topographic legibility (category: `topography`) + +The directory tree is a spatial artifact, read top-down by humans and agents during orientation — *before any file is opened*. Layout is its own design surface, peer to module depth. Three lenses fire here: + +- **Topographic legibility** — a stranger should be able to *walk* the tree (not grep it) and infer the shape of the territory: what kinds of things exist, where each kind lives, and how they relate. Directory names predict the *kind* of their children; file names predict their contents. +- **Chunking budget** — siblings at one level should fit working memory (~7±2). A directory with many peer entries blows the budget; nested grouping should restore it. **Mixed grain** among siblings (a domain concept next to a utility next to a config) is the same kind of smell — peers should be peers in kind, not just in location. +- **Orientation debt / navigation tax** — the failure mode. When the tree doesn't teach, every reader pays a search cost on first contact. The cost compounds invisibly because no test, type-check, or build catches it. The signal is "a stranger had to grep to find X" or "no two readers guess the same location for a new file." + +Concrete cues to look for: + +- Sibling counts well above ~9 with no clear sub-grouping +- Mixed-grain siblings (e.g., one file is a domain concept, the next is a utility, the next is config) +- Deep nesting that doesn't reflect conceptual depth (folders of folders with one file each) +- Generic bucket names (`utils/`, `helpers/`, `lib/`, `misc/`, `shared/`) that hide what lives inside +- File names that don't predict contents; directory names that don't predict their children's kind +- Fractal-pattern violations: a file outgrew its boundary but stayed flat instead of getting its same-named private folder (the pattern documented in `AGENTS.md`) +- Imports that cross conceptual layers in surprising directions, hinting that the tree is *lying* about the dependency shape + +Collect findings as numbered items (category: `topography`). Frame each as: what the reader sees today, what they would have to internalize to find things, and the smallest topographic move that would make the tree teach itself. Routing for coordinated layout changes goes through `ln-refactor`; a single misplaced file can be a `ln-scope` slice. + ## Output Present findings as numbered candidates. Use the compact form for ordinary findings: @@ -63,7 +93,7 @@ Present findings as numbered candidates. Use the compact form for ordinary findi ```md ## Review: [area] -1. **[Description]** — [category: depth|naming|model|coupling|seam|oracle-coverage] — [impact: low|medium|high] +1. **[Description]** — [category: depth|naming|model|coupling|seam|oracle-coverage|topography] — [impact: low|medium|high] [1-2 sentence explanation and suggested action] 2. ... @@ -92,7 +122,7 @@ After presenting findings, present these options to the user (use `tool-ask-ques | 3 | Plan a refactor | `ln-refactor` | Multiple findings need coordinated restructuring | | 4 | Back to triage | `ln-consult` | Review complete, no immediate action needed | -Recommended: **2** if the highest-impact finding is a deepening candidate, **1** if high-impact findings are concrete fixes, **4** otherwise. +Recommended: **2** if the highest-impact finding is a deepening candidate, **1** if high-impact findings are concrete fixes, **3** when multiple topographic or naming findings cluster into a single layout pass, **4** otherwise. --- *Draws from [mattpocock/skills/improve-codebase-architecture](https://github.com/mattpocock/skills/tree/main/improve-codebase-architecture) and [theswerd/aicode/skills/self-documenting-code](https://github.com/theswerd/aicode/blob/main/skills/self-documenting-code/SKILL.md).* From f1e1b90c307d57aa1ecbc5371afd44df7219e6f2 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 20:29:21 +0200 Subject: [PATCH 122/164] Move public RPC modules under rpc tree --- src/brunch.ts | 2 +- src/fixture-capture.ts | 4 ++-- src/{rpc.test.ts => rpc/handlers.test.ts} | 15 +++++++++------ src/{rpc.ts => rpc/handlers.ts} | 12 ++++++------ .../protocol.test.ts} | 2 +- src/{json-rpc-protocol.ts => rpc/protocol.ts} | 0 src/{web-rpc-transport.ts => rpc/websocket.ts} | 4 ++-- src/web-client/rpc-client.ts | 4 ++-- src/web-host.ts | 4 ++-- 9 files changed, 25 insertions(+), 22 deletions(-) rename src/{rpc.test.ts => rpc/handlers.test.ts} (98%) rename src/{rpc.ts => rpc/handlers.ts} (98%) rename src/{json-rpc-protocol.test.ts => rpc/protocol.test.ts} (98%) rename src/{json-rpc-protocol.ts => rpc/protocol.ts} (100%) rename src/{web-rpc-transport.ts => rpc/websocket.ts} (94%) diff --git a/src/brunch.ts b/src/brunch.ts index 7e69439a..072ef0b8 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -7,7 +7,7 @@ import { renderWorkspaceSnapshot, workspaceSnapshotFromState, } from "./print-snapshot.js" -import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" +import { createRpcHandlers, runJsonRpcLineServer } from "./rpc/handlers.js" import { startWebHost } from "./web-host.js" import { createWorkspaceSessionCoordinator, diff --git a/src/fixture-capture.ts b/src/fixture-capture.ts index e8e82e92..b1b69928 100644 --- a/src/fixture-capture.ts +++ b/src/fixture-capture.ts @@ -4,10 +4,10 @@ import { PassThrough } from "node:stream" import { fileURLToPath } from "node:url" import { loadBriefLibrary, type FixtureBrief } from "./brief-library.js" -import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" +import { createRpcHandlers, runJsonRpcLineServer } from "./rpc/handlers.js" import type { ElicitationExchangeProjection } from "./elicitation-exchange.js" import type { WorkspaceSnapshot } from "./print-snapshot.js" -import type { JsonRpcResponse } from "./json-rpc-protocol.js" +import type { JsonRpcResponse } from "./rpc/protocol.js" import { createWorkspaceSessionCoordinator, type WorkspaceSessionBoundaryCoordinator, diff --git a/src/rpc.test.ts b/src/rpc/handlers.test.ts similarity index 98% rename from src/rpc.test.ts rename to src/rpc/handlers.test.ts index cde1386b..fae8700d 100644 --- a/src/rpc.test.ts +++ b/src/rpc/handlers.test.ts @@ -8,10 +8,10 @@ import { SessionManager } from "@earendil-works/pi-coding-agent" import { Value } from "typebox/value" -import { createRpcHandlers, runJsonRpcLineServer } from "./rpc.js" -import { createSessionBindingData } from "./session-binding.js" -import { createWorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" -import { assistantMessage, userMessage } from "./test-helpers.js" +import { createRpcHandlers, runJsonRpcLineServer } from "./handlers.js" +import { createSessionBindingData } from "../session-binding.js" +import { createWorkspaceSessionCoordinator } from "../workspace-session-coordinator.js" +import { assistantMessage, userMessage } from "../test-helpers.js" import type { DefaultWorkspaceCoordinator, WorkspaceActivationState, @@ -20,7 +20,7 @@ import type { WorkspaceSessionState, SpecSessionActivationCoordinator, SpecSessionActivationDecision, -} from "./workspace-session-coordinator.js" +} from "../workspace-session-coordinator.js" function coordinator( state: WorkspaceSessionState = readyState( @@ -440,7 +440,10 @@ describe("JSON-RPC handlers", () => { }) it("keeps RPC initial selection independent from TUI picker imports", async () => { - const source = await readFile(new URL("./rpc.ts", import.meta.url), "utf8") + const source = await readFile( + new URL("./handlers.ts", import.meta.url), + "utf8", + ) expect(source).not.toContain("workspace-dialog") expect(source).not.toContain("createWorkspaceDialogComponent") diff --git a/src/rpc.ts b/src/rpc/handlers.ts similarity index 98% rename from src/rpc.ts rename to src/rpc/handlers.ts index 81c77b09..d9a862bf 100644 --- a/src/rpc.ts +++ b/src/rpc/handlers.ts @@ -8,11 +8,11 @@ import { readBrunchSessionEnvelope, NonLinearTranscriptError, type BrunchSessionEnvelope, -} from "./brunch-session-envelope.js" +} from "../brunch-session-envelope.js" import { projectLinearElicitationExchangeProjection, projectLinearTranscriptDisplayProjection, -} from "./elicitation-exchange.js" +} from "../elicitation-exchange.js" import { createJsonRpcFailure, createJsonRpcSuccess, @@ -22,13 +22,13 @@ import { type JsonRpcId, type JsonRpcRequest, type JsonRpcResponse, -} from "./json-rpc-protocol.js" -import { workspaceSnapshotFromState } from "./print-snapshot.js" +} from "./protocol.js" +import { workspaceSnapshotFromState } from "../print-snapshot.js" import { resolveExplicitSessionProjectionTarget, type ExplicitSessionProjectionParams, type SessionProjectionTarget, -} from "./session-projection-reader.js" +} from "../session-projection-reader.js" import type { DefaultWorkspaceCoordinator, WorkspaceActivationState, @@ -36,7 +36,7 @@ import type { WorkspaceSessionState, SpecSessionActivationCoordinator, SpecSessionActivationDecision, -} from "./workspace-session-coordinator.js" +} from "../workspace-session-coordinator.js" export interface RpcHandlers { handle(request: unknown): Promise<JsonRpcResponse> diff --git a/src/json-rpc-protocol.test.ts b/src/rpc/protocol.test.ts similarity index 98% rename from src/json-rpc-protocol.test.ts rename to src/rpc/protocol.test.ts index 99c8d110..0cfa3c87 100644 --- a/src/json-rpc-protocol.test.ts +++ b/src/rpc/protocol.test.ts @@ -7,7 +7,7 @@ import { dispatchJsonRpcMessage, isJsonRpcRequest, parseJsonRpcMessage, -} from "./json-rpc-protocol.js" +} from "./protocol.js" describe("JSON-RPC protocol helpers", () => { it("recognizes valid request IDs and rejects invalid request shapes", () => { diff --git a/src/json-rpc-protocol.ts b/src/rpc/protocol.ts similarity index 100% rename from src/json-rpc-protocol.ts rename to src/rpc/protocol.ts diff --git a/src/web-rpc-transport.ts b/src/rpc/websocket.ts similarity index 94% rename from src/web-rpc-transport.ts rename to src/rpc/websocket.ts index b09479e5..00efe53e 100644 --- a/src/web-rpc-transport.ts +++ b/src/rpc/websocket.ts @@ -2,8 +2,8 @@ import type { Server as HttpServer } from "node:http" import { WebSocketServer, type RawData } from "ws" -import { dispatchJsonRpcMessage } from "./json-rpc-protocol.js" -import type { RpcHandlers } from "./rpc.js" +import { dispatchJsonRpcMessage } from "./protocol.js" +import type { RpcHandlers } from "./handlers.js" export interface WebRpcTransport { close(): Promise<void> diff --git a/src/web-client/rpc-client.ts b/src/web-client/rpc-client.ts index 8808a61f..9e9f6247 100644 --- a/src/web-client/rpc-client.ts +++ b/src/web-client/rpc-client.ts @@ -3,9 +3,9 @@ import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, -} from "../json-rpc-protocol.js" +} from "../rpc/protocol.js" -export type { JsonRpcRequest, JsonRpcResponse } from "../json-rpc-protocol.js" +export type { JsonRpcRequest, JsonRpcResponse } from "../rpc/protocol.js" type WebSocketEventListener = (event: { data?: unknown }) => void diff --git a/src/web-host.ts b/src/web-host.ts index 8442d57f..f53bfdde 100644 --- a/src/web-host.ts +++ b/src/web-host.ts @@ -3,8 +3,8 @@ import { createServer, type Server } from "node:http" import { dirname, resolve, sep } from "node:path" import { fileURLToPath } from "node:url" -import { createRpcHandlers } from "./rpc.js" -import { attachWebRpcTransport } from "./web-rpc-transport.js" +import { createRpcHandlers } from "./rpc/handlers.js" +import { attachWebRpcTransport } from "./rpc/websocket.js" import type { WorkspaceSessionCoordinator } from "./workspace-session-coordinator.js" export interface WebHostOptions { From ee9a28d8e8eb8afbbf0a05fe639561b1186fde97 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 20:32:09 +0200 Subject: [PATCH 123/164] Move probe harnesses under probes tree --- docs/architecture/pi-ui-extension-patterns.md | 4 ++-- runbooks/verify-m1.sh | 2 +- src/{ => probes}/brief-library.test.ts | 0 src/{ => probes}/brief-library.ts | 0 .../check-workspace-session-stores.ts | 4 ++-- src/{ => probes}/fixture-capture.test.ts | 6 +++--- src/{ => probes}/fixture-capture.ts | 17 +++++++++++------ .../structured-exchange-rpc-proof.test.ts | 0 .../structured-exchange-rpc-proof.ts | 4 ++-- 9 files changed, 21 insertions(+), 16 deletions(-) rename src/{ => probes}/brief-library.test.ts (100%) rename src/{ => probes}/brief-library.ts (100%) rename src/{ => probes}/check-workspace-session-stores.ts (82%) rename src/{ => probes}/fixture-capture.test.ts (97%) rename src/{ => probes}/fixture-capture.ts (91%) rename src/{ => probes}/structured-exchange-rpc-proof.test.ts (100%) rename src/{ => probes}/structured-exchange-rpc-proof.ts (98%) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 73b1dcb2..27e6bdea 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -24,7 +24,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. -- **Live structured-exchange RPC oracle:** `npm run test -- src/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. +- **Live structured-exchange RPC oracle:** `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. ## Command inventory and containment matrix @@ -246,7 +246,7 @@ The seam Brunch must still prove is the public product relay around that composi | Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | | Registered structured-exchange tool seam | Brunch result-builder/schema tests cover self-contained `toolResult.details`; exchange projection now classifies terminal structured-exchange results as response-side entries. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side. | | TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for text, single-select, multi-select, questionnaire, and terminal statuses. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | -| JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | +| JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | | Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | diff --git a/runbooks/verify-m1.sh b/runbooks/verify-m1.sh index 23f6aa32..af00a957 100755 --- a/runbooks/verify-m1.sh +++ b/runbooks/verify-m1.sh @@ -48,7 +48,7 @@ run_check "Per-brief binding/title alignment and metadata/projection parity" \ node --import "$TSX_LOADER" --input-type=module <<'NODE' import { readFile } from "node:fs/promises" import { join } from "node:path" -import { loadBriefLibrary } from "./src/brief-library.ts" +import { loadBriefLibrary } from "./src/probes/brief-library.ts" import { loadJsonlTranscriptEntries, projectElicitationExchanges } from "./src/elicitation-exchange.ts" const briefs = await loadBriefLibrary(".brunch-fixtures/briefs") diff --git a/src/brief-library.test.ts b/src/probes/brief-library.test.ts similarity index 100% rename from src/brief-library.test.ts rename to src/probes/brief-library.test.ts diff --git a/src/brief-library.ts b/src/probes/brief-library.ts similarity index 100% rename from src/brief-library.ts rename to src/probes/brief-library.ts diff --git a/src/check-workspace-session-stores.ts b/src/probes/check-workspace-session-stores.ts similarity index 82% rename from src/check-workspace-session-stores.ts rename to src/probes/check-workspace-session-stores.ts index d3551f8e..410392fa 100644 --- a/src/check-workspace-session-stores.ts +++ b/src/probes/check-workspace-session-stores.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from "node:process" -import { verifyWorkspaceSessionStores } from "./workspace-session-coordinator.js" +import { verifyWorkspaceSessionStores } from "../workspace-session-coordinator.js" const cwd = process.argv[2] const expectedSessionCount = process.argv[3] @@ -10,7 +10,7 @@ const expectedSessionCount = process.argv[3] if (!cwd || Number.isNaN(expectedSessionCount)) { process.stderr.write( - "Usage: tsx src/check-workspace-session-stores.ts <cwd> [expected-session-count]\n", + "Usage: tsx src/probes/check-workspace-session-stores.ts <cwd> [expected-session-count]\n", ) process.exit(2) } diff --git a/src/fixture-capture.test.ts b/src/probes/fixture-capture.test.ts similarity index 97% rename from src/fixture-capture.test.ts rename to src/probes/fixture-capture.test.ts index 7b676aff..6d6faafa 100644 --- a/src/fixture-capture.test.ts +++ b/src/probes/fixture-capture.test.ts @@ -6,9 +6,9 @@ import { describe, expect, it } from "vitest" import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, -} from "./workspace-session-coordinator.js" -import { loadLinearElicitationExchangeProjection } from "./elicitation-exchange.js" -import { assistantMessage, userMessage } from "./test-helpers.js" +} from "../workspace-session-coordinator.js" +import { loadLinearElicitationExchangeProjection } from "../elicitation-exchange.js" +import { assistantMessage, userMessage } from "../test-helpers.js" import { captureDeterministicBriefRuns, captureFixtureRun, diff --git a/src/fixture-capture.ts b/src/probes/fixture-capture.ts similarity index 91% rename from src/fixture-capture.ts rename to src/probes/fixture-capture.ts index b1b69928..d962a170 100644 --- a/src/fixture-capture.ts +++ b/src/probes/fixture-capture.ts @@ -4,16 +4,16 @@ import { PassThrough } from "node:stream" import { fileURLToPath } from "node:url" import { loadBriefLibrary, type FixtureBrief } from "./brief-library.js" -import { createRpcHandlers, runJsonRpcLineServer } from "./rpc/handlers.js" -import type { ElicitationExchangeProjection } from "./elicitation-exchange.js" -import type { WorkspaceSnapshot } from "./print-snapshot.js" -import type { JsonRpcResponse } from "./rpc/protocol.js" +import { createRpcHandlers, runJsonRpcLineServer } from "../rpc/handlers.js" +import type { ElicitationExchangeProjection } from "../elicitation-exchange.js" +import type { WorkspaceSnapshot } from "../print-snapshot.js" +import type { JsonRpcResponse } from "../rpc/protocol.js" import { createWorkspaceSessionCoordinator, type WorkspaceSessionBoundaryCoordinator, type WorkspaceSessionCoordinator, type WorkspaceSetupCoordinator, -} from "./workspace-session-coordinator.js" +} from "../workspace-session-coordinator.js" export interface FixtureCaptureOptions { cwd: string @@ -178,7 +178,12 @@ async function readPackageVersion(): Promise<string> { try { const packageJson = JSON.parse( await readFile( - join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), + join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "package.json", + ), "utf8", ), ) as { diff --git a/src/structured-exchange-rpc-proof.test.ts b/src/probes/structured-exchange-rpc-proof.test.ts similarity index 100% rename from src/structured-exchange-rpc-proof.test.ts rename to src/probes/structured-exchange-rpc-proof.test.ts diff --git a/src/structured-exchange-rpc-proof.ts b/src/probes/structured-exchange-rpc-proof.ts similarity index 98% rename from src/structured-exchange-rpc-proof.ts rename to src/probes/structured-exchange-rpc-proof.ts index 030a4df2..e495d7ac 100644 --- a/src/structured-exchange-rpc-proof.ts +++ b/src/probes/structured-exchange-rpc-proof.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import type { StructuredExchangeToolResultDetails } from "./tui-client/.pi/extensions/structured-exchange/index.js" +import type { StructuredExchangeToolResultDetails } from "../tui-client/.pi/extensions/structured-exchange/index.js" interface ProbeMetadata { name: string @@ -245,7 +245,7 @@ async function readProofDetails( function piCliPath(): string { return fileURLToPath( new URL( - "../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + "../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", import.meta.url, ), ) From e4e9c2bce2882258787c5b094b2c124de1831c21 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 20:38:30 +0200 Subject: [PATCH 124/164] Rename runbook checks to probe scripts --- docs/architecture/pi-ui-extension-patterns.md | 4 +-- docs/archive/PLAN_HISTORY.md | 4 +-- memory/PLAN.md | 4 +-- memory/SPEC.md | 30 +++++++++---------- .../probe-scripts.test.ts} | 15 ++++++---- {runbooks => src/probes/scripts}/verify-m1.sh | 22 +++++++------- .../scripts}/verify-startup-no-resume.sh | 4 +-- 7 files changed, 43 insertions(+), 40 deletions(-) rename src/{runbook.test.ts => probes/probe-scripts.test.ts} (69%) rename {runbooks => src/probes/scripts}/verify-m1.sh (89%) rename {runbooks => src/probes/scripts}/verify-startup-no-resume.sh (96%) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 27e6bdea..84621fe0 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -12,7 +12,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | | Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | -| Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `runbooks/verify-startup-no-resume.sh` pty oracle | +| Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `src/probes/scripts/verify-startup-no-resume.sh` pty probe oracle | | In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | | Structured-exchange response loop | partially proven; product relay pending | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests | @@ -177,7 +177,7 @@ Runtime should **not** invoke Chafa on startup. The logo should be deterministic Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure spec/session picker UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. -The executable pty oracle is `runbooks/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains spec/session picker markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. +The executable pty probe oracle is `src/probes/scripts/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains spec/session picker markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. The in-session product command is `/brunch` with `ctrl+shift+b`. It waits for idle, inspects inventory, renders the same typed centered spec/session picker with `ctx.ui.custom(..., { overlay: true })`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins; it is the Brunch-owned workspace adapter over Pi's session-replacement API. diff --git a/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md index 8f72f245..1cd21e1d 100644 --- a/docs/archive/PLAN_HISTORY.md +++ b/docs/archive/PLAN_HISTORY.md @@ -17,7 +17,7 @@ Archived from `memory/PLAN.md` so the live plan only carries active, next, horiz - **Objective:** Prove the wrapping model works at all: a `brunch` binary launches a pi-backed TUI session through the `WorkspaceSessionCoordinator`, scopes durable state to `.brunch/`, hardcodes Brunch's prompt and curated toolset, and mounts the persistent TUI chrome and spec-selector gate. - **Why now / unlocks:** First architectural proof of D1-L (depend on `pi-coding-agent`) and D2-L (opinionated product, not pi shell). Unlocks every subsequent milestone. Also doubles as the Phase-3 infra bootstrap (package.json, tsconfig, oxlint/oxfmt, vitest). - **Acceptance:** `brunch` launches a TUI session in a project directory; `.brunch/` is created; boot routes through a `WorkspaceSessionCoordinator` that returns `ready | select_spec | needs_human`; the spec-selector is presented before any agent loop runs when no bound spec is ready; the selected spec is written as the session's `brunch.session_binding`; `/new` creates another session bound to the same spec rather than mutating the current session's spec; the chrome region displays cwd / spec / phase / runtime bundle at all times; `npm run verify` is green. -- **Verification:** Inner — `npm run fix` / `npm run verify` plus coordinator state/unit tests. Middle — M0 runbook oracle: manual TUI smoke against a scratch project paired with artifact/query postconditions for `.brunch/`, `brunch.session_binding`, same-spec `/new`, and chrome/workspace state (SPEC §Runbook Oracle Design). Outer — defer; first replay-regression fixture lands in M1. +- **Verification:** Inner — `npm run fix` / `npm run verify` plus coordinator state/unit tests. Middle — M0 runbook oracle: manual TUI smoke against a scratch project paired with artifact/query postconditions for `.brunch/`, `brunch.session_binding`, same-spec `/new`, and chrome/workspace state (SPEC §Probe Oracle Design). Outer — defer; first replay-regression fixture lands in M1. - **Cross-cutting obligations:** Preserve the `cwd → spec → session` hierarchy, one-spec-per-session binding, and persistent chrome region as durable product surfaces, not temporary bootstrapping hacks. Do not let TUI, RPC, or fixture code create/open Pi sessions or write `brunch.session_binding` directly; route boot, spec selection, and `/new` through the workspace-session seam. - **Traceability:** R1, R2, R3, R4, R19 / D1-L, D2-L, D6-L, D11-L, D21-L / I8-L, I13-L / A1-L, A10-L - **Design docs:** [prd.md §M0](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §3](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) @@ -33,7 +33,7 @@ Archived from `memory/PLAN.md` so the live plan only carries active, next, horiz - **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression fixtures for at least briefs #1–#3. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. - **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. - **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.brunch-fixtures/`; the first three curated briefs are captured. -- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./runbooks/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the runbook. +- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./src/probes/scripts/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the probe. - **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. - **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L - **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) diff --git a/memory/PLAN.md b/memory/PLAN.md index 69cbc40f..55d3dd59 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -220,7 +220,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, and the discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts` have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — runbook oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L @@ -278,7 +278,7 @@ The POC should maximize assumption falsification rather than merely implement mi - 2026-05-22 `web-shell` — Done: M3 now serves the native React web shell over one persistent WebSocket RPC client, blocks/adjudicates branchy transcript shapes for session-consuming reads, serves only static HTTP assets (no REST product reads), projects explicit durable sessions through a canonical Brunch session-envelope reader, renders assistant/user/prompt transcript rows, and keeps browser state as a read-only client attachment rather than a durable session. Verified: `npm run verify` after each slice plus direct host/WebSocket smoke for static HTML, missing REST product reads, explicit `{ sessionId, specId }` projections, transcript display, and exchange projection. Accepted deferral: qualitative browser-open smoke remains environment-blocked by the current macOS sandbox. - 2026-05-21 `jsonl-session-viability` — Done: Pi JSONL reload preserves coordinator-created binding-only sessions, first assistant/user flushes without duplicate prefixes, `/new` same-spec bindings, raw user/assistant payloads, representative Brunch custom entries, context-participating custom messages, continuity/compaction metadata, structured elicitation entries, defensive active-branch projection behavior, and M1 bundle-local replay parity for briefs #1–#3. Verified: `npm run verify` after each slice. Watch: M2 validates JSONL as sufficient for Brunch-supported linear sessions on current POC terms; branch-aware Brunch sessions are intentionally unsupported per D24-L, and later side-task, mention, and continuity frontiers still own their final payload semantics. -- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.brunch-fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./runbooks/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. +- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.brunch-fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./src/probes/scripts/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. Older history: `docs/archive/PLAN_HISTORY.md` diff --git a/memory/SPEC.md b/memory/SPEC.md index 1185e149..4bac6205 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -247,7 +247,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I5-L | For every `brunch.lens_switch` entry and every session/spec binding transition, the session interest set is recomputed before the next agent turn. | planned (M7 property test) | D11-L | | I6-L | Every reconciliation need has `created_at_lsn ≤` current global LSN; `kind='impasse'` needs reference at least two graph nodes; resolved needs carry a strictly later `resolved_at_lsn`. | planned (M8 property test) | D8-L, I1-L | | I7-L | Every `framing_as` value belongs to the allowed matrix for that node's base kind. | planned (fixture property check) | D7-L | -| I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only runbook checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | +| I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only probe checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | | I9-L | Every `brunch.mention` payload resolves a transcript `#` handle to a stable graph entity id; the ledger never stores title-anchored references or relies on autocomplete popup metadata. | planned (M7 invariant) | D14-L | | I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported elicitation exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | | I11-L | No durable graph mutation path — including migrations, maintenance scripts, elicitor-capture writes, deferred observer/auditor writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | @@ -261,7 +261,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | -| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `runbooks/verify-startup-no-resume.sh` pty/ANSI-stripped TUI oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | +| I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-exchange/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, optional note, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 now has one provider-valid `structured_exchange` Pi tool exported from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover self-contained `toolResult.details` and model-readable `content` for text/single-select/multi-select with terminal `answered`/`cancelled`/`unavailable` statuses, option notes, TUI custom UI, JSON-over-editor fallback, and response-side elicitation-exchange projection. The older provider-invalid `brunch_structured_question`/top-level-union tool path was retired. Questionnaire and `skipped` support remain product requirements rather than current extension coverage; Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | @@ -439,7 +439,7 @@ Verification is first-class product work for Brunch because the POC's claims are Brunch uses a three-layer stance: 1. **Inner loop:** fast static and unit checks prove local contracts and keep the codebase shippable. -2. **Middle loop:** runbook oracles, round-trip/property tests, contract tests, and fixture replay prove frontier seams against durable artifacts. +2. **Middle loop:** probe oracles, round-trip/property tests, contract tests, and fixture replay prove frontier seams against durable artifacts. 3. **Outer loop:** adversarial/generative fixtures and manual walkthroughs assess LLM elicitation quality, UX feel, and long-horizon coherence that cannot be reduced to schema checks. **POC-phase posture (M0–M9): viable-and-reasonable, not hardened.** Across the POC milestone ladder, the goal is "the system is viable and works at least reasonably well" — proof-of-life for each architectural claim, not statistical robustness. The implications for oracle design: @@ -456,9 +456,9 @@ The structural/behavioral split is the key discipline: never let a behavioral fi | Dimension | Score | Notes | Raised by | | --- | --- | --- | --- | -| Observability | partial, improving to high by M4/M5 | Text-native artifacts are planned (`.brunch/state.json`, Pi JSONL, command results, graph exports, coherence exports, fixture bundles). Generative-lens material adds further text-native surfaces: `brunch.review_set_proposal`, `brunch.establishment_offer`, `brunch.elicitor_intent_hint` entries plus reviewer-finding `reconciliation_need` records. *Structural* observability is high; *behavioral* observability (proposal quality, lens-recommendation appropriateness, reviewer precision) remains low and outer-loop only. M0 TUI chrome and M3 browser UX remain partly visual unless paired with artifact/query checks. | Runbook oracles; projection handlers; graph/coherence exports; transcript projection of lens/establishment/proposal entries. | -| Reproducibility | partial | Fixture briefs and captured runs create a repeatable path. M1/M2 proved the agent-as-user harness and JSONL projection/reload discipline. LLM runs remain variable, so deterministic postcondition checks and property assertions are required; batch-proposal/review-set flows additionally need seeded multi-run probes to characterize structural-legality rate at all. Driver extension for review-cycle flows (approve / request-changes / reject) is conditional on cost being worth the controllability gain. | Deterministic runbook checks; captured-run metadata; replay/property fixtures; (planned) review-cycle driver extension. | -| Controllability | partial → high (conditional) | `npm run fix` / `npm run verify` are agent-controllable. The agent-as-user stdio RPC driver covers single-exchange flows end-to-end; extending it to drive review-cycle acceptance/regeneration would lift batch-proposal/review-set controllability to "high" but carries implementation cost. TUI/browser/manual flows for ambient affordances, in-flight reviewer signals, and chrome rendering remain runbook-oracle territory. | Store/projection postcondition checkers; stdio/WebSocket drivers; (planned) review-cycle driver extension; runbook oracles for chrome surfaces. | +| Observability | partial, improving to high by M4/M5 | Text-native artifacts are planned (`.brunch/state.json`, Pi JSONL, command results, graph exports, coherence exports, fixture bundles). Generative-lens material adds further text-native surfaces: `brunch.review_set_proposal`, `brunch.establishment_offer`, `brunch.elicitor_intent_hint` entries plus reviewer-finding `reconciliation_need` records. *Structural* observability is high; *behavioral* observability (proposal quality, lens-recommendation appropriateness, reviewer precision) remains low and outer-loop only. M0 TUI chrome and M3 browser UX remain partly visual unless paired with artifact/query checks. | Probe oracles; projection handlers; graph/coherence exports; transcript projection of lens/establishment/proposal entries. | +| Reproducibility | partial | Fixture briefs and captured runs create a repeatable path. M1/M2 proved the agent-as-user harness and JSONL projection/reload discipline. LLM runs remain variable, so deterministic postcondition checks and property assertions are required; batch-proposal/review-set flows additionally need seeded multi-run probes to characterize structural-legality rate at all. Driver extension for review-cycle flows (approve / request-changes / reject) is conditional on cost being worth the controllability gain. | Deterministic probe checks; captured-run metadata; replay/property fixtures; (planned) review-cycle driver extension. | +| Controllability | partial → high (conditional) | `npm run fix` / `npm run verify` are agent-controllable. The agent-as-user stdio RPC driver covers single-exchange flows end-to-end; extending it to drive review-cycle acceptance/regeneration would lift batch-proposal/review-set controllability to "high" but carries implementation cost. TUI/browser/manual flows for ambient affordances, in-flight reviewer signals, and chrome rendering remain probe-oracle territory. | Store/projection postcondition checkers; stdio/WebSocket drivers; (planned) review-cycle driver extension; probe oracles for chrome surfaces. | ### Verification Commands @@ -486,7 +486,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, fixture metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | -| Middle | **Runbook oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | +| Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; pending-exchange start/read/respond handlers preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | @@ -497,16 +497,16 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | -### Runbook Oracle Design +### Probe Oracle Design -A **runbook oracle** is the preferred bridge for seams that require human interaction but leave durable state. It has two parts: +A **probe oracle** is the preferred bridge for seams that require human interaction but leave durable state. It has two parts: 1. **Manual checklist** — what the human does or observes (for example: launch TUI, select/create spec, confirm chrome, run `/new`). 2. **Executable postcondition checker** — what the agent/test harness inspects afterward in canonical stores or projection handlers. -Runbook postconditions should be boring and product-shaped: paths exist, JSON fields match, JSONL entries are present and unique, projections reconstruct the same state, command results carry expected discriminants. Store-only checks are acceptable before projection handlers exist; projection-including checks become the default once `workspace.*`, `session.*`, `graph.*`, or `coherence.*` handlers exist. +Probe postconditions should be boring and product-shaped: paths exist, JSON fields match, JSONL entries are present and unique, projections reconstruct the same state, command results carry expected discriminants. Store-only checks are acceptable before projection handlers exist; projection-including checks become the default once `workspace.*`, `session.*`, `graph.*`, or `coherence.*` handlers exist. -The first required runbook is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. FE-744 extends this with a startup-switcher runbook: launch Brunch against a workspace with an existing selected transcript, assert the pre-Pi switcher appears before transcript rendering, choose new-session vs resume paths explicitly, and pair the visual capture with store/projection checks for activated spec/session state. +The first required probe is M0: after manual TUI interaction, a checker proves `.brunch/` creation, `.brunch/state.json` current spec acceleration, Pi session JSONL files, exactly one `brunch.session_binding` per session, same-spec `/new`, and workspace/session reconstruction when available. FE-744 extends this with a startup-switcher probe: launch Brunch against a workspace with an existing selected transcript, assert the pre-Pi switcher appears before transcript rendering, choose new-session vs resume paths explicitly, and pair the visual capture with store/projection checks for activated spec/session state. ### Invariant Oracle Coverage @@ -519,7 +519,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I5-L | M7 property tests over binding/lens transitions and interest-set recomputation. | | I6-L | M4/M8 reconciliation-need property tests and contradictory-requirements fixture. | | I7-L | M4+ schema/property tests over framing matrix plus brief fixture assertions. | -| I8-L | M0 runbook oracle plus M2 coordinator-created JSONL reload tests. | +| I8-L | M0 probe oracle plus M2 coordinator-created JSONL reload tests. | | I9-L | M7 mention parser/ledger unit tests and staleness property tests. | | I10-L | M1/M2 exchange projection tests, linear transcript validation, and no chat/turn architectural test. | | I11-L | M4/M5 no-bypass architectural test plus command transaction integration tests. | @@ -533,7 +533,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | I19-L | Brunch extension/runtime guard tests for `/tree`/`/fork`/`/clone` blocking plus transcript-reader non-linearity rejection tests. | | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | -| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI runbook assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | +| I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | | I23-L | FE-744 structured-exchange tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | @@ -555,7 +555,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | Blind spot | Reason | Mitigation | Revisit trigger | | --- | --- | --- | --- | -| Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query runbook oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | +| Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query probe oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | | LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and M1 scripted exchanges intentionally encode only a thin current exchange model. | Brief library, human-reviewed golden captures, adversarial probes, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated fixture failures where structure passes but elicitation is judged poor, or M2/M3 reveals that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | | Subscription reconnect/resume | POC can prove snapshot + live update without hardening network recovery yet. | Contract tests for initial snapshot and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | | Performance and scale | Local POC graph/session sizes are small; premature budgets may distort design. | Keep exports/checkers text-native and simple; add budgets when slow tests appear. | `npm run verify` or fixture runs exceed acceptable local iteration time. | @@ -563,7 +563,7 @@ The first required runbook is M0: after manual TUI interaction, a checker proves | Lens-recommendation appropriateness | No deterministic ground truth for "did the agent offer the right strategy at the right time" given temperament + grounding density inputs. | Brief-driven outer-loop walkthrough; small targeted scenarios where recommended lens is judged by reviewer; tracked as fitness, not gated. | Repeated user complaints that the offered strategies feel wrong, or fixture review reveals systematic mis-offers. | | Framing/proposal quality at thin grounding | Generative-lens proposals may be syntactically legal but semantically weak when grounding is thin; `epistemic_status` honesty may not be enforceable without human judgment. | A14-L proposal-legality rate tracked as fitness; outer-loop walkthrough of proposals under thin vs rich grounding; `epistemic_status` distribution surfaced per run. | Acceptance-without-rework rates drop, or reviewers consistently mark proposals as `inferred`/`asserted` despite asserted grounding. | | Reviewer finding precision (false positives/negatives) | Advisory-only reviewer can spam reconciliation needs (false positives) or miss real coherence gaps (false negatives); both erode trust. | Targeted adversarial briefs with known-bad coherence problems; precision/recall surfaced per run as fitness; user can dismiss reviewer findings without consequence. | Users systematically ignore reviewer findings, or coherence gaps slip past reviewer in known-bad fixtures. | -| In-flight reviewer-signal UX | Chrome rendering of "reviewer running / has findings" before next-turn delivery is not yet designed; cost may exceed value in POC. | Runbook oracle on chrome state after batch-accept; defer in-flight progress affordances unless a frontier explicitly demands them. | Users report confusion about whether reviewer ran or completed; or async job latency makes silence feel like failure. | +| In-flight reviewer-signal UX | Chrome rendering of "reviewer running / has findings" before next-turn delivery is not yet designed; cost may exceed value in POC. | Probe oracle on chrome state after batch-accept; defer in-flight progress affordances unless a frontier explicitly demands them. | Users report confusion about whether reviewer ran or completed; or async job latency makes silence feel like failure. | | Meta-rubric usefulness (D31-L) | Universal evaluative dimensions (complexity, lock-in, etc.) may or may not be productive across lens types; this is an unproven hypothesis. | Comparative outer-loop walkthrough: same proposal scenario with and without meta-rubric framing; user judgment captured in fixture metadata. | Meta-rubric framings are consistently ignored by users, or consistently produce better decisions — either signal warrants spec revision. | ### Acceptance Criteria diff --git a/src/runbook.test.ts b/src/probes/probe-scripts.test.ts similarity index 69% rename from src/runbook.test.ts rename to src/probes/probe-scripts.test.ts index 18c2ab56..e3ea8c4b 100644 --- a/src/runbook.test.ts +++ b/src/probes/probe-scripts.test.ts @@ -6,14 +6,17 @@ import { describe, expect, it } from "vitest" const execFileAsync = promisify(execFile) -describe("M1 runbook", () => { +describe("M1 probe script", () => { it("runs and prints expected plus actual outputs", async () => { - await access("runbooks/verify-m1.sh", constants.X_OK) + await access("src/probes/scripts/verify-m1.sh", constants.X_OK) - const { stdout } = await execFileAsync("./runbooks/verify-m1.sh", { - timeout: 120_000, - maxBuffer: 1024 * 1024 * 4, - }) + const { stdout } = await execFileAsync( + "./src/probes/scripts/verify-m1.sh", + { + timeout: 120_000, + maxBuffer: 1024 * 1024 * 4, + }, + ) expect(stdout).toContain("Expected outputs") expect(stdout).toContain("Actual outputs") diff --git a/runbooks/verify-m1.sh b/src/probes/scripts/verify-m1.sh similarity index 89% rename from runbooks/verify-m1.sh rename to src/probes/scripts/verify-m1.sh index af00a957..c68e1af3 100755 --- a/runbooks/verify-m1.sh +++ b/src/probes/scripts/verify-m1.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -u -o pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" TSX_LOADER="$ROOT/node_modules/tsx/dist/loader.mjs" export ROOT TSX_LOADER cd "$ROOT" || exit 1 @@ -33,12 +33,12 @@ cleanup() { } trap cleanup EXIT -echo "# M1 mode shell and fixture driver runbook" +echo "# M1 mode shell and fixture driver probe" echo echo "## Expected outputs" echo "- Each committed scripted bundle has one brunch.session_binding whose specTitle matches its brief title." echo "- Each committed bundle metadata projection summary matches projection from its JSONL transcript." -echo "- Print mode emits a product-shaped workspace snapshot for a selected runbook spec." +echo "- Print mode emits a product-shaped workspace snapshot for a selected probe spec." echo "- RPC workspace.snapshot and session.elicitationExchanges return product-shaped JSON-RPC results." echo "- Human review remains responsible for brief quality and golden-capture representativeness." echo @@ -93,28 +93,28 @@ for (const briefId of briefIds) { } NODE -TMP_WORKSPACE="$(mktemp -d "${TMPDIR:-/tmp}/brunch-m1-runbook.XXXXXX")" +TMP_WORKSPACE="$(mktemp -d "${TMPDIR:-/tmp}/brunch-m1-probe.XXXXXX")" export TMP_WORKSPACE node --import "$TSX_LOADER" --input-type=module <<'NODE' import { createWorkspaceSessionCoordinator } from "./src/workspace-session-coordinator.ts" const cwd = process.env.TMP_WORKSPACE const coordinator = createWorkspaceSessionCoordinator({ cwd }) -const workspace = await coordinator.createSetupSession({ specTitle: "M1 runbook smoke" }) +const workspace = await coordinator.createSetupSession({ specTitle: "M1 probe smoke" }) workspace.session.manager.appendCustomMessageEntry( "brunch.elicitation_prompt", - "Runbook prompt: confirm the M1 mode shell is product-shaped.", + "Probe prompt: confirm the M1 mode shell is product-shaped.", true, ) -workspace.session.manager.appendMessage({ role: "user", content: "Runbook response" }) +workspace.session.manager.appendMessage({ role: "user", content: "Probe response" }) await coordinator.bindCurrentSpecToReplacementSession(workspace.session.manager) NODE run_check "Print-mode smoke output" \ - bash -c 'cd "$TMP_WORKSPACE" && node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode print | tee "$TMP_WORKSPACE/print.out" && grep -q "M1 runbook smoke" "$TMP_WORKSPACE/print.out"' + bash -c 'cd "$TMP_WORKSPACE" && node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode print | tee "$TMP_WORKSPACE/print.out" && grep -q "M1 probe smoke" "$TMP_WORKSPACE/print.out"' run_check "RPC workspace.snapshot smoke output" \ - bash -c 'cd "$TMP_WORKSPACE" && printf "%s\n" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"workspace.snapshot\"}" | node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode rpc > "$TMP_WORKSPACE/workspace-rpc.out" && node -e "const fs=require(\"node:fs\"); const path=process.env.TMP_WORKSPACE + \"/workspace-rpc.out\"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(path, \"utf8\")), null, 2))" && grep -q "M1 runbook smoke" "$TMP_WORKSPACE/workspace-rpc.out" && grep -q "\"session\"" "$TMP_WORKSPACE/workspace-rpc.out"' + bash -c 'cd "$TMP_WORKSPACE" && printf "%s\n" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"workspace.snapshot\"}" | node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode rpc > "$TMP_WORKSPACE/workspace-rpc.out" && node -e "const fs=require(\"node:fs\"); const path=process.env.TMP_WORKSPACE + \"/workspace-rpc.out\"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(path, \"utf8\")), null, 2))" && grep -q "M1 probe smoke" "$TMP_WORKSPACE/workspace-rpc.out" && grep -q "\"session\"" "$TMP_WORKSPACE/workspace-rpc.out"' run_check "RPC session.elicitationExchanges smoke output" \ bash -c 'cd "$TMP_WORKSPACE" && printf "%s\n" "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session.elicitationExchanges\"}" | node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode rpc > "$TMP_WORKSPACE/exchanges-rpc.out" && node -e "const fs=require(\"node:fs\"); const path=process.env.TMP_WORKSPACE + \"/exchanges-rpc.out\"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(path, \"utf8\")), null, 2))" && grep -q "\"status\":\"ready\"" "$TMP_WORKSPACE/exchanges-rpc.out" && grep -q "promptEntryIds" "$TMP_WORKSPACE/exchanges-rpc.out"' @@ -127,9 +127,9 @@ echo "- Product shape: Do print/RPC outputs expose workspace/session/exchange co if [[ "$failures" -gt 0 ]]; then echo - echo "Runbook failed with $failures structural failure(s)." + echo "Probe failed with $failures structural failure(s)." exit 1 fi echo -echo "Runbook structural checks passed; complete the human review prompts above before final M1 acceptance." +echo "Probe structural checks passed; complete the human review prompts above before final M1 acceptance." diff --git a/runbooks/verify-startup-no-resume.sh b/src/probes/scripts/verify-startup-no-resume.sh similarity index 96% rename from runbooks/verify-startup-no-resume.sh rename to src/probes/scripts/verify-startup-no-resume.sh index 9a8bf44d..3c6cbcd7 100755 --- a/runbooks/verify-startup-no-resume.sh +++ b/src/probes/scripts/verify-startup-no-resume.sh @@ -2,11 +2,11 @@ set -euo pipefail # Proves FE-744/I22 at the terminal boundary: Brunch TUI startup shows the -# spec/session picker before any prior transcript is rendered. This runbook uses +# spec/session picker before any prior transcript is rendered. This probe uses # a real pty via `script`; it is intended as a manual/middle-loop oracle rather # than part of the default verify gate. -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/brunch-startup-oracle.XXXXXX")}" CAPTURE_RAW="$WORK_DIR/startup.raw" CAPTURE_STRIPPED="$WORK_DIR/startup.stripped" From 79fdd67cdd1e84d6533615f718530e1d8e9716c6 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 21:34:55 +0200 Subject: [PATCH 125/164] Remodel structured exchange tools --- docs/architecture/pi-ui-extension-patterns.md | 5 +- memory/PLAN.md | 6 +- memory/SPEC.md | 14 +- src/brunch-tui.test.ts | 25 +- src/brunch-tui.ts | 4 +- src/tui-client/.pi/README.md | 12 + .../structured-exchange-extension.test.ts | 116 +- ...tructured-exchange-present-request.test.ts | 195 +++ .../.pi/__tests__/structured-exchange.test.ts | 420 +----- .../extensions/structured-exchange/index.ts | 1237 +---------------- .../structured-exchange/present-candidates.ts | 5 + .../structured-exchange/present-options.ts | 107 ++ .../structured-exchange/present-question.ts | 70 + .../structured-exchange/present-review-set.ts | 5 + .../structured-exchange/request-answer.ts | 101 ++ .../structured-exchange/request-choice.ts | 200 +++ .../structured-exchange/request-choices.ts | 5 + .../structured-exchange/request-review.ts | 5 + .../shared/editor-fallback.ts | 203 +++ .../structured-exchange/shared/markdown.ts | 76 + .../structured-exchange/shared/model.ts | 63 + .../structured-exchange/shared/recovery.ts | 129 ++ .../pi-extension-shell.ts} | 30 +- 23 files changed, 1377 insertions(+), 1656 deletions(-) create mode 100644 src/tui-client/.pi/README.md create mode 100644 src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/present-candidates.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/present-options.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/present-question.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/present-review-set.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/request-answer.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/request-choice.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/request-choices.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/request-review.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/shared/editor-fallback.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/shared/markdown.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/shared/model.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/shared/recovery.ts rename src/{pi-extensions.ts => tui-client/pi-extension-shell.ts} (70%) diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 84621fe0..3e03e8ce 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/pi-extensions.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/tui-client/pi-extension-shell.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-exchange RPC oracle:** `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. @@ -121,7 +121,7 @@ The same probe emitted corresponding `notify` requests (`cancel switch new`, `ca ## Brunch extension layout and dynamic chrome proof -The Brunch extension entrypoint is intentionally a registration map. `src/pi-extensions.ts` composes product-owned modules under `src/tui-client/.pi/extensions/*` by Pi surface/responsibility: +The Brunch extension entrypoint is intentionally a registration map. `src/tui-client/pi-extension-shell.ts` composes product-owned modules under `src/tui-client/.pi/extensions/*` by Pi surface/responsibility: - `chrome.ts` owns `BrunchChromeState`, reusable formatting helpers, and `renderBrunchChrome()`. - `session-lifecycle.ts` owns coordinator refresh calls on Pi session lifecycle events. @@ -130,6 +130,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/pi-ext - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. - `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/tui-client/.pi/components/*`. +- `structured-exchange/` owns the remodeled present/request structured-exchange tool family; the active registry currently exposes `present_question`, `present_options`, `request_answer`, and `request_choice`, while review/candidate/multi-choice modules are stubs until their product flows land. `renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: diff --git a/memory/PLAN.md b/memory/PLAN.md index 55d3dd59..92c6eb03 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -82,9 +82,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** unassigned - **Kind:** structural hardening - **Status:** not-started -- **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into explicit Brunch-owned product modules under `src/tui-client/.pi/extensions/*` plus aggregate `src/pi-extensions.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. +- **Objective:** Turn the discussion-locked Brunch Pi Profile and runtime-bundle model into code/tests by porting the useful `.pi/` probe extensions into explicit Brunch-owned product modules under `src/tui-client/.pi/extensions/*` plus aggregate `src/tui-client/pi-extension-shell.ts`: Brunch-owned programmatic settings/resource/tool/prompt/keybinding policy isolates product behavior from ambient user/project `.pi/`; operational mode / role preset / strategy / lens state is appended to Pi JSONL as Brunch custom entries and reconstructed at turn boundaries. - **Why now / unlocks:** FE-744 proved multiple Pi extension seams and exposed the exact weak point: ambient resource discovery is mostly disabled, but `SettingsManager.create(cwd, agentDir)` can still leak behavior-shaping settings, and future `elicit` vs `execute` work needs prompt/tool posture to be stateful without hidden extension memory. This frontier de-risks M5/M6/M7 before graph tools, capture/reviewer jobs, and authority gating depend on the embedded harness. -- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load explicitly from `src/pi-extensions.ts` / `src/tui-client/.pi/extensions/*` and reusable TUI components under `src/tui-client/.pi/components/*`, with no root project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. +- **Acceptance:** A `BrunchPiProfile` (or equivalent module boundary) owns settings policy, resource-loader options, extension factories, keybinding/command policy, tool policy, and prompt policy; tests prove ambient context files/extensions/skills/prompt templates/themes do not load while explicit Brunch-owned extension-discovered resources can load intentionally through Pi `resources_discover`; settings that affect product behavior are overridden/sealed or documented as a Pi upstream seam; runtime extension factories now load explicitly from `src/tui-client/pi-extension-shell.ts` / `src/tui-client/.pi/extensions/*` and reusable TUI components under `src/tui-client/.pi/components/*`, with no root project-local Pi discovery path as product runtime. Full selected-state transcript entries under `brunch.agent_runtime_state` can be appended by Brunch helpers and replayed to reconstruct active operational mode, role preset/runtime bundle, strategy, and lens; turn prep composes prompt packs from base Brunch prompt + operational mode + role preset + strategy + lens + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules; `elicit` suppresses execute/dangerous tools such as raw `bash`/`write` unless explicitly allowed by the active bundle. - **Verification:** Inner — profile/runtimestate unit tests, prompt-composition snapshot tests, and tool-policy contract tests. Middle — ambient `.pi/` fixture/audit tests proving disabled discovery and sealed settings; explicit Brunch resource-injection test proving extension factories may inject Brunch-owned skills/prompts despite ambient `noSkills`/`noPromptTemplates`; JSONL reload/projection tests for runtime init/switch entries; before-agent-start/tool-call policy tests for `elicit`. Outer — manual TUI/RPC smoke that active role/lens/strategy changes are inspectable in transcript and reflected in prompt/tool posture rather than hidden UI state. - **Cross-cutting obligations:** Do not expose Pi's generic extension/skill/prompt/theme configuration to Brunch users; do not make Pi skills the primary authority for core operational prompts; keep raw Pi RPC behind Brunch adapters; keep runtime state linear-transcript-backed and compatible with compaction/session-boundary lifecycle hooks (`session_start`, `resources_discover`, `before_agent_start`, `context`, `tool_call`, `session_before_switch`, `session_before_compact`, `session_shutdown`). - **Traceability:** R25, R26 / D2-L, D23-L, D39-L, D40-L / I24-L, I25-L / A19-L @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The live `structured_exchange`/structured-exchange UI extension is now canonical under `src/tui-client/.pi/extensions/structured-exchange/index.ts` so Pi can auto-discover it when launched from `src/tui-client` for `/reload`-based iteration, while production still imports it explicitly through the sealed extension shell; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the repeatable parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, and `request_choice` are registered; review/candidate/multi-choice tools are named stubs but intentionally not registered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the ordering/parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 4bac6205..93932605 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -127,7 +127,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/pi-extensions.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/tui-client/pi-extension-shell.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, or modeling read-only posture as a volatile allowlist of every safe tool. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. @@ -217,7 +217,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; toolResult details may be the canonical structured response.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. For basic structured exchanges and questionnaires, the preferred seam is a registered Pi tool exchange: the assistant `toolCall` supplies call identity and arguments, the toolResult `content` supplies the human/model-readable answer summary, and the toolResult `details` supplies Brunch's self-contained structured response payload (status, mode, prompts/questions, options, answers, transport metadata). Anything that must survive reload/resume must be derivable from persisted toolCall arguments or final toolResult `content`/`details`; `renderCall` is only a projection of stored args for call/header/progress display, and `renderResult` should be a pure projection of durable `content`/`details` plus row-local args. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. In TUI mode, the tool may replace the default Pi editor with Brunch custom UI supporting single-choice, multi-choice, questionnaire, and optional freeform input. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, or relying on ephemeral dialog results detached from transcript truth. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"` while FE-744 proves whether same-assistant-message ordering is sufficient; if not, present/request must span separate tool-use steps. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -262,7 +262,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists exactly one terminal structured result (`answered`, `skipped`, `cancelled`, or `unavailable`) in Pi JSONL before the next agent turn consumes it. For structured-exchange/questionnaire tools, `toolResult.details` is self-contained enough for Brunch projection (status, mode, prompts/questions, options, answers, optional note, and transport metadata); the assistant tool-call args are correlation/position rather than the only semantic source. | partial (FE-744 now has one provider-valid `structured_exchange` Pi tool exported from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover self-contained `toolResult.details` and model-readable `content` for text/single-select/multi-select with terminal `answered`/`cancelled`/`unavailable` statuses, option notes, TUI custom UI, JSON-over-editor fallback, and response-side elicitation-exchange projection. The older provider-invalid `brunch_structured_question`/top-level-union tool path was retired. Questionnaire and `skipped` support remain product requirements rather than current extension coverage; Brunch public product relay remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | partial (FE-744 now registers sequential `present_question`, `present_options`, `request_answer`, and `request_choice` tools from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, and the legacy JSON-editor fallback helper used by the raw Pi RPC proof. `present_review_set`, `present_candidates`, `request_choices`, and `request_review` are named stubs but intentionally not registered. A real Pi ordering proof for same-assistant-message sequential present/request execution remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | @@ -391,9 +391,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | -| **Structured exchange tool** | A registered Pi tool used by the assistant to ask a typed question or questionnaire. Its toolResult `content` is the model-readable answer summary; its toolResult `details` is Brunch's projection payload. Durable UI after reload/resume must be rebuilt from persisted toolCall args plus final toolResult content/details, not from render lifecycle state. | -| **Structured exchange result details** | The self-contained structured payload in a structured-exchange/questionnaire toolResult: schema/version, status, mode, prompt/questions, options, answers, optional user note, and transport metadata. Brunch projection should not need to rehydrate unselected options solely from the assistant tool-call args. | -| **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained toolResult details for structured-exchange tools. It is transcript truth, not an ephemeral UI return value. | +| **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` family. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | +| **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. Its details include `exchangeId`, `presentTool`, `kind`, `status: presented`, `expectedRequest`, and `createdAtToolCallId`. | +| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, future `request_choices`, `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. It references the present half by `exchangeId` and present tool rather than repeating the presented markdown. | +| **Structured exchange result details** | The structured payload in a structured-exchange toolResult. Present details support tuple recovery; request details carry terminal status (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review/comment data. Brunch projection should not need render lifecycle state to rebuild the exchange. | +| **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained `request_*` toolResult details. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | | **Deferred observer/auditor job** | Optional durable async work item keyed by session id and elicitation-exchange entry-range ids. If introduced, it audits or backfills exchange analysis and survives process restart, but it is not the primary path for next-turn graph freshness. | diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 5b11c2e4..c8d40f43 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -28,7 +28,7 @@ import { registerBrunchOperationalModePolicy, runBrunchWorkspaceCommand, runBrunchWorkspaceAction, -} from "./pi-extensions.js" +} from "./tui-client/pi-extension-shell.js" import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, @@ -295,7 +295,10 @@ describe("Brunch TUI boot", () => { "find", "ls", "present_alternatives", - "structured_exchange", + "present_question", + "present_options", + "request_answer", + "request_choice", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( "Open the Brunch spec/session picker", @@ -690,7 +693,10 @@ describe("Brunch TUI boot", () => { "grep", "find", "ls", - "structured_exchange", + "present_question", + "present_options", + "request_answer", + "request_choice", "bash", "edit", "write", @@ -706,7 +712,16 @@ describe("Brunch TUI boot", () => { expect(registeredTools).toEqual(["read", "grep", "find", "ls"]) await events.session_start?.({} as never) expect(activeTools).toEqual([ - ["read", "grep", "find", "ls", "structured_exchange"], + [ + "read", + "grep", + "find", + "ls", + "present_question", + "present_options", + "request_answer", + "request_choice", + ], ]) await expect( Promise.resolve( @@ -714,7 +729,7 @@ describe("Brunch TUI boot", () => { ), ).resolves.toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, structured_exchange.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, present_question, present_options, request_answer, request_choice.", ), }) await expect( diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 3bde51cb..e66b71ef 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -22,7 +22,7 @@ import { import { chromeStateForWorkspace, createBrunchPiExtensionShell, -} from "./pi-extensions.js" +} from "./tui-client/pi-extension-shell.js" import { runWorkspaceDialogPreflight } from "./tui-client/.pi/components/workspace-dialog.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE, @@ -35,7 +35,7 @@ export { type BrunchChromeStage, type BrunchChromeState, type BrunchChromeWorkerStatus, -} from "./pi-extensions.js" +} from "./tui-client/pi-extension-shell.js" export { runWorkspaceDialogPreflight } from "./tui-client/.pi/components/workspace-dialog.js" export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator diff --git a/src/tui-client/.pi/README.md b/src/tui-client/.pi/README.md new file mode 100644 index 00000000..6d873bdb --- /dev/null +++ b/src/tui-client/.pi/README.md @@ -0,0 +1,12 @@ +# Brunch Pi extension iteration + +This directory is intentionally shaped like a project-local Pi resource tree so Brunch-owned extensions can be hot-reloaded while developing TUI affordances. + +```bash +cd src/tui-client +pi +# edit .pi/extensions/... or .pi/components/... +/reload +``` + +Production Brunch does not rely on ambient discovery from the repository root. The product shell imports these modules explicitly; tests for extensions/components live in `.pi/__tests__/`, not inside auto-discovered resource directories. diff --git a/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts index 145540e5..1639d767 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-extension.test.ts @@ -1,6 +1,9 @@ -import { Text } from "@earendil-works/pi-tui" import { describe, expect, it } from "vitest" -import registerStructuredExchange from "../extensions/structured-exchange/index.js" + +import registerStructuredExchange, { + PRESENT_OPTIONS_TOOL, + REQUEST_CHOICE_TOOL, +} from "../extensions/structured-exchange/index.js" const ansiPattern = new RegExp( `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, @@ -11,14 +14,14 @@ function stripAnsi(text: string): string { return text.replace(ansiPattern, "") } -function registerAskUserQuestionTool() { - let tool: any +function registerTools() { + const tools = new Map<string, any>() registerStructuredExchange({ registerTool(definition: any) { - tool = definition + tools.set(definition.name, definition) }, } as any) - return tool + return tools } const theme = { @@ -27,86 +30,41 @@ const theme = { bold: (text: string) => text, } -describe("structured_exchange renderer", () => { - it("renders prompt markdown before the question without duplicating options", () => { - const tool = registerAskUserQuestionTool() - - const component = tool.renderCall( - { - question: "Which path should we take?", - details: "## Preamble\n\nThis is caller-provided context.", - options: [ - { label: "First path", value: "first" }, - { label: "Second path", value: "second" }, - ], - }, - theme, - { argsComplete: true, lastComponent: new Text("stale", 0, 0) }, - ) - - const rendered = stripAnsi(component.render(80).join("\n")) - expect(rendered.indexOf("Preamble")).toBeLessThan( - rendered.indexOf("Question"), - ) - expect(rendered).toContain("This is caller-provided context.") - expect(rendered).toContain("Which path should we take?") - expect(rendered).not.toContain("First path") - expect(rendered).not.toContain("Second path") - expect(rendered).not.toContain("structured_exchange") - }) - - it("keeps renderCall component reuse type-safe across partial renders", () => { - const tool = registerAskUserQuestionTool() - const args = { question: "Proceed?" } - - const partial = tool.renderCall(args, theme, { argsComplete: false }) - expect(stripAnsi(partial.render(80).join("\n"))).toBe("") - - const first = tool.renderCall(args, theme, { - argsComplete: true, - lastComponent: partial, - }) - const second = tool.renderCall({ question: "Proceed now?" }, theme, { - argsComplete: true, - lastComponent: first, - }) +describe("structured exchange renderers", () => { + it("keeps renderCall non-semantic for present/request tools", () => { + const tools = registerTools() + const present = tools.get(PRESENT_OPTIONS_TOOL) + const request = tools.get(REQUEST_CHOICE_TOOL) - expect(second).toBe(first) - expect(stripAnsi(second.render(80).join("\n"))).toContain("Proceed now?") + expect( + stripAnsi(present.renderCall({}, theme, {}).render(80).join("\n")), + ).toBe("") + expect( + stripAnsi(request.renderCall({}, theme, {}).render(80).join("\n")), + ).toBe("") }) - it("summarizes selected and rejected options using original option indexes", () => { - const tool = registerAskUserQuestionTool() + it("renders present_options from tool result markdown content", async () => { + const present = registerTools().get(PRESENT_OPTIONS_TOOL) - const component = tool.renderResult( + const result = await present.execute( + "call-1", { - content: [{ type: "text", text: "User selected: 2. Second" }], - details: { - status: "answered", - question: "Pick one", - mode: "single-select", - answers: [ - { type: "option", label: "Second", value: "second", index: 2 }, - ], - }, - }, - { expanded: true, isPartial: false }, - theme, - { - args: { - options: [ - { label: "First", value: "first" }, - { label: "Second", value: "second" }, - { label: "Third", value: "third" }, - ], - }, + exchangeId: "x-1", + heading: "Choose", + body: "Body text", + options: [{ id: "a", content: "Alpha", rationale: "First" }], }, + undefined, + undefined, + {} as never, ) - const rendered = stripAnsi(component.render(80).join("\n")) - expect(rendered).toContain("✓ Selected: 2. Second") - expect(rendered).toContain("○ Rejected: 1. First") - expect(rendered).toContain("○ Rejected: 3. Third") - expect(rendered).not.toContain("○ Rejected: 2. Third") + const rendered = stripAnsi( + present.renderResult(result, {}, theme, {}).render(80).join("\n"), + ) + expect(rendered).toContain("Choose") + expect(rendered).toContain("Alpha") + expect(rendered).toContain("First") }) }) diff --git a/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts new file mode 100644 index 00000000..a2f922de --- /dev/null +++ b/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest" + +import registerStructuredExchange, { + PRESENT_OPTIONS_TOOL, + REQUEST_CHOICE_TOOL, +} from "../extensions/structured-exchange/index.js" +import { + findIncompleteStructuredExchangePresents, + isStructuredExchangePresentDetails, + isStructuredExchangeRequestDetails, +} from "../extensions/structured-exchange/shared/recovery.js" + +interface ToolTextContent { + type: "text" + text: string +} + +interface ToolExecutionResult { + content: ToolTextContent[] + details: any +} + +interface RegisteredTool { + name: string + executionMode?: string + execute: ( + toolCallId: string, + params: Record<string, unknown>, + signal: AbortSignal | undefined, + onUpdate: unknown, + ctx: unknown, + ) => Promise<ToolExecutionResult> + renderResult: ( + result: ToolExecutionResult, + options: unknown, + theme: FakeTheme, + context?: unknown, + ) => { render?: (width: number) => string[] } +} + +interface FakeTheme { + fg: (_color: string, text: string) => string + bold?: (text: string) => string +} + +const theme: FakeTheme = { + fg: (_color, text) => text, + bold: (text) => text, +} + +function registeredTools(): Map<string, RegisteredTool> { + const tools = new Map<string, RegisteredTool>() + registerStructuredExchange({ + registerTool(tool: RegisteredTool) { + tools.set(tool.name, tool) + }, + } as never) + return tools +} + +describe("structured exchange present/request tools", () => { + it("registers implemented present/request tools as sequential", () => { + const tools = registeredTools() + + expect([...tools.keys()]).toEqual([ + "present_question", + PRESENT_OPTIONS_TOOL, + "request_answer", + REQUEST_CHOICE_TOOL, + ]) + expect(tools.get(PRESENT_OPTIONS_TOOL)?.executionMode).toBe("sequential") + expect(tools.get(REQUEST_CHOICE_TOOL)?.executionMode).toBe("sequential") + }) + + it("persists a present_options result as markdown content plus recoverable details", async () => { + const present = registeredTools().get(PRESENT_OPTIONS_TOOL) + if (!present) throw new Error("present_options was not registered") + + const result = await present.execute( + "present-call-1", + { + exchangeId: "shell-location", + heading: "Where should the shell live?", + body: "Choose the module boundary for Brunch Pi extensions.", + options: [ + { + id: "root", + content: "Keep src/pi-extensions.ts", + rationale: "Smallest diff.", + }, + { + id: "tui", + content: "Move under src/tui-client", + rationale: "Clearer ownership.", + }, + ], + expectedRequestTool: REQUEST_CHOICE_TOOL, + }, + undefined, + undefined, + {} as never, + ) + + expect(result.content[0]?.text).toContain("## Where should the shell live?") + expect(result.content[0]?.text).toContain("Clearer ownership.") + expect(isStructuredExchangePresentDetails(result.details)).toBe(true) + expect(result.details).toMatchObject({ + exchangeId: "shell-location", + presentTool: PRESENT_OPTIONS_TOOL, + kind: "options", + status: "presented", + expectedRequest: { tool: REQUEST_CHOICE_TOOL, required: true }, + createdAtToolCallId: "present-call-1", + }) + + const rendered = result.content[0] + ? present.renderResult(result, {}, theme).render?.(80).join("\n") + : "" + expect(rendered).toContain("Where should the shell live?") + expect(rendered).toContain("Move under src/tui-client") + }) + + it("persists a request_choice response without repeating the presented content", async () => { + const request = registeredTools().get(REQUEST_CHOICE_TOOL) + if (!request) throw new Error("request_choice was not registered") + + const result = await request.execute( + "request-call-1", + { + exchangeId: "shell-location", + respondsToPresentTool: PRESENT_OPTIONS_TOOL, + prompt: "Select one option.", + choices: [ + { id: "root", label: "Keep src/pi-extensions.ts" }, + { id: "tui", label: "Move under src/tui-client" }, + ], + allowOther: false, + commentPrompt: "Optional comment", + }, + undefined, + undefined, + { + hasUI: true, + ui: { + select: async () => "Move under src/tui-client", + input: async () => "Aligns ownership with /reload iteration.", + }, + } as never, + ) + + expect(result.content[0]?.text).toContain("### Response") + expect(result.content[0]?.text).toContain("Move under src/tui-client") + expect(result.content[0]?.text).not.toContain("Clearer ownership") + expect(isStructuredExchangeRequestDetails(result.details)).toBe(true) + expect(result.details).toMatchObject({ + exchangeId: "shell-location", + requestTool: REQUEST_CHOICE_TOOL, + status: "answered", + respondsTo: { + exchangeId: "shell-location", + presentTool: PRESENT_OPTIONS_TOOL, + }, + choice: { id: "tui", label: "Move under src/tui-client" }, + comment: "Aligns ownership with /reload iteration.", + }) + }) + + it("detects an unmatched present result for recovery", () => { + const incomplete = findIncompleteStructuredExchangePresents([ + { + type: "message", + message: { + role: "toolResult", + toolName: PRESENT_OPTIONS_TOOL, + toolCallId: "present-call-1", + content: [{ type: "text", text: "## Offer" }], + details: { + schema: "brunch.structured_exchange.present", + schemaVersion: 1, + exchangeId: "shell-location", + presentTool: PRESENT_OPTIONS_TOOL, + kind: "options", + status: "presented", + expectedRequest: { tool: REQUEST_CHOICE_TOOL, required: true }, + createdAtToolCallId: "present-call-1", + }, + isError: false, + }, + }, + ]) + + expect(incomplete).toHaveLength(1) + expect(incomplete[0]?.details.exchangeId).toBe("shell-location") + }) +}) diff --git a/src/tui-client/.pi/__tests__/structured-exchange.test.ts b/src/tui-client/.pi/__tests__/structured-exchange.test.ts index 8d9953f8..f232f1b6 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange.test.ts @@ -1,419 +1,85 @@ import { describe, expect, it } from "vitest" -import registerStructuredExchange, { - STRUCTURED_EXCHANGE_TOOL, +import { buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, + structuredExchangeResultFromEditor, } from "../extensions/structured-exchange/index.js" -interface ToolTextContent { - type: "text" - text: string -} - -interface ToolExecutionResult { - content: ToolTextContent[] - details: any -} - -interface RenderableText { - render?: (width: number) => string[] -} - -interface RegisteredTool { - name: string - parameters: unknown - execute: ( - toolCallId: string, - params: Record<string, unknown>, - signal: AbortSignal | undefined, - onUpdate: unknown, - ctx: unknown, - ) => Promise<ToolExecutionResult> - renderResult: ( - result: ToolExecutionResult, - options: unknown, - theme: FakeTheme, - context?: unknown, - ) => RenderableText -} - -interface FakeTheme { - fg: (_color: string, text: string) => string -} - -const enter = "\r" -const down = "\x1b[B" -const up = "\x1b[A" -const space = " " -const theme: FakeTheme = { fg: (_color, text) => text } - -function registeredTool(): RegisteredTool { - let tool: RegisteredTool | undefined - registerStructuredExchange({ - registerTool: (registered: RegisteredTool) => { - tool = registered - }, - } as never) - if (!tool) throw new Error("tool was not registered") - return tool -} - -function contextDrivingCustom(inputs: string[], renders?: string[]) { - return { - hasUI: true, - ui: { - custom: async (factory: any) => { - let resolved: unknown = undefined - const component = factory( - { requestRender: () => {}, terminal: { rows: 30 } }, - theme, - {}, - (result: unknown) => { - resolved = result - }, - ) - renders?.push(component.render(80).join("\n")) - for (const input of inputs) { - component.handleInput(input) - renders?.push(component.render(80).join("\n")) - if (resolved !== undefined) return resolved - } - throw new Error("custom UI did not resolve") - }, - }, - } -} - -function contextEditingJson(edit: (payload: any) => void) { - return { - hasUI: true, - ui: { - editor: async (prefill: string) => { - const payload = JSON.parse(prefill) - edit(payload) - return JSON.stringify(payload) - }, - }, - } -} - -function optionParams(multiSelect = false): Record<string, unknown> { - return { - question: "Pick a path", - details: "Choose deliberately.", - options: [ - { label: "Alpha", value: "a" }, - { label: "Beta", value: "b" }, - ], - multiSelect, - } -} - -describe("structured exchange inline JIT editor", () => { - it("registers one provider-valid structured exchange question tool", () => { - const tool = registeredTool() - - expect(tool.name).toBe(STRUCTURED_EXCHANGE_TOOL) - expect(tool).toMatchObject({ - parameters: { type: "object" }, - }) - }) - - it("renders one inline optional editor after a single-select listed option", async () => { - const tool = registeredTool() - const renders: string[] = [] - - const result = await tool.execute( - "call-1", - optionParams(), - undefined, - undefined, - contextDrivingCustom([enter, ..."Add context", enter], renders), - ) - - expect( - renders.some((rendered) => rendered.includes("Optional context:")), - ).toBe(true) - expect(renders.join("\n")).not.toContain("Optional note:") - expect(result.details).toMatchObject({ - status: "answered", - mode: "single-select", - note: "Add context", - answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], - }) - expect(result.content[0]?.text).toContain("Add context") - }) - - it("uses the inline editor as required single-select Other text", async () => { - const tool = registeredTool() - const emptyAttemptRenders: string[] = [] - - const result = await tool.execute( - "call-1", - optionParams(), - undefined, - undefined, - contextDrivingCustom( - [down, down, enter, enter, ..."Custom", enter], - emptyAttemptRenders, - ), - ) - - expect(emptyAttemptRenders.join("\n")).toContain("Custom answer required:") - expect(result.details).toMatchObject({ - status: "answered", - mode: "single-select", - note: "", - answers: [{ type: "other", label: "Custom", value: "Custom" }], - }) - }) - - it("renders one global inline editor for multi-select listed options", async () => { - const tool = registeredTool() - const renders: string[] = [] - - const result = await tool.execute( - "call-1", - optionParams(true), - undefined, - undefined, - contextDrivingCustom( - [space, down, enter, ..."Shared note", down, down, enter], - renders, - ), - ) - - expect( - renders.some((rendered) => rendered.includes("Optional context:")), - ).toBe(true) - expect(renders.join("\n")).not.toContain("Optional note:") - expect(result.details).toMatchObject({ - status: "answered", +describe("structured exchange JSON-editor fallback compatibility helpers", () => { + it("builds schema-tagged editor prefill for the raw Pi RPC fallback proof", () => { + const prefill = buildStructuredExchangeEditorPrefill({ + question: "Pick paths", + context: "Use the fallback.", mode: "multi-select", - note: "Shared note", - answers: [ - { type: "option", label: "Alpha", value: "a", index: 1 }, - { type: "option", label: "Beta", value: "b", index: 2 }, - ], - }) - }) - - it("makes multi-select Other exclusive and required", async () => { - const tool = registeredTool() - - const result = await tool.execute( - "call-1", - optionParams(true), - undefined, - undefined, - contextDrivingCustom([ - space, - ..."Existing note becomes custom text", - down, - down, - enter, - enter, - ]), - ) - - expect(result.details).toMatchObject({ - status: "answered", - mode: "multi-select", - note: "", - answers: [ - { - type: "other", - label: "Existing note becomes custom text", - value: "Existing note becomes custom text", - }, + options: [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b", description: "Second" }, ], }) - }) - - it("preserves global listed-option context as selections change", async () => { - const tool = registeredTool() - const result = await tool.execute( - "call-1", - optionParams(true), - undefined, - undefined, - contextDrivingCustom([ - space, - ..."Global context", - down, - enter, - up, - enter, - down, - down, - down, - enter, - ]), - ) - - expect(result.details).toMatchObject({ - status: "answered", - mode: "multi-select", - note: "Global context", - answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], - }) - }) - - it("renders a non-empty note without rendering empty notes", async () => { - const tool = registeredTool() - const withNote = await tool.execute( - "call-1", - optionParams(), - undefined, - undefined, - contextDrivingCustom([enter, ..."Useful note", enter]), - ) - const emptyNote = await tool.execute( - "call-2", - optionParams(), - undefined, - undefined, - contextDrivingCustom([enter, enter]), - ) - - expect( - tool - .renderResult(withNote, undefined, theme, { - args: optionParams(), - }) - ?.render?.(80) - .join("\n"), - ).toContain("Note: Useful note") - expect( - tool - .renderResult(emptyNote, undefined, theme, { - args: optionParams(), - }) - ?.render?.(80) - .join("\n"), - ).not.toContain("Note:") - }) -}) - -describe("structured exchange RPC editor fallback", () => { - it("builds schema-tagged JSON with options and parses single-select notes", () => { - const prefill = JSON.parse( - buildStructuredExchangeEditorPrefill({ - question: "Pick a path", - context: "Choose deliberately.", - mode: "single-select", - options: [ - { label: "Alpha", value: "a" }, - { label: "Beta", value: "b" }, - ], - }), - ) - - expect(prefill).toMatchObject({ + expect(JSON.parse(prefill)).toMatchObject({ schema: "brunch.structured_exchange.editor", schemaVersion: 1, - question: "Pick a path", - context: "Choose deliberately.", - mode: "single-select", + question: "Pick paths", + context: "Use the fallback.", + mode: "multi-select", options: [ { index: 1, label: "Alpha", value: "a" }, - { index: 2, label: "Beta", value: "b" }, + { index: 2, label: "Beta", value: "b", description: "Second" }, ], response: { status: "cancelled", answers: [], note: "" }, }) - - prefill.response = { - status: "answered", - answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], - note: "Because it matches the brief.", - } - - expect( - parseStructuredExchangeEditorResponse(JSON.stringify(prefill)), - ).toEqual({ - status: "answered", - answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], - note: "Because it matches the brief.", - }) }) - it("uses ctx.ui.editor for single-select fallback and keeps empty notes explicit", async () => { - const tool = registeredTool() - - const result = await tool.execute( - "call-rpc-single", - optionParams(), - undefined, - undefined, - contextEditingJson((payload) => { - payload.response = { + it("parses answered editor JSON with explicit empty notes", () => { + const parsed = parseStructuredExchangeEditorResponse( + JSON.stringify({ + response: { status: "answered", - answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], note: "", - } + }, }), ) - expect(result.details).toMatchObject({ + expect(parsed).toEqual({ status: "answered", - mode: "single-select", - answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + answers: [{ type: "option", label: "Beta", value: "b", index: 2 }], note: "", }) }) - it("uses ctx.ui.editor for multi-select fallback with option notes", async () => { - const tool = registeredTool() - - const result = await tool.execute( - "call-rpc-multi", - optionParams(true), - undefined, - undefined, - contextEditingJson((payload) => { - payload.response = { - status: "answered", - answers: [ - { type: "option", label: "Alpha", value: "a", index: 1 }, - { type: "other", label: "Custom", value: "Custom" }, - ], - note: "Carry this nuance.", - } + it("returns legacy structured result details for the existing RPC proof", () => { + const prefill = JSON.parse( + buildStructuredExchangeEditorPrefill({ + question: "Pick paths", + mode: "single-select", + options: [{ label: "Alpha", value: "a" }], }), ) - - expect(result.details).toMatchObject({ + prefill.response = { status: "answered", - mode: "multi-select", - answers: [ - { type: "option", label: "Alpha", value: "a", index: 1 }, - { type: "other", label: "Custom", value: "Custom" }, - ], - note: "Carry this nuance.", - }) - }) - - it("returns a structured failure for invalid editor JSON", async () => { - const tool = registeredTool() + answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + note: "Add context", + } - const result = await tool.execute( - "call-rpc-invalid", - optionParams(), - undefined, - undefined, + const result = structuredExchangeResultFromEditor( { - hasUI: true, - ui: { editor: async () => "not json" }, + question: "Pick paths", + mode: "single-select", + options: [{ label: "Alpha", value: "a" }], }, + JSON.stringify(prefill), ) expect(result.details).toMatchObject({ - status: "unavailable", + schema: "brunch.structured_exchange.result", + status: "answered", mode: "single-select", - answers: [], + answers: [{ type: "option", label: "Alpha", value: "a", index: 1 }], + note: "Add context", + transport: { surface: "rpc-editor" }, }) - expect(result.content[0]?.text).toContain("invalid JSON") }) }) diff --git a/src/tui-client/.pi/extensions/structured-exchange/index.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts index 53f8058b..51381ff1 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/index.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/index.ts @@ -1,1173 +1,76 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { - type Component, - Editor, - type EditorTheme, - Key, - Markdown, - type MarkdownTheme, - Text, - matchesKey, - truncateToWidth, - wrapTextWithAnsi, -} from "@earendil-works/pi-tui" -import { Type } from "typebox" import { - STRUCTURED_EXCHANGE_RESULT_SCHEMA, - type StructuredExchangeAnswer, - type StructuredExchangeMode, - type StructuredExchangeOption, - type StructuredExchangeResultDetails, - type StructuredExchangeStatus, -} from "../../../../structured-exchange.js" - -export const STRUCTURED_EXCHANGE_TOOL = "structured_exchange" - -export type AskOption = StructuredExchangeOption - -interface DisplayOption extends AskOption { - id: string - index?: number - isOther?: boolean - isSubmit?: boolean -} - -interface TextAnswer { - type: "text" - label: string - value: string -} - -interface OptionAnswer { - type: "option" - label: string - value: string - index: number -} - -interface OtherAnswer { - type: "other" - label: string - value: string -} - -type AskAnswer = StructuredExchangeAnswer -type StructuredExchangeToolStatus = StructuredExchangeStatus -type StructuredExchangeToolMode = StructuredExchangeMode - -export type StructuredExchangeToolResultDetails = StructuredExchangeResultDetails - -interface OptionAnswerResult { - answers: AskAnswer[] - note: string -} - -export interface StructuredExchangeEditorPrefillParams { - question: string - context?: string - mode: Exclude<StructuredExchangeToolMode, "text"> - options: AskOption[] -} - -interface StructuredExchangeEditorResponse { - status: "answered" | "cancelled" - answers: AskAnswer[] - note: string -} - -const OptionSchema = Type.Object({ - label: Type.String({ - description: - 'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.', - }), - value: Type.Optional( - Type.String({ - description: - "Optional machine-readable value returned for the option. Defaults to the label.", - }), - ), - description: Type.Optional( - Type.String({ - description: "Optional extra detail shown below the option.", - }), - ), -}) - -const StructuredExchangeParams = Type.Object({ - question: Type.String({ - description: - "The single question to ask the user. Ask exactly one question per tool call.", - }), - details: Type.Optional( - Type.String({ - description: - "Optional extra context or instructions shown under the question.", - }), - ), - options: Type.Optional( - Type.Array(OptionSchema, { - description: - "Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.", - }), - ), - multiSelect: Type.Optional( - Type.Boolean({ - description: - "Set to true to allow multiple answers to be selected for a question.", - }), - ), -}) - -function normalizeOptions( - options: Array<{ - label: string - value?: string - description?: string - }> | undefined, -): AskOption[] { - return (options || []) - .map((option) => { - const normalized: AskOption = { - label: option.label.trim(), - value: option.value?.trim() || option.label.trim(), - } - const description = option.description?.trim() - if (description) normalized.description = description - return normalized - }) - .filter((option) => option.label.length > 0) -} - -function getOtherLabel(options: AskOption[]): string { - return options.some((option) => option.label.toLowerCase() === "other") - ? "Other (custom)" - : "Other" -} - -function createEditorTheme(theme: { - fg(color: string, text: string): string -}): EditorTheme { - return { - borderColor: (s) => theme.fg("accent", s), - selectList: { - selectedPrefix: (text) => theme.fg("accent", text), - selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), - }, - } -} - -function createPromptMarkdownTheme(theme: { - fg(color: string, text: string): string - bold?: (text: string) => string - italic?: (text: string) => string - underline?: (text: string) => string - strikethrough?: (text: string) => string -}): MarkdownTheme { - const fg = (color: string) => (text: string) => theme.fg(color, text) - const identity = (text: string) => text - return { - heading: fg("mdHeading"), - link: fg("mdLink"), - linkUrl: fg("mdLinkUrl"), - code: fg("mdCode"), - codeBlock: fg("mdCodeBlock"), - codeBlockBorder: fg("mdCodeBlockBorder"), - quote: fg("mdQuote"), - quoteBorder: fg("mdQuoteBorder"), - hr: fg("mdHr"), - listBullet: fg("mdListBullet"), - bold: theme.bold ?? identity, - italic: theme.italic ?? identity, - underline: theme.underline ?? identity, - strikethrough: theme.strikethrough ?? identity, - highlightCode: (code: string) => code.split("\n").map(fg("mdCodeBlock")), - } -} - -function formatAnswerForModel(answer: AskAnswer): string { - switch (answer.type) { - case "text": - return answer.label - case "other": - return `Other: ${answer.label}` - case "option": - return `${answer.index}. ${answer.label}` - } -} - -function answerSortRank(answer: AskAnswer): number { - switch (answer.type) { - case "option": - return answer.index - case "other": - return Number.MAX_SAFE_INTEGER - 1 - case "text": - return Number.MAX_SAFE_INTEGER - } -} - -function sortAnswers(answers: AskAnswer[]): AskAnswer[] { - return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)) -} - -function addWrapped( - lines: string[], - text: string, - width: number, - indent = "", -): void { - const contentWidth = Math.max(1, width - indent.length) - for (const line of wrapTextWithAnsi(text, contentWidth)) { - lines.push(truncateToWidth(`${indent}${line}`, width)) - } -} - -function buildQuestionMarkdown( - question: string, - context: string | undefined, -): string { - const sections = [`## Question\n\n${question}`] - - if (context) { - sections.unshift(context) - } - - return sections.join("\n\n---\n\n") -} - -function optionMatchesAnswer(option: AskOption, answer: AskAnswer): boolean { - if (answer.type !== "option") return false - return option.label === answer.label && option.value === answer.value -} - -function pickerTopBorder(theme: any, width: number): string { - return theme.fg("accent", "─".repeat(width)) -} - -function pickerBottomBorder(theme: any, width: number): string { - return theme.fg("accent", "─".repeat(width)) -} - -class PromptQuestionComponent implements Component { - private markdown: Markdown - - constructor( - private text: string, - markdownTheme: MarkdownTheme, - ) { - this.markdown = new Markdown(text, 0, 0, markdownTheme) - } - - setText(text: string): void { - this.text = text - this.markdown.setText(text) - } - - invalidate(): void { - this.markdown.invalidate() - } - - render(width: number): string[] { - return this.markdown.render(width) - } -} - -function buildStructuredResult( - status: StructuredExchangeToolStatus, - question: string, - mode: StructuredExchangeToolMode, - answers: AskAnswer[], - context?: string, - message?: string, - note?: string, - options?: AskOption[], - transport?: StructuredExchangeToolResultDetails["transport"], -): StructuredExchangeToolResultDetails { - const result: StructuredExchangeToolResultDetails = { - schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, - schemaVersion: 1, - status, - question, - mode, - answers, - } - if (context !== undefined) result.context = context - if (options !== undefined) { - result.options = options - result.rejectedOptions = options.filter( - (option) => - !answers.some((answer) => optionMatchesAnswer(option, answer)), - ) - } - if (note !== undefined) result.note = note - if (transport !== undefined) result.transport = transport - if (message !== undefined) result.message = message - return result -} - -function cancelledResult( - question: string, - mode: StructuredExchangeToolMode, - context?: string, -) { - const message = "User cancelled the question" - return { - content: [{ type: "text" as const, text: message }], - details: buildStructuredResult( - "cancelled", - question, - mode, - [], - context, - message, - ), - } -} - -function unavailableResult( - question: string, - mode: StructuredExchangeToolMode, - message: string, - context?: string, -) { - return { - content: [{ type: "text" as const, text: message }], - details: buildStructuredResult( - "unavailable", - question, - mode, - [], - context, - message, - ), - } -} - -function buildResult( - question: string, - context: string | undefined, - mode: StructuredExchangeToolMode, - answers: AskAnswer[], - note?: string, - options?: AskOption[], - transport?: StructuredExchangeToolResultDetails["transport"], -) { - let text: string - if (mode === "text") { - const answer = answers[0] - text = - answer && answer.label.trim().length > 0 - ? `User answered: ${answer.label}` - : "User submitted an empty response" - } else if (mode === "single-select") { - text = `User selected: ${formatAnswerForModel(answers[0]!)} ` - } else { - text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}` - } - - if (note) { - text = `${text.trim()}\nNote: ${note}` - } - - return { - content: [{ type: "text" as const, text: text.trim() }], - details: buildStructuredResult( - "answered", - question, - mode, - answers, - context, - undefined, - note, - options, - transport, - ), - } -} - -export function buildStructuredExchangeEditorPrefill( - params: StructuredExchangeEditorPrefillParams, -): string { - const payload: Record<string, unknown> = { - schema: "brunch.structured_exchange.editor", - schemaVersion: 1, - question: params.question, - mode: params.mode, - options: params.options.map((option, index) => ({ - index: index + 1, - label: option.label, - value: option.value, - ...(option.description ? { description: option.description } : {}), - })), - instructions: [ - "Edit only response.", - 'For a selected listed option, add an answer like {"type":"option","label":"Alpha","value":"alpha","index":1}.', - 'For Other, add an answer like {"type":"other","label":"Custom answer","value":"Custom answer"}.', - 'Set response.note to a string. Use "" when there is no additional note.', - ], - response: { status: "cancelled", answers: [], note: "" }, - } - if (params.context !== undefined) payload.context = params.context - return JSON.stringify(payload, null, 2) -} - -export function parseStructuredExchangeEditorResponse( - value: string, -): StructuredExchangeEditorResponse | null { - let parsed: unknown - try { - parsed = JSON.parse(value) - } catch { - return null - } - - if (!isRecord(parsed)) return null - const response = parsed.response - if (!isRecord(response)) return null - - if (response.status === "cancelled") { - return { status: "cancelled", answers: [], note: "" } - } - if (response.status !== "answered") return null - if (!Array.isArray(response.answers)) return null - if (typeof response.note !== "string") return null - - const answers = response.answers.map(parseEditorAnswer) - if (answers.some((answer) => answer === null)) return null - return { - status: "answered", - answers: sortAnswers(answers as AskAnswer[]), - note: response.note.trim(), - } -} - -export function structuredExchangeResultFromEditor( - params: StructuredExchangeEditorPrefillParams, - edited: string | undefined, -) { - const response = parseStructuredExchangeEditorResponse(edited ?? "") - if (edited === undefined) { - return cancelledResult(params.question, params.mode, params.context) - } - if (!response) { - return unavailableResult( - params.question, - params.mode, - "structured_exchange editor fallback returned invalid JSON", - params.context, - ) - } - if (response.status === "cancelled") { - return cancelledResult(params.question, params.mode, params.context) - } - return buildResult( - params.question, - params.context, - params.mode, - response.answers, - response.note, - params.options, - { surface: "rpc-editor" }, - ) -} - -function parseEditorAnswer(value: unknown): AskAnswer | null { - if (!isRecord(value)) return null - - if (value.type === "option") { - if ( - typeof value.label !== "string" || - typeof value.value !== "string" || - typeof value.index !== "number" || - !Number.isInteger(value.index) || - value.index < 1 - ) { - return null - } - return { - type: "option", - label: value.label, - value: value.value, - index: value.index, - } - } - - if (value.type === "other") { - if (typeof value.label !== "string" || typeof value.value !== "string") { - return null - } - return { type: "other", label: value.label, value: value.value } - } - - return null -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null -} - -async function askOptionsWithEditor( - ctx: any, - question: string, - context: string | undefined, - mode: Exclude<StructuredExchangeToolMode, "text">, - options: AskOption[], -): Promise<OptionAnswerResult | null | "invalid"> { - if (typeof ctx.ui.editor !== "function") return "invalid" - const prefillParams: StructuredExchangeEditorPrefillParams = { - question, - mode, - options, - } - if (context !== undefined) prefillParams.context = context - const edited = await ctx.ui.editor( - buildStructuredExchangeEditorPrefill(prefillParams), - ) - if (edited === undefined) return null - - const response = parseStructuredExchangeEditorResponse(edited) - if (!response) return "invalid" - if (response.status === "cancelled") return null - - if (mode === "single-select" && response.answers.length !== 1) { - return "invalid" - } - if (mode === "multi-select" && response.answers.length === 0) { - return "invalid" - } - return { answers: response.answers, note: response.note } -} - -async function askSingleChoice( - ctx: any, - _question: string, - _context: string | undefined, - options: AskOption[], -): Promise<OptionAnswerResult | null> { - const otherLabel = getOtherLabel(options) - const allOptions: DisplayOption[] = [ - ...options.map((option, index) => ({ - ...option, - id: `option:${index}`, - index: index + 1, - })), - { id: "other", label: otherLabel, value: "__other__", isOther: true }, - ] - - return ctx.ui.custom( - ( - tui: any, - theme: any, - _kb: any, - done: (result: OptionAnswerResult | null) => void, - ) => { - let optionIndex = 0 - let selectedAnswer: AskAnswer | undefined - let cachedLines: string[] | undefined - const editor = new Editor(tui, createEditorTheme(theme)) - - editor.onSubmit = (value) => { - const trimmed = value.trim() - if (!selectedAnswer) return - if (selectedAnswer.type === "other") { - if (!trimmed) return - done({ - answers: [{ type: "other", label: trimmed, value: trimmed }], - note: "", - }) - return - } - done({ answers: [selectedAnswer], note: trimmed }) - } - - function refresh() { - cachedLines = undefined - tui.requestRender() - } - - function selectFocusedOption() { - const selected = allOptions[optionIndex]! - if (selected.isOther) { - selectedAnswer = { type: "other", label: "", value: "" } - return - } - selectedAnswer = { - type: "option", - label: selected.label, - value: selected.value, - index: selected.index!, - } - } - - function handleInput(data: string) { - if (matchesKey(data, Key.up)) { - optionIndex = Math.max(0, optionIndex - 1) - if (selectedAnswer) selectFocusedOption() - refresh() - return - } - if (matchesKey(data, Key.down)) { - optionIndex = Math.min(allOptions.length - 1, optionIndex + 1) - if (selectedAnswer) selectFocusedOption() - refresh() - return - } - if (matchesKey(data, Key.enter)) { - if (selectedAnswer) { - editor.onSubmit?.(editor.getText()) - return - } - selectFocusedOption() - refresh() - return - } - if (matchesKey(data, Key.escape)) { - done(null) - return - } - - if (selectedAnswer) { - editor.handleInput(data) - refresh() - } - } - - function render(width: number): string[] { - if (cachedLines) return cachedLines - - const lines: string[] = [] - const add = (text: string) => lines.push(truncateToWidth(text, width)) - - add(pickerTopBorder(theme, width)) - - for (let i = 0; i < allOptions.length; i++) { - const option = allOptions[i]! - const selected = i === optionIndex - const prefix = selected ? theme.fg("accent", "> ") : " " - const label = option.isOther - ? option.label - : `${option.index}. ${option.label}` - const styled = selected - ? theme.fg("accent", label) - : theme.fg("text", label) - add(`${prefix}${styled}`) - if (option.description) { - const descriptionPrefix = selected ? theme.fg("accent", "│ ") : " " - addWrapped( - lines, - theme.fg("muted", option.description), - width, - descriptionPrefix, - ) - } - } - - if (selectedAnswer) { - lines.push("") - const isOther = selectedAnswer.type === "other" - add( - theme.fg( - isOther ? "warning" : "muted", - isOther ? " Custom answer required:" : " Optional context:", - ), - ) - for (const line of editor.render(Math.max(1, width - 2))) { - add(` ${line}`) - } - lines.push("") - add( - theme.fg( - "dim", - " ↑↓ change selection • Type context • Enter submit • Esc cancel", - ), - ) - } else { - lines.push("") - add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel")) - } - - add(pickerBottomBorder(theme, width)) - cachedLines = lines - return lines - } - - return { - render, - invalidate: () => { - cachedLines = undefined - }, - handleInput, - } - }, - ) -} - -async function askMultiChoice( - ctx: any, - _question: string, - _context: string | undefined, - options: AskOption[], -): Promise<OptionAnswerResult | null> { - const otherLabel = getOtherLabel(options) - const choiceItems: DisplayOption[] = options.map((option, index) => ({ - ...option, - id: `option:${index}`, - index: index + 1, - })) - const submitItem: DisplayOption = { - id: "submit", - label: "Submit", - value: "__submit__", - isSubmit: true, - } - const allItems: DisplayOption[] = [ - ...choiceItems, - { id: "other", label: otherLabel, value: "__other__", isOther: true }, - submitItem, - ] - - return ctx.ui.custom( - ( - tui: any, - theme: any, - _kb: any, - done: (result: OptionAnswerResult | null) => void, - ) => { - let optionIndex = 0 - let cachedLines: string[] | undefined - const selected = new Map<string, AskAnswer>() - const editor = new Editor(tui, createEditorTheme(theme)) - let otherSelected = false - - editor.onSubmit = (value) => { - const trimmed = value.trim() - if (otherSelected) { - if (!trimmed) return - done({ - answers: [{ type: "other", label: trimmed, value: trimmed }], - note: "", - }) - return - } - if (selected.size > 0) { - done({ - answers: sortAnswers(Array.from(selected.values())), - note: trimmed, - }) - } - } - - function refresh() { - cachedLines = undefined - tui.requestRender() - } - - function toggleOption(item: DisplayOption) { - otherSelected = false - if (selected.has(item.id)) { - selected.delete(item.id) - } else { - selected.set(item.id, { - type: "option", - label: item.label, - value: item.value, - index: item.index!, - }) - } - refresh() - } - - function handleInput(data: string) { - if (matchesKey(data, Key.up)) { - optionIndex = Math.max(0, optionIndex - 1) - refresh() - return - } - if (matchesKey(data, Key.down)) { - optionIndex = Math.min(allItems.length - 1, optionIndex + 1) - refresh() - return - } - - const current = allItems[optionIndex]! - if (matchesKey(data, Key.space)) { - if (otherSelected || selected.size > 0) { - editor.handleInput(data) - refresh() - return - } - if (current.isSubmit) return - if (current.isOther) { - selected.clear() - otherSelected = true - refresh() - return - } - toggleOption(current) - return - } - - if (matchesKey(data, Key.enter)) { - if (current.isSubmit) { - editor.onSubmit?.(editor.getText()) - return - } - if (current.isOther && otherSelected) { - editor.onSubmit?.(editor.getText()) - return - } - if (current.isOther) { - selected.clear() - otherSelected = true - refresh() - return - } - toggleOption(current) - return - } - - if (matchesKey(data, Key.escape)) { - done(null) - return - } - - if (otherSelected || selected.size > 0) { - editor.handleInput(data) - refresh() - } - } - - function render(width: number): string[] { - if (cachedLines) return cachedLines - - const lines: string[] = [] - const add = (text: string) => lines.push(truncateToWidth(text, width)) - - add(pickerTopBorder(theme, width)) - - for (let i = 0; i < allItems.length; i++) { - const item = allItems[i]! - const isFocused = i === optionIndex - const prefix = isFocused ? theme.fg("accent", "> ") : " " - - if (item.isSubmit) { - const label = - selected.size > 0 || otherSelected - ? `✓ ${item.label} (${ - otherSelected ? 1 : selected.size - } selected)` - : `○ ${item.label}` - const styled = isFocused - ? theme.fg("accent", label) - : theme.fg( - selected.size > 0 || otherSelected ? "success" : "dim", - label, - ) - add(`${prefix}${styled}`) - continue - } - - if (item.isOther) { - const marker = otherSelected ? "[x]" : "[ ]" - const suffix = otherSelected ? ` — ${editor.getText?.() ?? ""}` : "" - const styled = isFocused - ? theme.fg("accent", `${marker} ${item.label}${suffix}`) - : theme.fg( - otherSelected ? "success" : "text", - `${marker} ${item.label}${suffix}`, - ) - add(`${prefix}${styled}`) - continue - } - - const checked = selected.has(item.id) - const marker = checked ? "[x]" : "[ ]" - const label = `${marker} ${item.index}. ${item.label}` - const styled = isFocused - ? theme.fg("accent", label) - : theme.fg(checked ? "success" : "text", label) - add(`${prefix}${styled}`) - if (item.description) { - const descriptionPrefix = isFocused - ? theme.fg("accent", "│ ") - : " " - addWrapped( - lines, - theme.fg("muted", item.description), - width, - descriptionPrefix, - ) - } - } - - if (otherSelected || selected.size > 0) { - lines.push("") - add( - theme.fg( - otherSelected ? "warning" : "muted", - otherSelected ? " Custom answer required:" : " Optional context:", - ), - ) - for (const line of editor.render(Math.max(1, width - 2))) { - add(` ${line}`) - } - lines.push("") - add( - theme.fg( - "dim", - " ↑↓ change focus • Enter toggle/submit • Type context • Esc cancel", - ), - ) - } else { - lines.push("") - if (selected.size === 0 && !otherSelected) { - add( - theme.fg( - "warning", - " Select at least one answer before submitting.", - ), - ) - } - add( - theme.fg( - "dim", - " ↑↓ navigate • Space toggle • Enter edit/submit • Esc cancel", - ), - ) - } - - add(pickerBottomBorder(theme, width)) - cachedLines = lines - return lines - } - - return { - render, - invalidate: () => { - cachedLines = undefined - }, - handleInput, - } - }, - ) -} - -let uiLock: Promise<void> = Promise.resolve() - -function withUILock<T>(fn: () => Promise<T>): Promise<T> { - const previous = uiLock - let release: (() => void) | undefined - uiLock = new Promise<void>((resolve) => { - release = resolve - }) - return previous.then(fn).finally(() => release?.()) -} + PRESENT_CANDIDATES_TOOL, + presentCandidatesTool, +} from "./present-candidates.js" +import { PRESENT_OPTIONS_TOOL, presentOptionsTool } from "./present-options.js" +import { + PRESENT_QUESTION_TOOL, + presentQuestionTool, +} from "./present-question.js" +import { + PRESENT_REVIEW_SET_TOOL, + presentReviewSetTool, +} from "./present-review-set.js" +import { REQUEST_ANSWER_TOOL, requestAnswerTool } from "./request-answer.js" +import { REQUEST_CHOICE_TOOL, requestChoiceTool } from "./request-choice.js" +import { REQUEST_CHOICES_TOOL, requestChoicesTool } from "./request-choices.js" +import { REQUEST_REVIEW_TOOL, requestReviewTool } from "./request-review.js" + +export type { StructuredExchangeResultDetails as StructuredExchangeToolResultDetails } from "../../../../structured-exchange.js" + +export { + buildStructuredExchangeEditorPrefill, + parseStructuredExchangeEditorResponse, + structuredExchangeResultFromEditor, + type StructuredExchangeEditorPrefillParams, +} from "./shared/editor-fallback.js" +export { + findIncompleteStructuredExchangePresents, + isStructuredExchangePresentDetails, + isStructuredExchangeRequestDetails, +} from "./shared/recovery.js" +export { + STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + type PresentToolName, + type RequestToolName, + type StructuredExchangePresentDetails, + type StructuredExchangeRequestDetails, +} from "./shared/model.js" +export { + PRESENT_CANDIDATES_TOOL, + PRESENT_OPTIONS_TOOL, + PRESENT_QUESTION_TOOL, + PRESENT_REVIEW_SET_TOOL, + REQUEST_ANSWER_TOOL, + REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, + REQUEST_REVIEW_TOOL, +} + +export const STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS = [ + presentQuestionTool, + presentOptionsTool, + requestAnswerTool, + requestChoiceTool, +] as const + +export const STRUCTURED_EXCHANGE_STUB_TOOL_NAMES = [ + PRESENT_REVIEW_SET_TOOL, + PRESENT_CANDIDATES_TOOL, + REQUEST_CHOICES_TOOL, + REQUEST_REVIEW_TOOL, +] as const + +void presentReviewSetTool +void presentCandidatesTool +void requestChoicesTool +void requestReviewTool export default function registerStructuredExchange(pi: ExtensionAPI) { - pi.registerTool({ - name: STRUCTURED_EXCHANGE_TOOL, - label: "Structured exchange", - renderShell: "self", - description: - "Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.", - promptSnippet: - "Ask exactly one clarifying, preference, confirmation, or decision question before continuing.", - promptGuidelines: [ - "Use structured_exchange when a user decision would materially affect the next step.", - "Ask exactly one question per structured_exchange tool call.", - "Use structured_exchange with multiSelect: true only when multiple answers to the same question are valid.", - 'structured_exchange always lets the user select "Other" when options are provided.', - ], - parameters: StructuredExchangeParams, - - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const options = normalizeOptions(params.options) - const context = params.details?.trim() || undefined - const mode: StructuredExchangeToolMode = - options.length === 0 - ? "text" - : params.multiSelect - ? "multi-select" - : "single-select" - - if (signal?.aborted) { - return cancelledResult(params.question, mode, context) - } - - if (!ctx.hasUI) { - return unavailableResult( - params.question, - mode, - "structured_exchange requires interactive mode UI", - context, - ) - } - - return withUILock(async () => { - if (mode === "text") { - const answer = await ctx.ui.editor("Answer the question shown above") - if (answer === undefined) { - return cancelledResult(params.question, mode, context) - } - const trimmed = answer.trim() - return buildResult(params.question, context, mode, [ - { type: "text", label: trimmed, value: trimmed }, - ]) - } - - if (mode === "single-select") { - const result = - typeof ctx.ui.custom === "function" - ? await askSingleChoice(ctx, params.question, context, options) - : await askOptionsWithEditor( - ctx, - params.question, - context, - mode, - options, - ) - if (result === "invalid") { - return unavailableResult( - params.question, - mode, - "structured_exchange editor fallback returned invalid JSON", - context, - ) - } - if (!result) { - return cancelledResult(params.question, mode, context) - } - return buildResult( - params.question, - context, - mode, - result.answers, - result.note, - options, - typeof ctx.ui.custom === "function" - ? { surface: "tui-custom" } - : { surface: "rpc-editor" }, - ) - } - - const result = - typeof ctx.ui.custom === "function" - ? await askMultiChoice(ctx, params.question, context, options) - : await askOptionsWithEditor( - ctx, - params.question, - context, - mode, - options, - ) - if (result === "invalid") { - return unavailableResult( - params.question, - mode, - "structured_exchange editor fallback returned invalid JSON", - context, - ) - } - if (!result) { - return cancelledResult(params.question, mode, context) - } - return buildResult( - params.question, - context, - mode, - result.answers, - result.note, - options, - typeof ctx.ui.custom === "function" - ? { surface: "tui-custom" } - : { surface: "rpc-editor" }, - ) - }) - }, - - renderCall(args, _theme, context) { - if (!context.argsComplete) { - return new Text("", 0, 0) - } - const text = buildQuestionMarkdown( - args.question, - args.details?.trim() || undefined, - ) - const prompt = - context.lastComponent instanceof PromptQuestionComponent - ? context.lastComponent - : undefined - if (prompt) { - prompt.setText(text) - return prompt - } - return new PromptQuestionComponent( - text, - createPromptMarkdownTheme(_theme), - ) - }, - - renderResult(result, _options, theme, context) { - const details = - result.details as StructuredExchangeToolResultDetails | undefined - if (!details) { - const first = result.content[0] - return new Text(first?.type === "text" ? first.text : "", 0, 0) - } - - if (details.status === "cancelled") { - return new Text( - theme.fg("warning", details.message || "Cancelled"), - 0, - 0, - ) - } - - if (details.status === "unavailable") { - return new Text( - theme.fg( - "warning", - details.message || "structured_exchange unavailable", - ), - 0, - 0, - ) - } - - const selectedLines = details.answers.map((answer) => { - switch (answer.type) { - case "text": - return `${theme.fg("success", "✓ Selected: ")}${theme.fg("accent", answer.label || "(empty response)")}` - case "other": - return `${theme.fg("success", "✓ Selected: ")}${theme.fg("muted", "Other: ")}${theme.fg("accent", answer.label)}` - case "option": - return `${theme.fg("success", "✓ Selected: ")}${theme.fg("accent", `${answer.index}. ${answer.label}`)}` - } - }) - const optionArgs = context?.args as { options?: AskOption[] } | undefined - const options = normalizeOptions(optionArgs?.options) - const rejectedLines = options.flatMap((option, index) => - details.answers.some((answer) => optionMatchesAnswer(option, answer)) - ? [] - : [theme.fg("dim", `○ Rejected: ${index + 1}. ${option.label}`)], - ) - - const noteLines = - details.note && details.note.length > 0 - ? [ - `${theme.fg("muted", "Note: ")}${theme.fg("accent", details.note)}`, - ] - : [] - - return new Text( - [...selectedLines, ...rejectedLines, ...noteLines].join("\n"), - 0, - 0, - ) - }, - }) + for (const tool of STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS) { + pi.registerTool(tool) + } } diff --git a/src/tui-client/.pi/extensions/structured-exchange/present-candidates.ts b/src/tui-client/.pi/extensions/structured-exchange/present-candidates.ts new file mode 100644 index 00000000..4e5d0259 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/present-candidates.ts @@ -0,0 +1,5 @@ +export const PRESENT_CANDIDATES_TOOL = "present_candidates" as const + +// Stubbed intentionally: candidate presentation semantics are named now, but +// not registered until candidate artefact rendering has a product owner. +export const presentCandidatesTool = undefined diff --git a/src/tui-client/.pi/extensions/structured-exchange/present-options.ts b/src/tui-client/.pi/extensions/structured-exchange/present-options.ts new file mode 100644 index 00000000..24215e3b --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/present-options.ts @@ -0,0 +1,107 @@ +import { defineTool } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +import { markdownEscape, renderMarkdownResult } from "./shared/markdown.js" +import { + STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + type StructuredExchangePresentDetails, +} from "./shared/model.js" + +export const PRESENT_OPTIONS_TOOL = "present_options" as const + +const PresentedOptionSchema = Type.Object({ + id: Type.String({ + description: "Stable option id for later request_* response correlation.", + }), + content: Type.String({ description: "Markdown-readable option content." }), + rationale: Type.Optional( + Type.String({ + description: "Why this option is plausible or recommended.", + }), + ), +}) + +export const PresentOptionsParams = Type.Object({ + exchangeId: Type.String({ + description: + "Stable id tying this presented offer to the later request_* response.", + }), + heading: Type.String({ description: "Heading for the presented options." }), + body: Type.Optional( + Type.String({ description: "Markdown body shown before the options." }), + ), + options: Type.Array(PresentedOptionSchema, { + description: "Options to display.", + }), + expectedRequestTool: Type.Optional( + Type.Union( + [Type.Literal("request_choice"), Type.Literal("request_choices")], + { + description: "The request_* tool expected to collect the response.", + }, + ), + ), +}) + +interface OptionsMarkdownParams { + heading: string + body?: string + options: Array<{ + id: string + content: string + rationale?: string + }> +} + +function optionsMarkdown(params: OptionsMarkdownParams): string { + const lines = [`## ${params.heading.trim()}`] + const body = params.body?.trim() + if (body) lines.push("", body) + params.options.forEach((option, index) => { + lines.push("", `### ${index + 1}. ${option.content.trim()}`) + const rationale = option.rationale?.trim() + if (rationale) lines.push("", `**Rationale:** ${rationale}`) + lines.push("", `<!-- option-id: ${markdownEscape(option.id)} -->`) + }) + return lines.join("\n") +} + +export const presentOptionsTool = defineTool({ + name: PRESENT_OPTIONS_TOOL, + label: "Present options", + description: + "Persist and display a set of structured options as the present half of a Brunch structured exchange. Call the matching request_choice/request_choices tool after this result is available.", + promptSnippet: "Present structured options before requesting a choice", + promptGuidelines: [ + "Use present_options before request_choice or request_choices.", + "Do not rely on renderCall for semantic display; the durable offer is this tool result.", + ], + parameters: PresentOptionsParams, + executionMode: "sequential", + + async execute(toolCallId, params) { + const markdown = optionsMarkdown(params) + const details: StructuredExchangePresentDetails = { + schema: STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + presentTool: PRESENT_OPTIONS_TOOL, + kind: "options", + status: "presented", + expectedRequest: { + tool: params.expectedRequestTool ?? "request_choice", + required: true, + }, + createdAtToolCallId: toolCallId, + } + return { content: [{ type: "text" as const, text: markdown }], details } + }, + + renderCall() { + return renderMarkdownResult({ content: [] }) + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme) + }, +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/present-question.ts b/src/tui-client/.pi/extensions/structured-exchange/present-question.ts new file mode 100644 index 00000000..eb1f530a --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/present-question.ts @@ -0,0 +1,70 @@ +import { defineTool } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +import { renderMarkdownResult } from "./shared/markdown.js" +import { + STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + type StructuredExchangePresentDetails, +} from "./shared/model.js" + +export const PRESENT_QUESTION_TOOL = "present_question" as const + +export const PresentQuestionParams = Type.Object({ + exchangeId: Type.String({ + description: + "Stable id tying this question to the later request_answer response.", + }), + heading: Type.String({ description: "Question heading." }), + body: Type.Optional( + Type.String({ + description: "Markdown body for context before the answer request.", + }), + ), + expectedRequestTool: Type.Optional(Type.Literal("request_answer")), +}) + +export const presentQuestionTool = defineTool({ + name: PRESENT_QUESTION_TOOL, + label: "Present question", + description: + "Persist and display a structured question as the present half of a Brunch structured exchange. Call request_answer after this result is available.", + promptSnippet: "Present a structured question before requesting an answer", + promptGuidelines: [ + "Use present_question before request_answer.", + "The durable user-visible question is this tool result, not renderCall.", + ], + parameters: PresentQuestionParams, + executionMode: "sequential", + + async execute(toolCallId, params) { + const body = params.body?.trim() + const markdown = [ + `## ${params.heading.trim()}`, + body ? `\n${body}` : undefined, + ] + .filter(Boolean) + .join("\n") + const details: StructuredExchangePresentDetails = { + schema: STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + presentTool: PRESENT_QUESTION_TOOL, + kind: "question", + status: "presented", + expectedRequest: { + tool: params.expectedRequestTool ?? "request_answer", + required: true, + }, + createdAtToolCallId: toolCallId, + } + return { content: [{ type: "text" as const, text: markdown }], details } + }, + + renderCall() { + return renderMarkdownResult({ content: [] }) + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme) + }, +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/present-review-set.ts b/src/tui-client/.pi/extensions/structured-exchange/present-review-set.ts new file mode 100644 index 00000000..6247f574 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/present-review-set.ts @@ -0,0 +1,5 @@ +export const PRESENT_REVIEW_SET_TOOL = "present_review_set" as const + +// Stubbed intentionally: review-set presentation semantics are named now, but +// not registered until review-set proposal/acceptance flow lands. +export const presentReviewSetTool = undefined diff --git a/src/tui-client/.pi/extensions/structured-exchange/request-answer.ts b/src/tui-client/.pi/extensions/structured-exchange/request-answer.ts new file mode 100644 index 00000000..8654601a --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/request-answer.ts @@ -0,0 +1,101 @@ +import { defineTool } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +import { renderMarkdownResult } from "./shared/markdown.js" +import { + STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + type StructuredExchangeRequestDetails, +} from "./shared/model.js" + +export const REQUEST_ANSWER_TOOL = "request_answer" as const + +export const RequestAnswerParams = Type.Object({ + exchangeId: Type.String({ + description: + "The structured exchange id from the corresponding present_question entry.", + }), + respondsToPresentTool: Type.Optional(Type.Literal("present_question")), + prompt: Type.String({ + description: + "Short live-input prompt. Do not repeat the presented question body.", + }), +}) + +function responseMarkdown(details: StructuredExchangeRequestDetails): string { + if (details.status === "cancelled") + return "### Response\n\n_User cancelled the request._" + if (details.status === "unavailable") { + return `### Response\n\n_${details.message ?? "Response UI unavailable."}_` + } + return ["### Response", "", details.answer ?? ""].join("\n") +} + +export const requestAnswerTool = defineTool({ + name: REQUEST_ANSWER_TOOL, + label: "Request answer", + description: + "Collect a freeform user answer as the request half of a Brunch structured exchange. Use only after present_question.", + promptSnippet: "Request a freeform answer after presenting a question", + promptGuidelines: [ + "Use request_answer only after the matching present_question tool.", + "Do not repeat the present_question markdown content in request_answer parameters; reference it by exchangeId.", + ], + parameters: RequestAnswerParams, + executionMode: "sequential", + + async execute(toolCallId, params, _signal, _onUpdate, ctx) { + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1 as const, + exchangeId: params.exchangeId, + requestTool: REQUEST_ANSWER_TOOL, + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool ?? "present_question", + }, + createdAtToolCallId: toolCallId, + } + + if (!ctx.hasUI || typeof ctx.ui.editor !== "function") { + const details: StructuredExchangeRequestDetails = { + ...base, + status: "unavailable", + message: "request_answer requires interactive UI", + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + } + + const answer = await ctx.ui.editor(params.prompt) + if (answer === undefined) { + const details: StructuredExchangeRequestDetails = { + ...base, + status: "cancelled", + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + } + + const details: StructuredExchangeRequestDetails = { + ...base, + status: "answered", + answer: answer.trim(), + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + }, + + renderCall() { + return renderMarkdownResult({ content: [] }) + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme) + }, +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/request-choice.ts b/src/tui-client/.pi/extensions/structured-exchange/request-choice.ts new file mode 100644 index 00000000..758ab442 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/request-choice.ts @@ -0,0 +1,200 @@ +import { defineTool } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +import { + normalizeOptionalText, + renderMarkdownResult, +} from "./shared/markdown.js" +import { + STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + type StructuredExchangeChoice, + type StructuredExchangeRequestDetails, +} from "./shared/model.js" + +export const REQUEST_CHOICE_TOOL = "request_choice" as const + +const ChoiceSchema = Type.Object({ + id: Type.String({ + description: "Stable choice id from the corresponding present_* entry.", + }), + label: Type.String({ + description: "Short choice label shown in the live selection UI.", + }), +}) + +export const RequestChoiceParams = Type.Object({ + exchangeId: Type.String({ + description: + "The structured exchange id from the corresponding present_* entry.", + }), + respondsToPresentTool: Type.Union([ + Type.Literal("present_options"), + Type.Literal("present_candidates"), + ]), + prompt: Type.String({ + description: + "Short live-input prompt. Do not repeat the presented content.", + }), + choices: Type.Array(ChoiceSchema, { + description: "Choices available for this response.", + }), + allowOther: Type.Optional( + Type.Boolean({ description: "Whether the user may choose Other." }), + ), + commentPrompt: Type.Optional( + Type.String({ + description: "Prompt for optional comment after a listed choice.", + }), + ), +}) + +function responseMarkdown(details: StructuredExchangeRequestDetails): string { + if (details.status === "cancelled") + return "### Response\n\n_User cancelled the request._" + if (details.status === "unavailable") { + return `### Response\n\n_${details.message ?? "Response UI unavailable."}_` + } + const lines = ["### Response"] + if (details.choice) lines.push("", `Selected: **${details.choice.label}**`) + if (details.comment) lines.push("", "Comment:", "", `> ${details.comment}`) + return lines.join("\n") +} + +function choiceByLabel( + choices: readonly StructuredExchangeChoice[], + selected: string, +): StructuredExchangeChoice | undefined { + return choices.find( + (choice) => choice.label === selected || choice.id === selected, + ) +} + +export const requestChoiceTool = defineTool({ + name: REQUEST_CHOICE_TOOL, + label: "Request choice", + description: + "Collect one user choice as the request half of a Brunch structured exchange. Use only after the corresponding present_* tool result has displayed the offer content.", + promptSnippet: "Request one choice after presenting a structured offer", + promptGuidelines: [ + "Use request_choice only after the matching present_options or present_candidates tool.", + "Do not repeat the present_* markdown content in request_choice parameters; reference it by exchangeId.", + ], + parameters: RequestChoiceParams, + executionMode: "sequential", + + async execute(toolCallId, params, _signal, _onUpdate, ctx) { + const choices: StructuredExchangeChoice[] = params.choices.map( + (choice) => ({ + id: choice.id, + label: choice.label, + }), + ) + const unavailable = (message: string) => { + const details: StructuredExchangeRequestDetails = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + requestTool: REQUEST_CHOICE_TOOL, + status: "unavailable", + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool, + }, + message, + createdAtToolCallId: toolCallId, + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + } + + if (!ctx.hasUI || typeof ctx.ui.select !== "function") { + return unavailable("request_choice requires interactive UI") + } + + const labels = [ + ...choices.map((choice) => choice.label), + ...(params.allowOther ? ["Other"] : []), + ] + const selected = await ctx.ui.select(params.prompt, labels) + if (selected === undefined) { + const details: StructuredExchangeRequestDetails = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + requestTool: REQUEST_CHOICE_TOOL, + status: "cancelled", + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool, + }, + createdAtToolCallId: toolCallId, + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + } + + const picked = choiceByLabel(choices, selected) + let choice = picked + let comment = "" + if (!choice) { + const other = + typeof ctx.ui.input === "function" + ? await ctx.ui.input("Other", "Describe your answer") + : undefined + if (other === undefined || other.trim().length === 0) { + const details: StructuredExchangeRequestDetails = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + requestTool: REQUEST_CHOICE_TOOL, + status: "cancelled", + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool, + }, + createdAtToolCallId: toolCallId, + } + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + } + choice = { id: "other", label: other.trim() } + } else if (typeof ctx.ui.input === "function") { + comment = + (await ctx.ui.input(params.commentPrompt ?? "Optional comment")) ?? "" + } + + const details: StructuredExchangeRequestDetails = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1, + exchangeId: params.exchangeId, + requestTool: REQUEST_CHOICE_TOOL, + status: "answered", + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool, + }, + choice, + createdAtToolCallId: toolCallId, + } + const normalizedComment = normalizeOptionalText(comment) + if (normalizedComment !== undefined) details.comment = normalizedComment + return { + content: [{ type: "text" as const, text: responseMarkdown(details) }], + details, + } + }, + + renderCall() { + return renderMarkdownResult({ content: [] }) + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme) + }, +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts b/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts new file mode 100644 index 00000000..a46a0ba2 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts @@ -0,0 +1,5 @@ +export const REQUEST_CHOICES_TOOL = "request_choices" as const + +// Stubbed intentionally: multi-choice response semantics are named now, but the +// implementation waits until the present/request ordering proof is complete. +export const requestChoicesTool = undefined diff --git a/src/tui-client/.pi/extensions/structured-exchange/request-review.ts b/src/tui-client/.pi/extensions/structured-exchange/request-review.ts new file mode 100644 index 00000000..67eabbf4 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/request-review.ts @@ -0,0 +1,5 @@ +export const REQUEST_REVIEW_TOOL = "request_review" as const + +// Stubbed intentionally: review response semantics are named now, but not +// registered until review-set proposal/acceptance flow lands. +export const requestReviewTool = undefined diff --git a/src/tui-client/.pi/extensions/structured-exchange/shared/editor-fallback.ts b/src/tui-client/.pi/extensions/structured-exchange/shared/editor-fallback.ts new file mode 100644 index 00000000..cc252267 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/shared/editor-fallback.ts @@ -0,0 +1,203 @@ +import { + STRUCTURED_EXCHANGE_RESULT_SCHEMA, + type StructuredExchangeAnswer, + type StructuredExchangeMode, + type StructuredExchangeOption, +} from "../../../../../structured-exchange.js" +import { isRecord } from "./model.js" + +export interface StructuredExchangeEditorPrefillParams { + question: string + context?: string + mode: Exclude<StructuredExchangeMode, "text"> + options: StructuredExchangeOption[] +} + +interface StructuredExchangeEditorResponse { + status: "answered" | "cancelled" + answers: StructuredExchangeAnswer[] + note: string +} + +function answerSortRank(answer: StructuredExchangeAnswer): number { + switch (answer.type) { + case "option": + return answer.index + case "other": + return Number.MAX_SAFE_INTEGER - 1 + case "text": + return Number.MAX_SAFE_INTEGER + } +} + +function sortAnswers( + answers: StructuredExchangeAnswer[], +): StructuredExchangeAnswer[] { + return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)) +} + +function parseEditorAnswer(value: unknown): StructuredExchangeAnswer | null { + if (!isRecord(value)) return null + + if (value.type === "option") { + if ( + typeof value.label !== "string" || + typeof value.value !== "string" || + typeof value.index !== "number" || + !Number.isInteger(value.index) || + value.index < 1 + ) { + return null + } + return { + type: "option", + label: value.label, + value: value.value, + index: value.index, + } + } + + if (value.type === "other") { + if (typeof value.label !== "string" || typeof value.value !== "string") { + return null + } + return { type: "other", label: value.label, value: value.value } + } + + return null +} + +function buildLegacyResult( + status: "answered" | "cancelled" | "unavailable", + params: StructuredExchangeEditorPrefillParams, + answers: StructuredExchangeAnswer[], + note: string, + message?: string, +) { + const selected = answers + .map((answer) => + answer.type === "option" + ? `${answer.index}. ${answer.label}` + : answer.type === "other" + ? `Other: ${answer.label}` + : answer.label, + ) + .join("\n") + const text = + status === "answered" + ? [ + `User selected:${selected ? `\n${selected}` : ""}`, + note ? `Note: ${note}` : undefined, + ] + .filter(Boolean) + .join("\n") + : (message ?? `User ${status} the question`) + + return { + content: [{ type: "text" as const, text }], + details: { + schema: STRUCTURED_EXCHANGE_RESULT_SCHEMA, + schemaVersion: 1 as const, + status, + question: params.question, + ...(params.context !== undefined ? { context: params.context } : {}), + mode: params.mode, + options: params.options, + answers, + rejectedOptions: params.options.filter( + (option) => + !answers.some( + (answer) => + answer.type === "option" && + answer.label === option.label && + answer.value === option.value, + ), + ), + note, + transport: { surface: "rpc-editor" as const }, + ...(message !== undefined ? { message } : {}), + }, + } +} + +export function buildStructuredExchangeEditorPrefill( + params: StructuredExchangeEditorPrefillParams, +): string { + const payload: Record<string, unknown> = { + schema: "brunch.structured_exchange.editor", + schemaVersion: 1, + question: params.question, + mode: params.mode, + options: params.options.map((option, index) => ({ + index: index + 1, + label: option.label, + value: option.value, + ...(option.description ? { description: option.description } : {}), + })), + instructions: [ + "Edit only response.", + 'For a selected listed option, add an answer like {"type":"option","label":"Alpha","value":"alpha","index":1}.', + 'For Other, add an answer like {"type":"other","label":"Custom answer","value":"Custom answer"}.', + 'Set response.note to a string. Use "" when there is no additional note.', + ], + response: { status: "cancelled", answers: [], note: "" }, + } + if (params.context !== undefined) payload.context = params.context + return JSON.stringify(payload, null, 2) +} + +export function parseStructuredExchangeEditorResponse( + value: string, +): StructuredExchangeEditorResponse | null { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return null + } + + if (!isRecord(parsed)) return null + const response = parsed.response + if (!isRecord(response)) return null + + if (response.status === "cancelled") { + return { status: "cancelled", answers: [], note: "" } + } + if (response.status !== "answered") return null + if (!Array.isArray(response.answers)) return null + if (typeof response.note !== "string") return null + + const answers = response.answers.map(parseEditorAnswer) + if (answers.some((answer) => answer === null)) return null + return { + status: "answered", + answers: sortAnswers(answers as StructuredExchangeAnswer[]), + note: response.note.trim(), + } +} + +export function structuredExchangeResultFromEditor( + params: StructuredExchangeEditorPrefillParams, + edited: string | undefined, +) { + const response = parseStructuredExchangeEditorResponse(edited ?? "") + if (edited === undefined || response?.status === "cancelled") { + return buildLegacyResult( + "cancelled", + params, + [], + "", + "User cancelled the question", + ) + } + if (!response) { + return buildLegacyResult( + "unavailable", + params, + [], + "", + "structured_exchange editor fallback returned invalid JSON", + ) + } + return buildLegacyResult("answered", params, response.answers, response.note) +} diff --git a/src/tui-client/.pi/extensions/structured-exchange/shared/markdown.ts b/src/tui-client/.pi/extensions/structured-exchange/shared/markdown.ts new file mode 100644 index 00000000..99b508ff --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/shared/markdown.ts @@ -0,0 +1,76 @@ +import { Markdown, Text, type MarkdownTheme } from "@earendil-works/pi-tui" + +interface ThemeLike { + fg?: (color: never, text: string) => string + bold?: (text: string) => string + italic?: (text: string) => string + underline?: (text: string) => string + strikethrough?: (text: string) => string +} + +interface ToolTextContentLike { + type?: string + text?: string +} + +interface ToolResultLike { + content?: ToolTextContentLike[] +} + +export function textFromToolContent(result: ToolResultLike): string { + const first = result.content?.[0] + return first?.type === "text" && typeof first.text === "string" + ? first.text + : "" +} + +export function createStructuredExchangeMarkdownTheme( + theme?: ThemeLike, +): MarkdownTheme { + const color = (name: string) => (text: string) => + theme?.fg ? theme.fg(name as never, text) : text + const identity = (text: string) => text + return { + heading: color("mdHeading"), + link: color("mdLink"), + linkUrl: color("mdLinkUrl"), + code: color("mdCode"), + codeBlock: color("mdCodeBlock"), + codeBlockBorder: color("mdCodeBlockBorder"), + quote: color("mdQuote"), + quoteBorder: color("mdQuoteBorder"), + hr: color("mdHr"), + listBullet: color("mdListBullet"), + bold: theme?.bold ?? identity, + italic: theme?.italic ?? identity, + underline: theme?.underline ?? identity, + strikethrough: theme?.strikethrough ?? identity, + highlightCode: (code: string) => code.split("\n").map(color("mdCodeBlock")), + } +} + +export function renderMarkdownResult( + result: ToolResultLike, + theme?: ThemeLike, +) { + return new Markdown( + textFromToolContent(result), + 0, + 0, + createStructuredExchangeMarkdownTheme(theme), + ) +} + +export function renderPlainResult(result: ToolResultLike) { + return new Text(textFromToolContent(result), 0, 0) +} + +export function markdownEscape(text: string): string { + return text.replace(/([\\`*_{}\[\]()#+\-.!|>])/g, "\\$1") +} + +export function normalizeOptionalText(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} diff --git a/src/tui-client/.pi/extensions/structured-exchange/shared/model.ts b/src/tui-client/.pi/extensions/structured-exchange/shared/model.ts new file mode 100644 index 00000000..4bfcc8e0 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/shared/model.ts @@ -0,0 +1,63 @@ +export const STRUCTURED_EXCHANGE_PRESENT_SCHEMA = + "brunch.structured_exchange.present" as const +export const STRUCTURED_EXCHANGE_REQUEST_SCHEMA = + "brunch.structured_exchange.request" as const + +export type PresentToolName = "present_question" | "present_options" | "present_review_set" | "present_candidates" +export type RequestToolName = "request_answer" | "request_choice" | "request_choices" | "request_review" + +export type StructuredExchangePresentKind = "question" | "options" | "review_set" | "candidates" + +export interface StructuredExchangeExpectedRequest { + tool: RequestToolName + required: boolean +} + +export interface StructuredExchangePresentDetails { + schema: typeof STRUCTURED_EXCHANGE_PRESENT_SCHEMA + schemaVersion: 1 + exchangeId: string + presentTool: PresentToolName + kind: StructuredExchangePresentKind + status: "presented" + expectedRequest?: StructuredExchangeExpectedRequest + createdAtToolCallId: string +} + +export interface StructuredExchangeChoice { + id: string + label: string +} + +export interface StructuredExchangeRequestDetails { + schema: typeof STRUCTURED_EXCHANGE_REQUEST_SCHEMA + schemaVersion: 1 + exchangeId: string + requestTool: RequestToolName + status: "answered" | "cancelled" | "unavailable" + respondsTo: { + exchangeId: string + presentTool: PresentToolName + } + choice?: StructuredExchangeChoice + choices?: StructuredExchangeChoice[] + answer?: string + review?: "approve" | "change-request" | "reject" + comment?: string + message?: string + createdAtToolCallId: string +} + +export interface ToolTextContent { + type: "text" + text: string +} + +export interface ToolTextResult<TDetails> { + content: ToolTextContent[] + details: TDetails +} + +export function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} diff --git a/src/tui-client/.pi/extensions/structured-exchange/shared/recovery.ts b/src/tui-client/.pi/extensions/structured-exchange/shared/recovery.ts new file mode 100644 index 00000000..04c7992c --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/shared/recovery.ts @@ -0,0 +1,129 @@ +import { + STRUCTURED_EXCHANGE_PRESENT_SCHEMA, + STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + type PresentToolName, + type RequestToolName, + type StructuredExchangePresentDetails, + type StructuredExchangeRequestDetails, + isRecord, +} from "./model.js" + +const PRESENT_TOOLS: readonly PresentToolName[] = [ + "present_question", + "present_options", + "present_review_set", + "present_candidates", +] +const REQUEST_TOOLS: readonly RequestToolName[] = [ + "request_answer", + "request_choice", + "request_choices", + "request_review", +] + +function isPresentToolName(value: unknown): value is PresentToolName { + return ( + typeof value === "string" && + PRESENT_TOOLS.includes(value as PresentToolName) + ) +} + +function isRequestToolName(value: unknown): value is RequestToolName { + return ( + typeof value === "string" && + REQUEST_TOOLS.includes(value as RequestToolName) + ) +} + +export function isStructuredExchangePresentDetails( + value: unknown, +): value is StructuredExchangePresentDetails { + if (!isRecord(value)) return false + if (value.schema !== STRUCTURED_EXCHANGE_PRESENT_SCHEMA) return false + if (value.schemaVersion !== 1) return false + if (typeof value.exchangeId !== "string" || value.exchangeId.length === 0) { + return false + } + if (!isPresentToolName(value.presentTool)) return false + if ( + value.kind !== "question" && + value.kind !== "options" && + value.kind !== "review_set" && + value.kind !== "candidates" + ) { + return false + } + if (value.status !== "presented") return false + if (typeof value.createdAtToolCallId !== "string") return false + if (value.expectedRequest !== undefined) { + if (!isRecord(value.expectedRequest)) return false + if (!isRequestToolName(value.expectedRequest.tool)) return false + if (typeof value.expectedRequest.required !== "boolean") return false + } + return true +} + +export function isStructuredExchangeRequestDetails( + value: unknown, +): value is StructuredExchangeRequestDetails { + if (!isRecord(value)) return false + if (value.schema !== STRUCTURED_EXCHANGE_REQUEST_SCHEMA) return false + if (value.schemaVersion !== 1) return false + if (typeof value.exchangeId !== "string" || value.exchangeId.length === 0) { + return false + } + if (!isRequestToolName(value.requestTool)) return false + if ( + value.status !== "answered" && + value.status !== "cancelled" && + value.status !== "unavailable" + ) { + return false + } + if (!isRecord(value.respondsTo)) return false + if (value.respondsTo.exchangeId !== value.exchangeId) return false + if (!isPresentToolName(value.respondsTo.presentTool)) return false + if (typeof value.createdAtToolCallId !== "string") return false + return true +} + +interface EntryLike { + type?: unknown + message?: { + role?: unknown + details?: unknown + } +} + +function toolResultDetails(entry: EntryLike): unknown { + return entry.type === "message" && entry.message?.role === "toolResult" + ? entry.message.details + : undefined +} + +export interface IncompleteStructuredExchangePresent { + entry: EntryLike + details: StructuredExchangePresentDetails +} + +export function findIncompleteStructuredExchangePresents( + entries: readonly EntryLike[], +): IncompleteStructuredExchangePresent[] { + const presents = new Map<string, IncompleteStructuredExchangePresent>() + const completed = new Set<string>() + + for (const entry of entries) { + const details = toolResultDetails(entry) + if (isStructuredExchangePresentDetails(details)) { + if (details.expectedRequest?.required !== false) { + presents.set(details.exchangeId, { entry, details }) + } + } else if (isStructuredExchangeRequestDetails(details)) { + completed.add(details.exchangeId) + } + } + + return [...presents.values()].filter( + (present) => !completed.has(present.details.exchangeId), + ) +} diff --git a/src/pi-extensions.ts b/src/tui-client/pi-extension-shell.ts similarity index 70% rename from src/pi-extensions.ts rename to src/tui-client/pi-extension-shell.ts index d4fbd5a8..1c26d861 100644 --- a/src/pi-extensions.ts +++ b/src/tui-client/pi-extension-shell.ts @@ -3,36 +3,36 @@ import { type ExtensionFactory, } from "@earendil-works/pi-coding-agent" -import { registerBrunchAlternatives } from "./tui-client/.pi/extensions/alternatives.js" -import { registerBrunchBranchPolicyHandlers } from "./tui-client/.pi/extensions/command-policy.js" +import { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" +import { registerBrunchBranchPolicyHandlers } from "./.pi/extensions/command-policy.js" import { FIXTURE_GRAPH_MENTION_SOURCE, registerBrunchMentionAutocomplete, type GraphMentionSource, -} from "./tui-client/.pi/extensions/mention-autocomplete.js" -import { registerBrunchOperationalModePolicy } from "./tui-client/.pi/extensions/operational-mode.js" -import registerBrunchStructuredExchange from "./tui-client/.pi/extensions/structured-exchange/index.js" +} from "./.pi/extensions/mention-autocomplete.js" +import { registerBrunchOperationalModePolicy } from "./.pi/extensions/operational-mode.js" +import registerBrunchStructuredExchange from "./.pi/extensions/structured-exchange/index.js" import { renderBrunchChrome, type BrunchChromeState, -} from "./tui-client/.pi/extensions/chrome.js" +} from "./.pi/extensions/chrome.js" import { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./tui-client/.pi/extensions/session-lifecycle.js" +} from "./.pi/extensions/session-lifecycle.js" import { registerBrunchWorkspaceDialog, type BrunchSpecSessionPickerOptions, -} from "./tui-client/.pi/extensions/workspace-dialog.js" +} from "./.pi/extensions/workspace-dialog.js" -export { registerBrunchAlternatives } from "./tui-client/.pi/extensions/alternatives.js" -export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./tui-client/.pi/extensions/command-policy.js" +export { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" +export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./.pi/extensions/command-policy.js" export { registerBrunchMentionAutocomplete, type GraphMentionCandidate, type GraphMentionSource, -} from "./tui-client/.pi/extensions/mention-autocomplete.js" +} from "./.pi/extensions/mention-autocomplete.js" export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -50,7 +50,7 @@ export { type OperationalModeDefinition, type OperationalModeId, type ResolvedBrunchAgentState, -} from "./tui-client/.pi/extensions/operational-mode.js" +} from "./.pi/extensions/operational-mode.js" export { chromeStateForWorkspace, projectBrunchChromeFooterLines, @@ -61,12 +61,12 @@ export { type BrunchChromeState, type BrunchChromeUi, type BrunchChromeWorkerStatus, -} from "./tui-client/.pi/extensions/chrome.js" +} from "./.pi/extensions/chrome.js" export { bindBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, -} from "./tui-client/.pi/extensions/session-lifecycle.js" +} from "./.pi/extensions/session-lifecycle.js" export { BRUNCH_WORKSPACE_COMMAND, BRUNCH_WORKSPACE_SHORTCUT, @@ -74,7 +74,7 @@ export { runBrunchWorkspaceAction, runBrunchWorkspaceCommand, type BrunchSpecSessionPickerOptions, -} from "./tui-client/.pi/extensions/workspace-dialog.js" +} from "./.pi/extensions/workspace-dialog.js" export interface BrunchPiExtensionShellOptions extends BrunchSpecSessionPickerOptions { From 449856f4befac97e9146bdb9069373cdf9b64bf4 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Thu, 28 May 2026 21:47:26 +0200 Subject: [PATCH 126/164] Prove structured exchange ordering --- docs/architecture/pi-ui-extension-patterns.md | 10 +- memory/PLAN.md | 2 +- memory/SPEC.md | 12 +- ...structured-exchange-ordering-proof.test.ts | 51 +++ .../structured-exchange-ordering-proof.ts | 403 ++++++++++++++++++ 5 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 src/probes/structured-exchange-ordering-proof.test.ts create mode 100644 src/probes/structured-exchange-ordering-proof.ts diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 3e03e8ce..a61fd8e7 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -229,7 +229,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Structured-exchange / RPC-relay gap -The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop: the structured-exchange helper produces self-contained terminal result details, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gap is the public Brunch product relay: exposing pending Pi extension-UI requests as product-shaped RPC state/events for web/CLI clients, then translating product responses back into Pi's documented `extension_ui_response` messages. +The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop and has started the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gaps are the ten-turn public Brunch RPC parity run, web observation, and chrome recovery. Pi source/docs already give strong evidence for the primitive: @@ -240,13 +240,13 @@ Pi source/docs already give strong evidence for the primitive: - `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch must still prove is the public product relay around that composition: assistant tool/custom prompt → pending Brunch elicitation state/event over the single public RPC surface → product response from web/CLI probe → Pi `extension_ui_response` → self-contained structured result in Pi JSONL → existing response-side exchange projection. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until that relay is implemented or deliberately moved into a named M5 slice. +The seam Brunch must still prove is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from web/CLI probe or TUI custom UI → durable present/request tool results in Pi JSONL → existing response-side exchange projection. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until the ten-turn parity proof lands or the remaining relay work is deliberately moved into a named M5 slice. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | -| Elicitation-first session loop | Missing and POC-critical. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, skipped, cancelled, or marked unavailable. | -| Registered structured-exchange tool seam | Brunch result-builder/schema tests cover self-contained `toolResult.details`; exchange projection now classifies terminal structured-exchange results as response-side entries. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side. | -| TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for text, single-select, multi-select, questionnaire, and terminal statuses. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | +| Elicitation-first session loop | POC-critical and partially proven. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, cancelled, marked unavailable, or explicitly display-only. | +| Registered structured-exchange tool seam | Brunch present/request tests cover markdown `toolResult.content`, self-contained `toolResult.details`, non-semantic `renderCall`, unmatched-present recovery, and a real Pi RPC same-assistant-message sequential ordering proof for `present_options → request_choice`. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side; RPC consumers should not require `request_*` `tool_execution_start` before extension UI because the UI request can arrive first. | +| TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for freeform and listed-choice responses; multi-choice/review/candidate tools are named stubs until their product flows land. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | | JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | diff --git a/memory/PLAN.md b/memory/PLAN.md index 92c6eb03..18e8ea56 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, and `request_choice` are registered; review/candidate/multi-choice tools are named stubs but intentionally not registered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. Next scope the ordering/parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, and `request_choice` are registered; review/candidate/multi-choice tools are named stubs but intentionally not registered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope the parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 93932605..2d3f1fb7 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -73,7 +73,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), questionnaire, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. In TUI mode a pending structured interaction may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs, including schema-tagged JSON over `ctx.ui.editor` for complex shapes. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), freeform, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. Multi-question/questionnaire surfaces are deferred; when a complex shape is needed before a bespoke UI lands, Brunch may use just-in-time schema-tagged JSON over `ctx.ui.editor` or an equivalent product relay. In TUI mode a pending response request may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -217,8 +217,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"` while FE-744 proves whether same-assistant-message ordering is sufficient; if not, present/request must span separate tool-use steps. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. -- **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, questionnaire, review-style response) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, so the next parity proof may use same-message tuples. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. +- **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. - **D25-L — Elicitation strategies are *lenses* within the `elicitor` agent role, not separate roles or operational modes.** Lens is metadata on elicitor-emitted custom transcript entries (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`, etc.); roles (`elicitor`, `reviewer`, `reconciler`, and any deferred observer/auditor roles) remain orthogonal. The known starter lens set is `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, and `project-requirements-from-upstream`; the catalogue is expected to grow. Capture, review, and future audit routing may filter on lens. Depends on: D12-L, D17-L, D23-L. Supersedes: collapsing strategy and agent role into one vocabulary axis. @@ -262,7 +262,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | partial (FE-744 now registers sequential `present_question`, `present_options`, `request_answer`, and `request_choice` tools from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, and the legacy JSON-editor fallback helper used by the raw Pi RPC proof. `present_review_set`, `present_candidates`, `request_choices`, and `request_review` are named stubs but intentionally not registered. A real Pi ordering proof for same-assistant-message sequential present/request execution remains pending.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | partial (FE-744 now registers sequential `present_question`, `present_options`, `request_answer`, and `request_choice` tools from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, and the legacy JSON-editor fallback helper used by the raw Pi RPC proof. `present_review_set`, `present_candidates`, `request_choices`, and `request_review` are named stubs but intentionally not registered. `src/probes/structured-exchange-ordering-proof.ts` proves same-assistant-message `present_options → request_choice` sequential ordering over a real Pi RPC run: present tool result before request UI, and present JSONL toolResult before request JSONL toolResult.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | @@ -386,7 +386,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | | **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-exchange results) plus response-side span (the user's text, linked structured action entries, and/or terminal structured-exchange toolResult details). This is the default post-exchange capture unit. | | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | -| **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, skipped, cancelled, or marked unavailable. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi tool call whose result details carry the structured response. | +| **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, cancelled, marked unavailable, or explicitly declared display-only. A distinct `skipped` terminal state is deferred until product pressure distinguishes “declined but continue” from cancellation or an explicit `none`/`other` answer. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi `present_*`/`request_*` tool tuple whose result details carry the structured display and response. | | **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `elicitation.respond`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | @@ -536,7 +536,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | -| I23-L | FE-744 structured-exchange tests: pending interaction mounts an input-replacing TUI response surface when available; single/multi/questionnaire/freeform answers persist as self-contained toolResult details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; elicitation-exchange projection pairs the prompt-side tool/custom entry with the terminal structured result. | +| I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; elicitation-exchange projection pairs the prompt-side present/custom entry with the terminal request result. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I28-L | Inner — TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | diff --git a/src/probes/structured-exchange-ordering-proof.test.ts b/src/probes/structured-exchange-ordering-proof.test.ts new file mode 100644 index 00000000..0be6579e --- /dev/null +++ b/src/probes/structured-exchange-ordering-proof.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" + +import { runStructuredExchangeOrderingProof } from "./structured-exchange-ordering-proof.js" + +describe("structured-exchange ordering proof", () => { + it("runs same-assistant-message present_options before request_choice with sequential tools", async () => { + const proof = await runStructuredExchangeOrderingProof() + + expect(proof.scenario).toMatchObject({ + mission: + "Prove same-assistant-message present/request structured-exchange ordering.", + evaluationFocus: + "Verify sequential present_options persists before request_choice opens response UI.", + maxTurns: 1, + }) + expect(proof.verdict).toEqual({ + presentResultBeforeRequestUi: true, + jsonlPresentBeforeRequest: true, + }) + expect(proof.eventOrder).toEqual([ + "present_options:start", + "present_options:end", + "ui:select", + "request_choice:start", + "ui:input", + "request_choice:end", + ]) + expect(proof.jsonlToolResultOrder).toEqual([ + "present_options", + "request_choice", + ]) + expect(proof.presentDetails).toMatchObject({ + schema: "brunch.structured_exchange.present", + exchangeId: "ordering-proof", + presentTool: "present_options", + expectedRequest: { tool: "request_choice", required: true }, + }) + expect(proof.requestDetails).toMatchObject({ + schema: "brunch.structured_exchange.request", + exchangeId: "ordering-proof", + requestTool: "request_choice", + status: "answered", + respondsTo: { + exchangeId: "ordering-proof", + presentTool: "present_options", + }, + choice: { id: "tui", label: "Move under src/tui-client" }, + comment: "Sequential ordering looks safe for the next parity proof.", + }) + }, 20_000) +}) diff --git a/src/probes/structured-exchange-ordering-proof.ts b/src/probes/structured-exchange-ordering-proof.ts new file mode 100644 index 00000000..941a135c --- /dev/null +++ b/src/probes/structured-exchange-ordering-proof.ts @@ -0,0 +1,403 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import type { + StructuredExchangePresentDetails, + StructuredExchangeRequestDetails, +} from "../tui-client/.pi/extensions/structured-exchange/index.js" + +interface OrderingScenario { + mission: string + evaluationFocus: string + maxTurns: number +} + +interface OrderingVerdict { + presentResultBeforeRequestUi: boolean + jsonlPresentBeforeRequest: boolean +} + +interface ToolResultRecord { + toolName: string + details: unknown +} + +export interface StructuredExchangeOrderingProofResult { + scenario: OrderingScenario + verdict: OrderingVerdict + eventOrder: string[] + jsonlToolResultOrder: string[] + presentDetails: StructuredExchangePresentDetails + requestDetails: StructuredExchangeRequestDetails + sessionFile: string + stdout: unknown[] +} + +interface StructuredExchangeOrderingProofOptions { + cwd?: string + timeoutMs?: number +} + +const scenario: OrderingScenario = { + mission: + "Prove same-assistant-message present/request structured-exchange ordering.", + evaluationFocus: + "Verify sequential present_options persists before request_choice opens response UI.", + maxTurns: 1, +} + +export async function runStructuredExchangeOrderingProof( + options: StructuredExchangeOrderingProofOptions = {}, +): Promise<StructuredExchangeOrderingProofResult> { + const cwd = + options.cwd ?? (await mkdtemp(join(tmpdir(), "brunch-exchange-ordering-"))) + const timeoutMs = options.timeoutMs ?? 10_000 + const extensionPath = await writeOrderingExtension(cwd) + const sessionDir = join(cwd, ".brunch", "sessions") + await mkdir(sessionDir, { recursive: true }) + + const child = spawn( + process.execPath, + [ + piCliPath(), + "--mode", + "rpc", + "--no-extensions", + "--no-builtin-tools", + "--extension", + extensionPath, + "--session-dir", + sessionDir, + ], + { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1", PI_OFFLINE: "1" }, + }, + ) + + const client = new RpcProbeClient(child, timeoutMs) + try { + const promptAccepted = client.waitFor( + (event): event is RpcResponse => + isRpcResponse(event) && event.command === "prompt", + ) + child.stdin.write( + `${JSON.stringify({ id: "ordering", type: "prompt", message: "/brunch-structured-exchange-ordering-proof" })}\n`, + ) + + const selectRequest = await client.waitFor( + (event): event is ExtensionUiRequest => + isExtensionUiRequest(event) && event.method === "select", + ) + child.stdin.write( + `${JSON.stringify({ type: "extension_ui_response", id: selectRequest.id, value: "Move under src/tui-client" })}\n`, + ) + + const inputRequest = await client.waitFor( + (event): event is ExtensionUiRequest => + isExtensionUiRequest(event) && event.method === "input", + ) + child.stdin.write( + `${JSON.stringify({ type: "extension_ui_response", id: inputRequest.id, value: "Sequential ordering looks safe for the next parity proof." })}\n`, + ) + + const promptResponse = await promptAccepted + if (!promptResponse.success) { + throw new Error( + `Ordering proof prompt failed: ${promptResponse.error ?? "unknown error"}`, + ) + } + + const stateResponse = client.waitFor( + (event): event is RpcResponse<{ sessionFile?: string }> => + isRpcResponse(event) && event.id === "state", + ) + child.stdin.write(`${JSON.stringify({ id: "state", type: "get_state" })}\n`) + const state = await stateResponse + const sessionFile = state.data?.sessionFile + if (!state.success || typeof sessionFile !== "string") { + throw new Error("Ordering proof did not expose a persisted session file") + } + + const toolResults = await readToolResults(sessionFile) + const present = toolResults.find( + (result) => result.toolName === "present_options", + ) + const request = toolResults.find( + (result) => result.toolName === "request_choice", + ) + if (!present || !request) { + throw new Error("Ordering proof did not persist both tool results") + } + + const eventOrder = orderingEvents(client.events) + const jsonlToolResultOrder = toolResults.map((result) => result.toolName) + const presentIndex = eventOrder.indexOf("present_options:end") + const requestUiIndex = eventOrder.indexOf("ui:select") + const jsonlPresentIndex = jsonlToolResultOrder.indexOf("present_options") + const jsonlRequestIndex = jsonlToolResultOrder.indexOf("request_choice") + + return { + scenario, + verdict: { + presentResultBeforeRequestUi: + presentIndex !== -1 && + requestUiIndex !== -1 && + presentIndex < requestUiIndex, + jsonlPresentBeforeRequest: + jsonlPresentIndex !== -1 && + jsonlRequestIndex !== -1 && + jsonlPresentIndex < jsonlRequestIndex, + }, + eventOrder, + jsonlToolResultOrder, + presentDetails: present.details as StructuredExchangePresentDetails, + requestDetails: request.details as StructuredExchangeRequestDetails, + sessionFile, + stdout: client.events, + } + } finally { + client.dispose() + } +} + +async function writeOrderingExtension(cwd: string): Promise<string> { + const extensionPath = join(cwd, "structured-exchange-ordering-extension.ts") + const adapterPath = resolve( + "src/tui-client/.pi/extensions/structured-exchange/index.ts", + ) + const content = ` + import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + import { + fauxAssistantMessage, + fauxToolCall, + registerFauxProvider, + } from "@earendil-works/pi-ai" + import registerStructuredExchange from ${JSON.stringify(adapterPath)} + + export default function(pi: ExtensionAPI): void { + registerStructuredExchange(pi) + const provider = registerFauxProvider({ + provider: "brunch-ordering", + api: "brunch-ordering-api", + models: [{ id: "ordering-model", name: "Ordering proof model" }], + }) + pi.registerProvider("brunch-ordering", { + api: provider.api as never, + baseUrl: "https://example.invalid", + apiKey: "BRUNCH_ORDERING_FAUX_API_KEY", + models: [ + { + id: "ordering-model", + name: "Ordering proof model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }) + provider.setResponses([ + fauxAssistantMessage([ + fauxToolCall("present_options", { + exchangeId: "ordering-proof", + heading: "Where should the extension shell live?", + body: "This present result must persist before the request UI opens.", + options: [ + { id: "root", content: "Keep src/pi-extensions.ts", rationale: "Smallest diff." }, + { id: "tui", content: "Move under src/tui-client", rationale: "Clearer ownership." }, + ], + expectedRequestTool: "request_choice", + }, { id: "present-ordering-call" }), + fauxToolCall("request_choice", { + exchangeId: "ordering-proof", + respondsToPresentTool: "present_options", + prompt: "Select one option.", + choices: [ + { id: "root", label: "Keep src/pi-extensions.ts" }, + { id: "tui", label: "Move under src/tui-client" }, + ], + allowOther: false, + commentPrompt: "Optional comment", + }, { id: "request-ordering-call" }), + ], { stopReason: "toolUse" }), + fauxAssistantMessage("Ordering proof complete.", { stopReason: "stop" }), + ]) + pi.registerCommand("brunch-structured-exchange-ordering-proof", { + description: "Start the deterministic present/request ordering proof.", + handler: async () => { + const selected = await pi.setModel(provider.getModel()) + if (!selected) throw new Error("Ordering proof faux model was not selectable") + pi.setActiveTools(["present_options", "request_choice"]) + pi.sendUserMessage("Run the present/request ordering proof.") + }, + }) + } + ` + await writeFile(extensionPath, content, "utf8") + return extensionPath +} + +function orderingEvents(events: readonly unknown[]): string[] { + return events.flatMap((event) => { + if (!isRecord(event)) return [] + if (event.type === "tool_execution_start") { + return [`${String(event.toolName)}:start`] + } + if (event.type === "tool_execution_end") { + return [`${String(event.toolName)}:end`] + } + if (event.type === "extension_ui_request") { + return [`ui:${String(event.method)}`] + } + return [] + }) +} + +async function readToolResults( + sessionFile: string, +): Promise<ToolResultRecord[]> { + const entries = (await readFile(sessionFile, "utf8")) + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as unknown) + return entries.flatMap((entry) => { + if (!isRecord(entry) || entry.type !== "message") return [] + const message = entry.message + if (!isRecord(message) || message.role !== "toolResult") return [] + if ( + message.toolName !== "present_options" && + message.toolName !== "request_choice" + ) { + return [] + } + return [{ toolName: message.toolName, details: message.details }] + }) +} + +function piCliPath(): string { + return fileURLToPath( + new URL( + "../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + import.meta.url, + ), + ) +} + +interface RpcResponse<T = unknown> { + type: "response" + id?: string + command: string + success: boolean + data?: T + error?: string +} + +interface ExtensionUiRequest { + type: "extension_ui_request" + id: string + method: string +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +function isRpcResponse(value: unknown): value is RpcResponse { + return ( + isRecord(value) && + value.type === "response" && + typeof value.command === "string" && + typeof value.success === "boolean" + ) +} + +function isExtensionUiRequest(value: unknown): value is ExtensionUiRequest { + return ( + isRecord(value) && + value.type === "extension_ui_request" && + typeof value.id === "string" && + typeof value.method === "string" + ) +} + +class RpcProbeClient { + readonly events: unknown[] = [] + readonly #child: ChildProcessWithoutNullStreams + readonly #timeoutMs: number + #stdout = "" + #stderr = "" + #waiters: Array<{ + predicate: (event: unknown) => boolean + resolve: (event: unknown) => void + }> = [] + + constructor(child: ChildProcessWithoutNullStreams, timeoutMs: number) { + this.#child = child + this.#timeoutMs = timeoutMs + child.stdout.on("data", (chunk) => this.#ingestStdout(String(chunk))) + child.stderr.on("data", (chunk) => { + this.#stderr += String(chunk) + }) + } + + waitFor<T,>(predicate: (event: unknown) => event is T): Promise<T> { + const existing = this.events.find(predicate) + if (existing) return Promise.resolve(existing) + + return new Promise<T>((resolve, reject) => { + const timeout = setTimeout( + () => { + reject( + new Error( + `Timed out waiting for ordering proof event. Events:\n${JSON.stringify(this.events, null, 2)}\nStderr:\n${this.#stderr}`, + ), + ) + }, + this.#timeoutMs, + ) + this.#waiters.push({ + predicate, + resolve: (event) => { + clearTimeout(timeout) + resolve(event as T) + }, + }) + }) + } + + dispose(): void { + this.#child.kill("SIGTERM") + } + + #ingestStdout(chunk: string): void { + this.#stdout += chunk + for (;;) { + const newline = this.#stdout.indexOf("\n") + if (newline === -1) return + const line = this.#stdout.slice(0, newline).trim() + this.#stdout = this.#stdout.slice(newline + 1) + if (line.length === 0) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + this.events.push(parsed) + for (const waiter of this.#waiters) { + if (waiter.predicate(parsed)) { + this.#waiters = this.#waiters.filter( + (candidate) => candidate !== waiter, + ) + waiter.resolve(parsed) + } + } + } + } +} From cf88f80fde8f8d58231015b1e7b8a71666d7749c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:28:56 +0200 Subject: [PATCH 127/164] handoff state, as of resolved structured-exchange plan --- HANDOFF.md | 118 +++++++++++ memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md | 223 --------------------- 2 files changed, 118 insertions(+), 223 deletions(-) create mode 100644 HANDOFF.md delete mode 100644 memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..31936e61 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,118 @@ +# Handoff + +> Generated by `ln-handoff` at 2026-05-28T19:49:02Z. Read this file to resume work. +> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. + +## Goal + +Finish FE-744 by proving Brunch's Pi structured-exchange and public-RPC elicitation loop deeply enough to move on to sealed Pi profile/runtime-state work. + +## Session State + +- **Last completed skill**: `ln-build` — implemented and committed the same-assistant-message structured-exchange ordering proof. +- **Current skill**: `ln-handoff` — refreshing volatile transfer state after the ordering proof. +- **Flow position**: `grill → spec → plan → scope → build → handoff` +- **Handoff trigger**: user requested an updated handoff that retires stale/resolved items while preserving future-relevant notes. + +## In-flight work + +> There is no uncommitted scope card or half-built implementation artifact to preserve. The last scope card was completed by commit `e6251724` and reconciled into canonical docs. + +### Next scope target — not yet scoped + +The next actionable item is still inside the FE-744 `pi-ui-extension-patterns` frontier: + +- Let the deterministic elicitor advance through **at least ten structured exchanges**. +- Then build the ten-turn public Brunch RPC agent-as-user parity proof and projection oracle. +- Then run web real-time observation smoke. + +Suggested next `/ln-scope` target: + +> Scope the next FE-744 parity slice: deterministic assistant-first elicitor advances through at least ten structured exchanges using the now-proven same-message `present_* → request_*` tuple shape. + +### Review findings + +No `ln-review` findings were produced in this session. + +| # | Finding | Status | Implications | +| --- | --- | --- | --- | +| 1 | None | n/a | n/a | + +### Diagnostic evidence + +- `src/probes/structured-exchange-ordering-proof.test.ts` passes under `npm run verify`: proves a real Pi RPC run can process same-assistant-message sibling tool calls `present_options` then `request_choice` with both tools marked `executionMode: "sequential"`. +- Observed event order from the proof: `present_options:start`, `present_options:end`, `ui:select`, `request_choice:start`, `ui:input`, `request_choice:end`. + - This proves the present result completes before request UI opens. + - It also shows a caveat: RPC may emit the request UI before `request_choice` `tool_execution_start`, so future RPC consumers should not require request tool-start before dialog UI. +- JSONL oracle from the proof: persisted toolResult order is `present_options`, then `request_choice` for the same `exchangeId`. +- Earlier stale handoff items are resolved: + - `src/pi-extensions.ts` was moved to `src/tui-client/pi-extension-shell.ts` in commit `f24669b6`. + - `src/tui-client/.pi/README.md` now documents extension iteration (`cd src/tui-client`, `pi`, edit `.pi/extensions/...`, `/reload`). + - The old single `structured_exchange` tool model was replaced by the present/request family. + +## Decisions and assumptions + +| Item | Type | Status | Source | +| --- | --- | --- | --- | +| Structured exchanges are durable `present_*` / `request_*` `toolResult` tuples; `renderCall` is transient. | decision | persisted | `memory/SPEC.md` D37-L / I23-L | +| Same-assistant-message `present_options → request_choice` is acceptable for the next parity proof when tools use `executionMode: "sequential"`. | assumption/proof result | persisted | `memory/SPEC.md` D37-L / I23-L; `src/probes/structured-exchange-ordering-proof.ts` | +| RPC event consumers should not assume request `tool_execution_start` precedes request extension UI. | implementation caveat | persisted | `memory/SPEC.md` D37-L; `memory/PLAN.md` FE-744 pointer; `docs/architecture/pi-ui-extension-patterns.md` | +| Questionnaire/multi-question surfaces and distinct `skipped` terminal state remain deferred. | decision | persisted | `memory/SPEC.md` R17 / lexicon | +| Broader source-tree cleanup remains opportunistic, not a standalone current slice. | preference | volatile but low urgency | conversation; not needed for immediate next step | + +## Repo state + +- **Branch**: `ln/fe-744-pi-ui-extension-patterns` +- **Recent commits**: + - `e6251724 Prove structured exchange ordering` + - `f24669b6 Remodel structured exchange tools` + - `c1989aae Rename runbook checks to probe scripts` + - `9e077b21 Move probe harnesses under probes tree` + - `1dfbd259 Move public RPC modules under rpc tree` +- **Dirty files**: only `HANDOFF.md` is untracked/modified as volatile transfer state. +- **Test status**: `npm run verify` passed after `e6251724`. + - 26 test files / 210 tests passed. + - Typecheck and build passed. + +## Artifact status + +| Artifact | Exists | Current vs conversation | +| --- | --- | --- | +| `memory/SPEC.md` | yes | current; reconciled through ordering proof | +| `memory/PLAN.md` | yes | current; FE-744 execution pointer names next parity sequence | +| `memory/CARDS.md` | no | n/a | +| `memory/REFACTOR.md` | no | n/a | +| `docs/architecture/pi-ui-extension-patterns.md` | yes | current; records ordering proof and remaining FE-744 gaps | +| `HANDOFF.md` | yes | volatile; this file | + +## Next steps + +1. Run `/ln-scope` for the next FE-744 parity slice: deterministic elicitor advances through at least ten structured exchanges. +2. Then `/ln-build` that slice, preserving the present/request tuple invariant and using Brunch public RPC rather than raw Pi RPC for product-facing proof work. +3. After the ten-turn parity proof lands, scope web real-time observation smoke. +4. Before FE-744 closeout, recover branded/themed chrome from retired probe evidence per `memory/PLAN.md` current execution pointer. + +## Retirement rule + +- Overwrite or delete this file once the next session has scoped/built the ten-turn deterministic elicitor/parity work or creates a newer handoff. +- Do not commit `HANDOFF.md` as canonical planning truth unless explicitly requested. + +## Open questions + +- What exact deterministic elicitor mechanism should the next slice use for ten exchanges: extend current `session.startElicitation` dummy flow, or introduce a small dedicated parity driver around the structured-exchange present/request tools? +- Should the ten-turn proof assert only JSONL/projection parity first, or also include a lightweight transcript display snapshot in the same slice? + +## Resume prompt + +Paste this into a new session: + +> Read `HANDOFF.md`, `memory/SPEC.md`, and the FE-744 section of `memory/PLAN.md`. +> The immediate next step is to run `/ln-scope` for the next FE-744 parity slice: deterministic assistant-first elicitor advances through at least ten structured exchanges. +> Start by reviewing `src/probes/structured-exchange-ordering-proof.ts`, `src/tui-client/.pi/extensions/structured-exchange/`, and the current RPC elicitation handlers/tests so the next scope card builds on the proven same-message present/request ordering rather than re-investigating it. + +## Addendum — after final reconciliation check + +- The public Brunch RPC tracer bullets have landed and should be treated as baseline, not open scope: `rpc.discover`, `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` are implemented in the current RPC handler module (`src/rpc/handlers.ts`) with tests in `src/rpc/handlers.test.ts`. +- `memory/PLAN.md`'s detailed FE-744 current execution pointer is accurate about the next sequence, but any older status prose saying public RPC discovery/pending/respond are still missing is stale and should be cleaned during the next `ln-plan`/`ln-sync` pass. +- The next slice should decide explicitly whether the ten-turn deterministic elicitor remains on the current lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` public-RPC loop, or whether it mirrors the newer structured-exchange `present_* → request_*` tuple shape for stronger TUI parity. Do not accidentally mix the two without naming the adapter boundary. +- Working-tree caution for the next session: `memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md` is currently deleted in the worktree and should be treated as protected concurrent cleanup unless the user confirms it is intentional. diff --git a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md b/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md deleted file mode 100644 index e5cd2afc..00000000 --- a/memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md +++ /dev/null @@ -1,223 +0,0 @@ -<!-- STRUCTURED_EXCHANGE_SIDE_MISSION.md — temporary side-mission scope. - Created because memory/CARDS.md is currently owned by another in-flight builder. - Delete or absorb after the prototype verdict is reconciled into SPEC/PLAN/CARDS/code. --> - -# Structured Exchange Side Mission — JIT Editor Probe - -## Orientation - -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the structured-exchange response surface in `src/tui-client/.pi/extensions/structured-exchange/index.ts` and its transcript replay rendering. -- **Frontier item:** `pi-ui-extension-patterns`; this side mission stays inside the existing FE-744 branch/Linear boundary and must not create a new tracker item. -- **Coordination:** do **not** edit `memory/CARDS.md` for this side mission while another builder thread owns the active card queue. This file is a temporary sidecar scope by explicit user request. -- **Main open risk:** the single just-in-time editor may feel better than the second note tab, but it may not be feasible with current `ctx.ui.custom()` focus/render constraints or may create ambiguous result payload semantics. - -## Disambiguation findings to carry into the probe - -- **Single global context field:** For option questions, there should be at most one additional text field for the whole response, regardless of single-select or multi-select. -- **JIT visibility:** The additional field appears only after a selection is made; no-selection does not reveal a freeform field. -- **Listed option semantics:** Selecting a listed option makes the JIT field optional additional context. Payload: selected `OptionAnswer`(s) plus `note` when non-empty. -- **Other semantics:** Selecting the built-in `Other` / `Something else` row makes the same JIT field required custom-answer text. Payload: one `OtherAnswer` with the custom text; `note` is empty/omitted. -- **Multi-select Other rule:** Tentative model for the probe: `Other` is exclusive in multi-select and deselects listed options. This is not yet a durable decision; the prototype should validate or reject it. -- **Replay rendering finding:** On transcript resume, Pi appears to replay only `renderResult`, not `renderCall`; therefore result rendering must be self-contained enough to show the question/context as well as the answer. -- **Review-set flow is deferred:** Review-set proposals likely need approve / request-changes / reject plus comments that can mention simulated proposal IDs, but this side mission should only note that future complexity. Do not solve review-set UI in this probe. - -## Scope Card — JIT editor structured-exchange prototype - -- **Status:** next -- **Weight:** full scope card — this probes a live interaction model and may change the production structured-exchange state machine/result rendering. - -### Target Behavior - -A throwaway structured-exchange prototype answers whether one inline just-in-time editor can replace the second note step across the option-selection permutations. - -### Boundary Crossings - -```text -→ local prototype command or narrowly marked prototype branch in structured-exchange tests -→ option-selection state machine mirroring structured_exchange single/multi modes -→ TUI-like render/input loop with picker focus and inline editor focus -→ payload projection examples for OptionAnswer / OtherAnswer / note -→ prototype verdict captured in this file or handoff -``` - -### Risks and Assumptions - -- RISK: `pi-tui` `Editor` cannot comfortably render/focus inline beneath the picker for all option modes. - → MITIGATION: build the probe near the current `ctx.ui.custom()` component and drive real `Editor` instances if possible; if not, record the exact technical blocker and fall back to state-machine-only evidence. -- RISK: JIT editor reduces tab complexity but reintroduces height/scroll problems in the active answer surface. - → MITIGATION: prototype with compact prompt rendering: full question/context remains in transcript/tool-call render, active picker/editor stays short. -- RISK: multi-select editing creates stale note text when selections change. - → MITIGATION: include scripted cases for selection changes after text entry; prototype must expose current state after each action. -- RISK: replay rendering bug is conflated with JIT interaction. - → MITIGATION: treat replay as an adjacent acceptance candidate, not the main prototype question; record whether production `renderResult` should include prompt context. -- ASSUMPTION: A small prototype is cheaper than directly rewriting production `askSingleChoice` / `askMultiChoice`. - → IMPACT IF FALSE: if the prototype is too artificial, production work still needs exploratory churn. - → VALIDATE: the probe must exercise the same keyboard/focus primitives or clearly state where it diverges. - → `memory/SPEC.md` §Assumptions: A23-L indirectly; this mostly informs FE-744 structured-exchange UX, not the public RPC parity assumption. - -### Tracer-bullet check - -- **Proof of life:** lights up the proposed no-second-tab interaction before production rewrite. -- **Invariants:** clarifies payload semantics for `OptionAnswer`, `OtherAnswer`, and global `note`. -- **Uncertainty:** attacks the open “is this even possible / does it feel usable?” question directly. - -### Acceptance Criteria - -✓ **exclusive listed option** — selecting a listed option focuses one inline optional context editor and can submit `{ answers: [OptionAnswer], note }`. - -✓ **exclusive Other** — selecting `Other` focuses the same inline editor as a required custom-answer field and submits `{ answers: [OtherAnswer], note: "" }` or omits `note`. - -✓ **inclusive listed options** — selecting multiple listed options uses one global optional context editor and submits sorted option answers plus one global note. - -✓ **inclusive Other exclusivity** — selecting `Other` in multi-select clears listed options in the prototype, requires custom text, and submits one `OtherAnswer`. - -✓ **no-selection state** — before any selection, no editor is shown and submission is unavailable. - -✓ **selection-change behavior** — the prototype demonstrates what happens when selections change after editor text exists, with state visible after each action. - -✓ **replay note** — the verdict records whether production `renderResult` must render `question` / `context` because resumed transcripts do not replay `renderCall`. - -✓ **review-set note** — the verdict records that review-set comments with simulated proposal IDs and `#`-mention-like affordances are a later flow, not part of the option-question prototype. - -### Verification Approach - -- **Inner:** no production test gate required for a throwaway prototype; if any production or test code is touched, run `npm run fix` and `npm run check`. -- **Middle:** scripted interaction cases print state/render/payload for the six acceptance permutations above. -- **Outer:** human/user judgement on whether the inline editor feels clearer than the second tab and whether the `Other` semantics are legible. - -### Cross-cutting obligations - -- Keep prompt/question content transcript-backed and replayable; production result rendering must not rely solely on `renderCall` if resumed transcripts only replay `renderResult`. -- Do not introduce a parallel chat/turn store or non-transcript response state. -- Keep `Other` as an answer value, not a note, unless the prototype disproves this model. -- Keep review-set proposal/comment semantics out of this slice; only record future complexity. -- Do not mutate user-level Pi config or ambient `.pi` resources. - -### Expected prototype verdict shape - -```md -## Prototype Verdict: JIT editor structured exchange - -**Command:** [exact command] -**What we tried:** [single listed, single Other, multi listed, multi Other, selection-change case] -**Verdict:** [JIT editor viable? production shape?] -**Absorb:** [state-machine/result-render changes to production] -**Delete:** [prototype file(s) or branch] -**Follow-up:** [scope card for production rewrite, if warranted] -``` - -## Candidate production slices after verdict - -These are **not** active cards; they are likely follow-ups if the prototype is positive. - -1. **Replace option-note second step with JIT editor** — rewrite `askSingleChoice` / `askMultiChoice` production UI around one inline editor and update tests. -2. **Make result rendering replay-self-contained** — update `renderResult` so resumed transcripts show question/context plus selected/rejected/note lines. -3. **Align RPC editor fallback payload examples** — adjust schema instructions/examples so listed-option notes and `OtherAnswer` custom text match the chosen payload semantics. -4. **Review-set flow design pass** — later: model review-set proposal IDs, approve/request-changes/reject, and comment editor with simulated `#`-mention affordance. - -## Prototype Verdict: JIT editor structured exchange - -**Branch:** UI -**Command:** `npx tsx src/pi-extensions/structured-exchange-jit-editor.prototype.ts` - -**What we tried:** A throwaway state/render/payload probe in `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` covering: no-selection hidden-editor state; single-select listed option with optional note; single-select `Other` with required custom text; multi-select listed options with one global note; multi-select `Other` exclusivity; and selection changes after editor text exists. - -**Verdict:** The single JIT editor model is viable at the state/payload level and clearer than the second note tab. Production should keep one editor whose meaning changes by selection kind: listed options treat the text as optional global `note`; `Other` treats the text as the required `OtherAnswer` value and omits/empties `note`. The tentative multi-select `Other` exclusivity rule held up: selecting `Other` clears listed options and submits exactly one `OtherAnswer`. The only unresolved feel risk is low-level Pi focus/height behavior in the real `ctx.ui.custom()` component; the prototype is intentionally state-machine/render-level and did not instantiate real `pi-tui` `Editor` objects. - -**Absorb:** Replace the option-note second step with one inline editor under the picker; keep submit disabled before any selection; focus the inline editor after a selection; preserve global note text across listed-option changes; treat switching to `Other` as converting the current editor text into the required custom answer; sort listed option answers by original index. Update result rendering so `renderResult` is self-contained: resumed transcripts appear to replay only `renderResult`, so production result display should include question/context (or a compact prompt summary) along with selected/rejected answers and note. Align RPC editor fallback instructions/examples to the same semantics: listed option answers plus `note`; `OtherAnswer` custom text plus omitted/empty `note`. - -**Delete:** Delete `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` after the production rewrite or after a scoped build explicitly rejects this direction. Delete or absorb this side-mission file after its findings are reconciled into canonical SPEC/PLAN and the active card queue. - -**Follow-up:** Scope a production slice to implement the inline JIT editor in `askSingleChoice` / `askMultiChoice` and update tests; scope a second small slice if needed for replay-self-contained `renderResult`. Review-set comments with proposal IDs and `#`-mention-like affordances remain a later design pass, not part of option-question UI. - -## Scope Card — Inline JIT editor for option structured exchanges - -- **Status:** done -- **Weight:** full scope card — this changes the option-answer UI state machine and retires the second-step note tab. - -### Target Behavior - -Option-selection structured exchanges use one inline just-in-time editor whose payload meaning is determined by whether the current selection is listed options or `Other`. - -### Boundary Crossings - -```text -→ structured_exchange tool execution -→ ctx.ui.custom option component state machine -→ pi-tui Editor rendered inline beneath the picker -→ OptionAnswer / OtherAnswer / note result details -→ renderResult transcript projection for the completed answer -``` - -### Risks and Assumptions - -- RISK: real `pi-tui` `Editor` focus/input handling does not work cleanly when rendered inline under the picker. → MITIGATION: add a test harness that records `component.render(width)` after each input and drives actual component `handleInput`; require acceptance tests to observe the inline editor state before result submission. -- RISK: inline editor height makes the active answer surface too tall. → MITIGATION: keep the active component compact: picker rows plus one short editor label/value area; leave full prompt/context in `renderCall`/transcript rendering. -- RISK: editor text becomes stale or semantically ambiguous when selections change. → MITIGATION: listed-option changes preserve the editor text as one global note; switching to `Other` clears listed selections and interprets the current editor text as the required custom answer. -- ASSUMPTION: multi-select `Other` should be exclusive rather than combinable with listed options. - → IMPACT IF FALSE: production payload semantics and tests need revision before public RPC parity depends on them. - → VALIDATE: multi-select UI test selects listed options, enters editor text, switches to `Other`, and asserts listed answers are cleared and one `OtherAnswer` is produced. - → memory/SPEC.md §Assumptions: indirect pressure on A23-L; mainly FE-744 UI semantics. - -### Tracer-bullet check - -- **Proof of life:** drives the real `ctx.ui.custom()` component through listed-option and `Other` flows with rendered inline editor snapshots. -- **Invariants:** stabilizes the answer payload mapping: listed options → `OptionAnswer[]` plus optional global `note`; `Other` → one `OtherAnswer` and omitted/empty `note`. -- **Uncertainty:** retires the prototype's remaining real-UI uncertainty around inline editor focus/render feasibility. - -### Acceptance Criteria - -✓ **single listed JIT** — selecting a listed single-select option renders the inline editor immediately, allows submitting with an empty note, and allows submitting `{ answers: [OptionAnswer], note }` after typing context. - -✓ **single Other JIT** — selecting `Other` renders the same inline editor as a required custom-answer field, blocks empty submission, and submits `{ answers: [OtherAnswer] }` with empty/omitted note after text entry. - -✓ **multi listed JIT** — selecting multiple listed options renders one global inline editor and submits sorted `OptionAnswer` values plus one global note. - -✓ **multi Other exclusivity** — selecting `Other` in multi-select clears listed selections, reuses the inline editor as required custom-answer text, and submits exactly one `OtherAnswer`. - -✓ **no second note tab** — option flows no longer enter a separate note-only mode after selection; rendered snapshots show picker plus inline editor in the same active surface before submission. - -✓ **selection-change behavior** — changing listed selections after typing preserves the editor text as global note; switching to `Other` interprets the editor text as custom answer text. - -✓ **result payload compatibility** — existing structured details keep `question`, `context`, `mode`, `options`, `answers`, `rejectedOptions`, `note`, and `transport` semantics; RPC editor fallback parsing remains compatible with listed-option notes and `OtherAnswer` values. - -### Verification Approach - -- **Inner:** `npm run fix` after meaningful edits; targeted `vitest src/structured-exchange.test.ts src/ask-user-question-extension.test.ts` during the loop. -- **Middle:** component-driving tests with render snapshots before and after input, proving the real custom component displays the inline editor and produces the expected details payloads. -- **Outer:** manual TUI smoke or scripted pty check if available: answer one single-select listed option, one single-select `Other`, one multi-select listed combination, and one multi-select `Other` path; confirm the interaction feels like one surface rather than a second tab. - -### Cross-cutting obligations - -- Do not edit `memory/CARDS.md` for this side mission while another builder owns it. -- Keep prompt/question content transcript-backed; do not introduce a parallel chat/turn store or non-transcript response state. -- Keep `Other` as an answer value, not a note. -- Preserve the finding that resumed transcripts appear to replay only `renderResult`; if this slice does not make `renderResult` question/context-self-contained, leave a visible follow-up for that separate replay-rendering slice. -- Keep review-set approval/comment semantics out of this slice. - -### Notes for build - -- Existing tests named around the second note step should be renamed or replaced rather than preserved as compatibility behavior. -- The throwaway prototype `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` should be deleted in the same build once production tests cover its cases, unless the builder explicitly keeps it with a deletion trigger. - -### Build Result - -Implemented in `src/tui-client/.pi/extensions/structured-exchange/index.ts` and covered by `src/structured-exchange.test.ts`. - -- Single-select listed options now reveal one inline optional-context editor and submit `OptionAnswer` plus optional `note`. -- Single-select `Other` uses the same inline editor as required custom-answer text and submits `OtherAnswer` with empty note. -- Multi-select listed options use one global inline editor; listed answer changes preserve the editor text as global note. -- Multi-select `Other` is exclusive: it clears listed options and submits one `OtherAnswer`. -- The separate note-only mode/tab was removed from option flows. -- The prototype file `src/pi-extensions/structured-exchange-jit-editor.prototype.ts` was deleted after production tests covered its cases. - -Verification run: - -```sh -npm run check -npx vitest --run src/structured-exchange.test.ts src/ask-user-question-extension.test.ts -npm run test -npm run build -``` - -Canonical follow-up: promote the durable UI semantics into `memory/SPEC.md`/`memory/PLAN.md` after the concurrent RPC builder's edits settle. The replay finding remains open: production `renderResult` still needs a separate replay-self-contained question/context slice if resumed transcripts continue to replay only results. From 08763bb95a7c25513cbcb4f827b456357a49af0b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:35:15 +0200 Subject: [PATCH 128/164] Implement structured exchange request choices --- memory/CARDS.md | 259 +++++++++++++++ src/brunch-tui.test.ts | 5 +- ...tructured-exchange-present-request.test.ts | 109 +++++++ .../extensions/structured-exchange/index.ts | 3 +- .../structured-exchange/request-choices.ts | 308 +++++++++++++++++- 5 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..46dba6a5 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,259 @@ +<!-- CARDS.md — temporary scope-card queue for one active frontier item. + Created by ln-scope. Delete or overwrite when exhausted/superseded. + Canonical planning state remains memory/SPEC.md and memory/PLAN.md. --> + +# Scope Card Queue — FE-744 public RPC structured-exchange parity + +## Orientation + +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch JSON-RPC structured-elicitation relay over Pi transcript truth. +- **Frontier boundary:** one existing Linear/branch unit: FE-744 / `ln/fe-744-pi-ui-extension-patterns`. These cards are commit-sized slices inside that frontier, not new Linear issues or branches. +- **Volatile state:** `HANDOFF.md` is transfer state only. The handoff flagged `memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md` as protected cleanup; current git status does not show a tracked deletion, but do not recreate/delete/overwrite that path without confirmation. +- **Main risk:** accidentally proving the old lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop instead of the durable structured-exchange tuple model, or exposing raw Pi RPC/editor fallback as the product API. + +## Queue Discipline + +- Consume cards in order unless implementation reveals a blocker that invalidates later scopes. +- Each card should be verified and committed independently. +- Stop and rescope if a card requires changing `memory/SPEC.md` requirements/decisions/invariants rather than merely implementing the existing D37-L/D38-L/D49-L shape. +- Inner loop after meaningful edits: `npm run fix`. Gate before each commit: `npm run verify`. + +## Card 1 — Implement `request_choices` as a durable structured-exchange request tool + +**Status:** done +**Weight:** full scope card + +### Target Behavior + +`request_choices` is a registered structured-exchange request tool that collects one-or-more option choices through the RPC-compatible editor fallback and persists terminal `brunch.structured_exchange.request` details. + +### Boundary Crossings + +```text +→ structured-exchange tool registry +→ request_choices tool parameter/result schema +→ Pi UI adapter (`ctx.ui.editor` JSON fallback for RPC-compatible multi-select) +→ durable toolResult.content/details +``` + +### Risks and Assumptions + +- RISK: The existing editor-fallback helper emits the legacy `brunch.structured_exchange.result` details shape rather than the current present/request request schema. + → MITIGATION: Add or refactor a request-schema-specific editor prefill/parser/result helper; keep the legacy helper only for old probe support if still needed. +- RISK: “Other” and “None” comment rules get flattened into an optional note and stop being enforceable. + → MITIGATION: Model `allowOther` / `allowNone` explicitly; parser rejects answered responses containing `other` or `none` without a nonblank comment. +- ASSUMPTION: Multi-select over public-RPC-compatible UI can be represented as schema-tagged JSON over `ctx.ui.editor` until a richer product form lands. + → IMPACT IF FALSE: Card 4 cannot exercise the required custom-UI-over-RPC fallback without raw Pi RPC or a bespoke product form. + → VALIDATE: request tool execute tests with fake editor contexts for answered/cancelled/invalid JSON and a probe-compatible payload. + → memory/SPEC.md: D38-L, I23-L, A23-L. + +### Tracer-bullet Check + +- **Proof of life:** lights up the currently stubbed multi-choice response tool needed by the parity proof. +- **Invariants:** reinforces that semantic response truth lives in request `toolResult.details`, not editor lifecycle state. +- **Uncertainty:** retires the local risk that multi-choice needs a new raw Pi RPC command shape. + +### Acceptance Criteria + +✓ `structured-exchange request_choices registry test` — `request_choices` moves from `STRUCTURED_EXCHANGE_STUB_TOOL_NAMES` into `STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS` and is registered by the extension entrypoint. +✓ `request_choices editor fallback tests` — answered multi-choice results persist `schema: "brunch.structured_exchange.request"`, `requestTool: "request_choices"`, `status: "answered"`, `choices`, optional `comment`, `respondsTo.presentTool: "present_options"`, and `transport` only if the request model deliberately carries one. +✓ `request_choices comment validation tests` — `other` or `none` answers without a nonblank comment are rejected or returned as `unavailable` with an explicit validation message; listed-option-only responses may omit comment. +✓ `request_choices markdown test` — `toolResult.content` is readable markdown summarizing selected choices and any comment. + +### Verification Approach + +- Inner: focused Vitest unit tests for schemas, parser, execute behavior, and markdown rendering. +- Middle: existing structured-exchange extension registry tests prove the active/stub split is updated intentionally. +- Outer: none for this card; Card 4 supplies the public-RPC parity proof using `request_choices`. + +### Cross-cutting Obligations + +- Preserve D37-L: `renderCall` remains transient; semantic display/response truth is in `renderResult` / `toolResult.content` and `toolResult.details`. +- Preserve D38-L: JSON-over-editor is an adapter behind Brunch/Pi, not the public product API. +- Do not implement review-set or candidate stubs as collateral work. + +--- + +## Card 2 — Project present/request structured-exchange tuples as pending and completed elicitation exchanges + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +Session projections recognize unmatched `present_*` tool results as pending exchanges and matching terminal `request_*` tool results as response-side exchange closures. + +### Boundary Crossings + +```text +→ Pi JSONL session envelope +→ structured-exchange present/request details classifiers +→ elicitation-exchange projection +→ session.pendingExchange / session.transcriptDisplay RPC projections +``` + +### Risks and Assumptions + +- RISK: Current projection code classifies only the legacy `brunch.structured_exchange.result` terminal details, so new request details could remain prompt-side or invisible. + → MITIGATION: Add classifiers for `brunch.structured_exchange.present` and `brunch.structured_exchange.request`; keep legacy support only while old probes require it. +- RISK: Transcript display omits toolResult content even though D37-L treats it as durable user-facing transcript content. + → MITIGATION: Render present tool results as assistant/prompt display rows and terminal request results as user/response display rows in Brunch projections. +- ASSUMPTION: Tuple recovery from `exchangeId` + expected request is sufficient without a parallel pending table. + → IMPACT IF FALSE: Cards 3–4 would need an alternate in-memory or store-backed pending-exchange model, changing the FE-744 proof shape. + → VALIDATE: synthetic JSONL projection tests for open, closed, mismatched, and multiple sequential tuples. + → memory/SPEC.md: D13-L, D37-L, I23-L, I32-L, A23-L. + +### Tracer-bullet Check + +- **Proof of life:** makes tuple-shaped transcript truth visible through the public read projections. +- **Invariants:** stabilizes the no-parallel-chat/turn-store rule for pending state. +- **Uncertainty:** tests whether unmatched-present recovery is enough for public RPC pending state. + +### Acceptance Criteria + +✓ `elicitation projection open tuple test` — a linear transcript containing `present_question` without a terminal `request_answer` projects `status: "open_prompt"` and `session.pendingExchange` returns a product-shaped pending exchange. +✓ `elicitation projection closed tuple test` — a matching terminal `request_answer`, `request_choice`, or `request_choices` result closes the exchange and appears in `responseEntryIds`. +✓ `projection mismatch test` — a terminal request with a different `exchangeId` or incompatible `respondsTo.presentTool` does not close the open prompt silently. +✓ `transcript display tuple test` — present markdown is visible as assistant/prompt text and terminal request markdown is visible as user/response text. +✓ `legacy prompt/response guard` — existing lightweight custom prompt/response projection tests are either intentionally preserved as backward probe support or retired when no longer used by public RPC. + +### Verification Approach + +- Inner: projection/unit tests over synthetic session entries and TypeBox/runtime classifiers. +- Middle: RPC handler tests for `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` reading tuple-shaped sessions by selected and explicit session ids. +- Outer: none for this card; Card 4 supplies end-to-end parity. + +### Cross-cutting Obligations + +- Preserve linear transcript rejection for branched Pi JSONL. +- Do not introduce a canonical chat/turn table or sidecar pending-exchange store. +- Public projection shape should describe Brunch product semantics, not raw Pi RPC events. + +--- + +## Card 3 — Move public RPC start/respond onto structured-exchange tuple truth for one deterministic exchange + +**Status:** queued +**Weight:** full scope card + +### Target Behavior + +`session.startElicitation` and `elicitation.respond` operate on one deterministic structured-exchange tuple instead of appending the old lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` pair. + +### Boundary Crossings + +```text +→ Brunch JSON-RPC handler (`session.startElicitation`) +→ selected workspace/spec/session coordinator state +→ deterministic structured-exchange present builder +→ Pi JSONL toolResult-shaped transcript entries +→ Brunch JSON-RPC handler (`elicitation.respond`) +→ deterministic structured-exchange request builder +→ projection-backed pending/closed exchange reads +``` + +### Risks and Assumptions + +- RISK: Public RPC cannot honestly “use structured-exchange tools” without running a raw Pi RPC agent loop. + → MITIGATION: Route through shared structured-exchange builder/helper code that produces the same `toolResult.content/details` contract; if a real Pi invocation is cheap and stable, it may be hidden behind the Brunch adapter, but the public client still speaks only Brunch RPC. +- RISK: Handler code imports TUI-only picker/custom UI modules while adopting structured-exchange helpers. + → MITIGATION: Keep the architectural source test for no `workspace-dialog` imports; replace the broad “no structured-exchange” assertion with a narrower “no TUI picker/raw Pi RPC public dependency” assertion. +- ASSUMPTION: A product-RPC response can append the terminal request result details directly and still be comparable to TUI/Pi tool execution transcript semantics. + → IMPACT IF FALSE: The parity proof must delegate to an internal Pi RPC adapter rather than in-process tuple append helpers. + → VALIDATE: one-exchange contract tests compare JSONL/projections against expected present/request details and display rows. + → memory/SPEC.md: D37-L, D38-L, D49-L, I23-L, I32-L, A23-L. + +### Tracer-bullet Check + +- **Proof of life:** first product-RPC exchange uses the real tuple shape. +- **Invariants:** establishes public pending/respond semantics over transcript truth rather than custom prompt/response side entries. +- **Uncertainty:** proves whether an in-process Brunch adapter can produce parity-quality transcript artifacts without exposing raw Pi RPC. + +### Acceptance Criteria + +✓ `rpc discover schema update` — `rpc.discover` describes tuple-shaped pending exchange/result schemas for text, single-choice, and multi-choice responses, with examples that do not mention raw Pi RPC. +✓ `start one tuple test` — starting elicitation in an activated session appends exactly one deterministic `present_*` toolResult-shaped transcript entry and returns the projection-backed pending exchange. +✓ `resume open tuple test` — calling `session.startElicitation` while that tuple is open returns the same pending exchange without duplicating transcript entries. +✓ `respond text test` — `elicitation.respond` can close a `present_question → request_answer` pending exchange with a freeform answer. +✓ `respond single-choice test` — `elicitation.respond` can close a `present_options → request_choice` pending exchange with one listed choice and optional comment. +✓ `respond multi-choice test` — `elicitation.respond` can close a `present_options → request_choices` pending exchange with one-or-more choices and required comment for `other`/`none`. +✓ `respond guard tests` — mismatched exchange id, invalid choice id, missing required comment, and duplicate response do not append transcript entries. +✓ `old lightweight loop retired` — public start/respond no longer appends `brunch.elicitation_prompt` / `brunch.elicitation_response` for the deterministic proof path; stale tests are updated or removed. + +### Verification Approach + +- Inner: RPC handler contract tests and transcript JSONL assertions. +- Middle: projection round-trip tests after each start/respond path prove pending closes through `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay`. +- Outer: none for this card; Card 4 expands to ten turns from a fresh cwd. + +### Cross-cutting Obligations + +- Preserve D49-L: public clients use Brunch JSON-RPC methods only. +- Preserve D36-L/I22-L: RPC/headless activation uses structured selection state and activation decisions, not TUI picker code. +- Preserve D37-L: do not encode semantic display in `renderCall` or raw extension UI event order. + +--- + +## Card 4 — Add the deterministic ten-turn public-RPC parity proof + +**Status:** queued +**Weight:** full scope card + +### Target Behavior + +A scripted public Brunch JSON-RPC agent-as-user creates a spec/session from a fresh cwd and completes establishment plus ten structured-exchange elicitation turns with parity assertions over JSONL and projections. + +### Boundary Crossings + +```text +→ probe/test client over Brunch JSON-RPC handlers or stdio host +→ rpc.discover +→ workspace.selectionState +→ workspace.activate(newSpec) +→ session.startElicitation / session.pendingExchange / elicitation.respond loop +→ Pi JSONL transcript in .brunch/sessions +→ session.transcriptDisplay + session.elicitationExchanges projections +→ parity oracle report +``` + +### Risks and Assumptions + +- RISK: The proof counts handler unit tests as parity without exercising fresh project entry and spec/session creation. + → MITIGATION: The probe starts from an empty temp cwd and must create a new spec/session through public activation methods before the first elicitation exchange. +- RISK: The ten-turn script overfits one response mode and fails to prove editor-fallback multi-choice semantics. + → MITIGATION: Fixed script includes at minimum: establishment/framing exchange(s), `present_question → request_answer`, multiple `present_options → request_choice`, and at least one `present_options → request_choices` case that uses the comment-required `other` or `none` path. +- RISK: The proof asserts only method success and misses transcript/projection quality. + → MITIGATION: Parity oracle checks tool names, exchange ids, present-before-request order, response modes, options/rationales, answers, comments, display rows, exchange spans, and absence of old lightweight public-RPC prompt/response entries. +- ASSUMPTION: Public Brunch RPC can drive at least ten assistant-first structured exchanges without raw Pi RPC, graph persistence, or a parallel prompt/turn store. + → IMPACT IF FALSE: FE-744 cannot close A23-L and PLAN sequencing should not move to sealed profile/runtime-state yet. + → VALIDATE: deterministic public-RPC parity test/probe with blocker/friction report. + → memory/SPEC.md: A5-L, A23-L, D5-L, D37-L, D48-L, D49-L, I23-L, I32-L. + +### Tracer-bullet Check + +- **Proof of life:** lights up the full public product path from empty cwd to ten answered assistant-first exchanges. +- **Invariants:** proves selected-session activation, linear transcript truth, pending/respond lifecycle, and tuple projection stay coherent together. +- **Uncertainty:** directly attacks A23-L, the active FE-744 risk blocking profile/runtime-state work. + +### Acceptance Criteria + +✓ `public rpc parity probe` — a deterministic script/test starts from a temp cwd, calls `rpc.discover`, observes selection required, activates `{ action: "newSpec" }`, and obtains a ready spec/session without invoking TUI picker code. +✓ `ten-turn loop oracle` — the probe completes at least ten structured exchanges through `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` only. +✓ `tool coverage oracle` — the resulting transcript includes `present_question`, `request_answer`, `present_options`, `request_choice`, and `request_choices` tuple entries. +✓ `establishment/framing oracle` — initial system/assistant-generated questions establish enough specification/session kind/framing metadata in transcript text/details to explain why later turns are being asked, without requiring graph persistence. +✓ `projection parity oracle` — `session.elicitationExchanges` reports ten completed exchanges with prompt and response spans; `session.transcriptDisplay` preserves prompt/question/option/rationale/answer/comment artifacts at TUI-comparable quality. +✓ `JSONL parity oracle` — every exchange has a recoverable `exchangeId`; each present precedes its matching request; terminal request details contain the correct mode-specific answer payload; no public-proof exchange is represented by `brunch.elicitation_prompt` / `brunch.elicitation_response`. +✓ `blocker/friction report` — the probe returns a compact scenario report with mission, evaluation focus, max-turn budget, completed turns, and any friction encountered. + +### Verification Approach + +- Inner: deterministic handler/probe tests for the ten-turn loop and parity oracle. +- Middle: executable probe under `src/probes/` or equivalent Vitest integration test using public Brunch RPC only; JSONL/projection postcondition checker. +- Outer: not required for this card; web real-time observation smoke is the next FE-744 slice after this queue. + +### Cross-cutting Obligations + +- No graph/data-layer capture is required or expected; transcript/projection is the proof surface for now. +- Do not expose raw Pi RPC/editor fallback as public product API. +- Do not introduce a parallel chat/turn store or durable pending-exchange table. +- Keep the proof deterministic enough to run in `npm run verify` without network/model access. diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index c8d40f43..9ac8d29b 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -299,6 +299,7 @@ describe("Brunch TUI boot", () => { "present_options", "request_answer", "request_choice", + "request_choices", ]) expect(commands.get(BRUNCH_WORKSPACE_COMMAND)?.description).toBe( "Open the Brunch spec/session picker", @@ -697,6 +698,7 @@ describe("Brunch TUI boot", () => { "present_options", "request_answer", "request_choice", + "request_choices", "bash", "edit", "write", @@ -721,6 +723,7 @@ describe("Brunch TUI boot", () => { "present_options", "request_answer", "request_choice", + "request_choices", ], ]) await expect( @@ -729,7 +732,7 @@ describe("Brunch TUI boot", () => { ), ).resolves.toMatchObject({ systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, present_question, present_options, request_answer, request_choice.", + "Brunch exposes only elicit-safe tools: read, grep, find, ls, present_question, present_options, request_answer, request_choice, request_choices.", ), }) await expect( diff --git a/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts index a2f922de..605b1100 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-present-request.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import registerStructuredExchange, { PRESENT_OPTIONS_TOOL, REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, } from "../extensions/structured-exchange/index.js" import { findIncompleteStructuredExchangePresents, @@ -67,9 +68,11 @@ describe("structured exchange present/request tools", () => { PRESENT_OPTIONS_TOOL, "request_answer", REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, ]) expect(tools.get(PRESENT_OPTIONS_TOOL)?.executionMode).toBe("sequential") expect(tools.get(REQUEST_CHOICE_TOOL)?.executionMode).toBe("sequential") + expect(tools.get(REQUEST_CHOICES_TOOL)?.executionMode).toBe("sequential") }) it("persists a present_options result as markdown content plus recoverable details", async () => { @@ -165,6 +168,112 @@ describe("structured exchange present/request tools", () => { }) }) + it("persists a request_choices response through the editor fallback", async () => { + const request = registeredTools().get(REQUEST_CHOICES_TOOL) + if (!request) throw new Error("request_choices was not registered") + + const result = await request.execute( + "request-choices-call-1", + { + exchangeId: "priorities", + respondsToPresentTool: PRESENT_OPTIONS_TOOL, + prompt: "Select all priorities.", + choices: [ + { id: "speed", label: "Move quickly" }, + { id: "safety", label: "Keep the transcript safe" }, + ], + allowOther: true, + commentPrompt: "Optional comment", + }, + undefined, + undefined, + { + hasUI: true, + ui: { + editor: async (prefill: string) => { + const payload = JSON.parse(prefill) + payload.response = { + status: "answered", + choices: [ + { id: "speed", label: "Move quickly" }, + { id: "other", label: "Other" }, + ], + comment: "Also keep the proof deterministic.", + } + return JSON.stringify(payload) + }, + }, + } as never, + ) + + expect(result.content[0]?.text).toContain("### Response") + expect(result.content[0]?.text).toContain("Move quickly") + expect(result.content[0]?.text).toContain("Other") + expect(result.content[0]?.text).toContain( + "Also keep the proof deterministic.", + ) + expect(isStructuredExchangeRequestDetails(result.details)).toBe(true) + expect(result.details).toMatchObject({ + schema: "brunch.structured_exchange.request", + exchangeId: "priorities", + requestTool: REQUEST_CHOICES_TOOL, + status: "answered", + respondsTo: { + exchangeId: "priorities", + presentTool: PRESENT_OPTIONS_TOOL, + }, + choices: [ + { id: "speed", label: "Move quickly" }, + { id: "other", label: "Other" }, + ], + comment: "Also keep the proof deterministic.", + createdAtToolCallId: "request-choices-call-1", + }) + }) + + it("rejects request_choices other/none selections without a comment", async () => { + const request = registeredTools().get(REQUEST_CHOICES_TOOL) + if (!request) throw new Error("request_choices was not registered") + + const result = await request.execute( + "request-choices-call-2", + { + exchangeId: "priorities", + respondsToPresentTool: PRESENT_OPTIONS_TOOL, + prompt: "Select all priorities.", + choices: [{ id: "speed", label: "Move quickly" }], + allowOther: true, + allowNone: true, + }, + undefined, + undefined, + { + hasUI: true, + ui: { + editor: async (prefill: string) => { + const payload = JSON.parse(prefill) + payload.response = { + status: "answered", + choices: [{ id: "none", label: "None" }], + comment: " ", + } + return JSON.stringify(payload) + }, + }, + } as never, + ) + + expect(result.details).toMatchObject({ + requestTool: REQUEST_CHOICES_TOOL, + status: "unavailable", + message: + "request_choices requires a comment for Other or None selections", + }) + expect(result.content[0]?.text).toContain( + "request_choices requires a comment", + ) + }) + it("detects an unmatched present result for recovery", () => { const incomplete = findIncompleteStructuredExchangePresents([ { diff --git a/src/tui-client/.pi/extensions/structured-exchange/index.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts index 51381ff1..32d5e1a2 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/index.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/index.ts @@ -55,18 +55,17 @@ export const STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS = [ presentOptionsTool, requestAnswerTool, requestChoiceTool, + requestChoicesTool, ] as const export const STRUCTURED_EXCHANGE_STUB_TOOL_NAMES = [ PRESENT_REVIEW_SET_TOOL, PRESENT_CANDIDATES_TOOL, - REQUEST_CHOICES_TOOL, REQUEST_REVIEW_TOOL, ] as const void presentReviewSetTool void presentCandidatesTool -void requestChoicesTool void requestReviewTool export default function registerStructuredExchange(pi: ExtensionAPI) { diff --git a/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts b/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts index a46a0ba2..cec83b56 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/request-choices.ts @@ -1,5 +1,307 @@ +import { defineTool } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +import { + markdownEscape, + normalizeOptionalText, + renderMarkdownResult, +} from "./shared/markdown.js" +import { + isRecord, + STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + type StructuredExchangeChoice, + type StructuredExchangeRequestDetails, +} from "./shared/model.js" + export const REQUEST_CHOICES_TOOL = "request_choices" as const -// Stubbed intentionally: multi-choice response semantics are named now, but the -// implementation waits until the present/request ordering proof is complete. -export const requestChoicesTool = undefined +const ChoiceSchema = Type.Object({ + id: Type.String({ + description: "Stable choice id from the corresponding present_* entry.", + }), + label: Type.String({ + description: "Short choice label shown in the live selection UI.", + }), +}) + +export const RequestChoicesParams = Type.Object({ + exchangeId: Type.String({ + description: + "The structured exchange id from the corresponding present_options entry.", + }), + respondsToPresentTool: Type.Literal("present_options"), + prompt: Type.String({ + description: + "Short live-input prompt. Do not repeat the presented content.", + }), + choices: Type.Array(ChoiceSchema, { + description: "Listed choices available for this multi-choice response.", + }), + allowOther: Type.Optional( + Type.Boolean({ description: "Whether the user may choose Other." }), + ), + allowNone: Type.Optional( + Type.Boolean({ description: "Whether the user may choose None." }), + ), + commentPrompt: Type.Optional( + Type.String({ + description: + "Prompt for an optional comment. Required when Other or None is selected.", + }), + ), +}) + +interface EditorChoice { + id: string + label?: string +} + +interface EditorResponse { + status: "answered" | "cancelled" + choices: EditorChoice[] + comment: string +} + +function buildEditorPrefill(params: { + prompt: string + choices: readonly StructuredExchangeChoice[] + allowOther?: boolean + allowNone?: boolean + commentPrompt?: string +}): string { + const choices = [ + ...params.choices, + ...(params.allowOther ? [{ id: "other", label: "Other" }] : []), + ...(params.allowNone ? [{ id: "none", label: "None" }] : []), + ] + return JSON.stringify( + { + schema: "brunch.structured_exchange.request_choices.editor", + schemaVersion: 1, + prompt: params.prompt, + mode: "multi-choice", + choices, + instructions: [ + "Edit only response.", + "Set response.status to answered or cancelled.", + "For each selected choice, include its id in response.choices.", + "Set response.comment to a string. Other or None requires a nonblank comment.", + ], + commentPrompt: params.commentPrompt ?? "Optional comment", + response: { status: "cancelled", choices: [], comment: "" }, + }, + null, + 2, + ) +} + +function parseEditorResponse(value: string): EditorResponse | null { + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return null + } + if (!isRecord(parsed)) return null + const response = parsed.response + if (!isRecord(response)) return null + + if (response.status === "cancelled") { + return { status: "cancelled", choices: [], comment: "" } + } + if (response.status !== "answered") return null + if (!Array.isArray(response.choices)) return null + if (typeof response.comment !== "string") return null + + const choices = response.choices.map((choice): EditorChoice | null => { + if (!isRecord(choice) || typeof choice.id !== "string") return null + return { + id: choice.id, + ...(typeof choice.label === "string" ? { label: choice.label } : {}), + } + }) + if (choices.some((choice) => choice === null)) return null + return { + status: "answered", + choices: choices as EditorChoice[], + comment: response.comment, + } +} + +function requestMarkdown(details: StructuredExchangeRequestDetails): string { + if (details.status === "cancelled") + return "### Response\n\n_User cancelled the request._" + if (details.status === "unavailable") { + return `### Response\n\n_${details.message ?? "Response UI unavailable."}_` + } + + const lines = ["### Response"] + if (details.choices && details.choices.length > 0) { + lines.push( + "", + ...details.choices.map((choice) => `- ${markdownEscape(choice.label)}`), + ) + } + if (details.comment) lines.push("", "Comment:", "", `> ${details.comment}`) + return lines.join("\n") +} + +function unavailable( + base: Omit<StructuredExchangeRequestDetails, "status">, + message: string, +) { + const details: StructuredExchangeRequestDetails = { + ...base, + status: "unavailable", + message, + } + return { + content: [{ type: "text" as const, text: requestMarkdown(details) }], + details, + } +} + +function matchSelectedChoices( + selected: readonly EditorChoice[], + params: { + choices: readonly StructuredExchangeChoice[] + allowOther?: boolean + allowNone?: boolean + }, +): StructuredExchangeChoice[] | string { + const allowed = new Map(params.choices.map((choice) => [choice.id, choice])) + if (params.allowOther) allowed.set("other", { id: "other", label: "Other" }) + if (params.allowNone) allowed.set("none", { id: "none", label: "None" }) + + const matched: StructuredExchangeChoice[] = [] + const seen = new Set<string>() + for (const choice of selected) { + const known = allowed.get(choice.id) + if (!known) + return `request_choices received unknown choice id: ${choice.id}` + if (seen.has(choice.id)) continue + seen.add(choice.id) + matched.push({ id: known.id, label: choice.label ?? known.label }) + } + if (matched.length === 0) + return "request_choices requires at least one choice" + return matched +} + +export const requestChoicesTool = defineTool({ + name: REQUEST_CHOICES_TOOL, + label: "Request choices", + description: + "Collect one-or-more user choices as the request half of a Brunch structured exchange. Use only after the corresponding present_options tool result has displayed the offer content.", + promptSnippet: "Request multiple choices after presenting structured options", + promptGuidelines: [ + "Use request_choices only after the matching present_options tool.", + "Do not repeat the present_options markdown content in request_choices parameters; reference it by exchangeId.", + "Require a comment when the response selects Other or None.", + ], + parameters: RequestChoicesParams, + executionMode: "sequential", + + async execute(toolCallId, params, _signal, _onUpdate, ctx) { + const choices: StructuredExchangeChoice[] = params.choices.map( + (choice) => ({ + id: choice.id, + label: choice.label, + }), + ) + const base = { + schema: STRUCTURED_EXCHANGE_REQUEST_SCHEMA, + schemaVersion: 1 as const, + exchangeId: params.exchangeId, + requestTool: REQUEST_CHOICES_TOOL, + respondsTo: { + exchangeId: params.exchangeId, + presentTool: params.respondsToPresentTool, + }, + createdAtToolCallId: toolCallId, + } + + if (!ctx.hasUI || typeof ctx.ui.editor !== "function") { + return unavailable(base, "request_choices requires interactive UI") + } + + const editorPrefillParams: Parameters<typeof buildEditorPrefill>[0] = { + prompt: params.prompt, + choices, + } + if (params.allowOther !== undefined) + editorPrefillParams.allowOther = params.allowOther + if (params.allowNone !== undefined) + editorPrefillParams.allowNone = params.allowNone + if (params.commentPrompt !== undefined) + editorPrefillParams.commentPrompt = params.commentPrompt + + const edited = await ctx.ui.editor(buildEditorPrefill(editorPrefillParams)) + if (edited === undefined) { + const details: StructuredExchangeRequestDetails = { + ...base, + status: "cancelled", + } + return { + content: [{ type: "text" as const, text: requestMarkdown(details) }], + details, + } + } + + const response = parseEditorResponse(edited) + if (!response) { + return unavailable( + base, + "request_choices editor fallback returned invalid JSON", + ) + } + if (response.status === "cancelled") { + const details: StructuredExchangeRequestDetails = { + ...base, + status: "cancelled", + } + return { + content: [{ type: "text" as const, text: requestMarkdown(details) }], + details, + } + } + + const matchParams: Parameters<typeof matchSelectedChoices>[1] = { choices } + if (params.allowOther !== undefined) + matchParams.allowOther = params.allowOther + if (params.allowNone !== undefined) matchParams.allowNone = params.allowNone + + const matched = matchSelectedChoices(response.choices, matchParams) + if (typeof matched === "string") return unavailable(base, matched) + + const comment = normalizeOptionalText(response.comment) + if ( + matched.some((choice) => choice.id === "other" || choice.id === "none") && + comment === undefined + ) { + return unavailable( + base, + "request_choices requires a comment for Other or None selections", + ) + } + + const details: StructuredExchangeRequestDetails = { + ...base, + status: "answered", + choices: matched, + ...(comment !== undefined ? { comment } : {}), + } + return { + content: [{ type: "text" as const, text: requestMarkdown(details) }], + details, + } + }, + + renderCall() { + return renderMarkdownResult({ content: [] }) + }, + + renderResult(result, _options, theme) { + return renderMarkdownResult(result, theme) + }, +}) From 90e015f02303e2f7fdb0fa290a64ac2a8dd8ed95 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:39:26 +0200 Subject: [PATCH 129/164] Project structured exchange tuples --- memory/CARDS.md | 4 +- src/elicitation-exchange.test.ts | 185 +++++++++++++++++++++++++++++++ src/elicitation-exchange.ts | 106 +++++++++++++++++- src/rpc/handlers.test.ts | 147 +++++++++++++++++++++++- src/rpc/handlers.ts | 86 +++++++++++++- 5 files changed, 520 insertions(+), 8 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 46dba6a5..2ff069fd 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -76,7 +76,7 @@ ## Card 2 — Project present/request structured-exchange tuples as pending and completed elicitation exchanges -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior @@ -133,7 +133,7 @@ Session projections recognize unmatched `present_*` tool results as pending exch ## Card 3 — Move public RPC start/respond onto structured-exchange tuple truth for one deterministic exchange -**Status:** queued +**Status:** next **Weight:** full scope card ### Target Behavior diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index bdb32db6..7945bbae 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -39,6 +39,96 @@ const toolResult = { isError: false, }, } +const presentQuestionToolResult = { + id: "present-question-1", + type: "message", + parentId: null, + message: { + role: "toolResult", + toolCallId: "present-call-1", + toolName: "present_question", + content: [{ type: "text", text: "## Domain?\n\nWhat are we specifying?" }], + details: { + schema: "brunch.structured_exchange.present", + schemaVersion: 1, + exchangeId: "domain", + presentTool: "present_question", + kind: "question", + status: "presented", + expectedRequest: { tool: "request_answer", required: true }, + createdAtToolCallId: "present-call-1", + }, + isError: false, + }, +} +const requestAnswerToolResult = { + id: "request-answer-1", + type: "message", + parentId: "present-question-1", + message: { + role: "toolResult", + toolCallId: "request-call-1", + toolName: "request_answer", + content: [{ type: "text", text: "### Response\n\nDeveloper tooling" }], + details: { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: "domain", + requestTool: "request_answer", + status: "answered", + respondsTo: { exchangeId: "domain", presentTool: "present_question" }, + answer: "Developer tooling", + createdAtToolCallId: "request-call-1", + }, + isError: false, + }, +} +const mismatchedRequestAnswerToolResult = { + ...requestAnswerToolResult, + id: "request-answer-mismatch", + message: { + ...requestAnswerToolResult.message, + details: { + ...requestAnswerToolResult.message.details, + exchangeId: "other-domain", + respondsTo: { + exchangeId: "other-domain", + presentTool: "present_question", + }, + }, + }, +} +const requestChoicesToolResult = { + id: "request-choices-1", + type: "message", + parentId: "present-options-1", + message: { + role: "toolResult", + toolCallId: "request-call-choices-1", + toolName: "request_choices", + content: [ + { + type: "text", + text: "### Response\n\n- Move quickly\n- Other\n\nComment:\n\n> Keep it deterministic.", + }, + ], + details: { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: "domain", + requestTool: "request_choices", + status: "answered", + respondsTo: { exchangeId: "domain", presentTool: "present_options" }, + choices: [ + { id: "speed", label: "Move quickly" }, + { id: "other", label: "Other" }, + ], + comment: "Keep it deterministic.", + createdAtToolCallId: "request-call-choices-1", + }, + isError: false, + }, +} const structuredExchangeToolResult = { id: "sq1", type: "message", @@ -216,6 +306,101 @@ describe("elicitation exchange projection", () => { }) }) + it("projects an unmatched present tool result as an open prompt", () => { + const projection = projectElicitationExchanges([presentQuestionToolResult]) + + expect(projection).toEqual({ + status: "open_prompt", + exchanges: [], + openPrompt: { + promptRange: { start: "present-question-1", end: "present-question-1" }, + promptEntryIds: ["present-question-1"], + }, + }) + }) + + it("closes a present/request structured-exchange tuple only when request details match", () => { + const projection = projectElicitationExchanges([ + presentQuestionToolResult, + requestAnswerToolResult, + ]) + + expect(projection).toEqual({ + status: "ready", + exchanges: [ + { + promptRange: { + start: "present-question-1", + end: "present-question-1", + }, + responseRange: { start: "request-answer-1", end: "request-answer-1" }, + promptEntryIds: ["present-question-1"], + responseEntryIds: ["request-answer-1"], + }, + ], + openPrompt: null, + }) + }) + + it("does not close an open present with a mismatched request tuple", () => { + const projection = projectElicitationExchanges([ + presentQuestionToolResult, + mismatchedRequestAnswerToolResult, + ]) + + expect(projection.exchanges).toEqual([]) + expect(projection.openPrompt?.promptEntryIds).toEqual([ + "present-question-1", + ]) + }) + + it("closes present_options with a terminal request_choices result", () => { + const presentOptions = { + ...presentQuestionToolResult, + id: "present-options-1", + message: { + ...presentQuestionToolResult.message, + toolName: "present_options", + details: { + ...presentQuestionToolResult.message.details, + presentTool: "present_options", + kind: "options", + expectedRequest: { tool: "request_choices", required: true }, + }, + }, + } + + const projection = projectElicitationExchanges([ + presentOptions, + requestChoicesToolResult, + ]) + + expect(projection.exchanges[0]?.responseEntryIds).toEqual([ + "request-choices-1", + ]) + expect(projection.openPrompt).toBeNull() + }) + + it("renders structured-exchange present/request tool markdown as transcript rows", () => { + const projection = projectTranscriptDisplay([ + presentQuestionToolResult, + requestAnswerToolResult, + ]) + + expect(projection.rows).toEqual([ + { + id: "present-question-1", + role: "prompt", + text: "## Domain?\n\nWhat are we specifying?", + }, + { + id: "request-answer-1", + role: "user", + text: "### Response\n\nDeveloper tooling", + }, + ]) + }) + it("classifies terminal structured-exchange tool results as response-side entries", () => { const projection = projectElicitationExchanges([ assistant, diff --git a/src/elicitation-exchange.ts b/src/elicitation-exchange.ts index f50a939a..24b87dc4 100644 --- a/src/elicitation-exchange.ts +++ b/src/elicitation-exchange.ts @@ -13,6 +13,14 @@ import { type BrunchSessionEnvelope, } from "./brunch-session-envelope.js" import { isTerminalStructuredExchangeResultDetails } from "./structured-exchange.js" +import { + isStructuredExchangePresentDetails, + isStructuredExchangeRequestDetails, +} from "./tui-client/.pi/extensions/structured-exchange/shared/recovery.js" +import type { + StructuredExchangePresentDetails, + StructuredExchangeRequestDetails, +} from "./tui-client/.pi/extensions/structured-exchange/shared/model.js" const PROMPT_SIDE_CUSTOM_TYPES = new Set([ "brunch.elicitation_prompt", @@ -123,13 +131,23 @@ export function projectTranscriptDisplay( continue } - const role = entry.message.role - if (role !== "assistant" && role !== "user") { + const text = textContent((entry.message as { content?: unknown }).content) + if (text.length === 0) { continue } - const text = textContent(entry.message.content) - if (text.length === 0) { + if (isStructuredExchangePresentToolResult(entry)) { + rows.push({ id: entry.id, role: "prompt", text }) + continue + } + + if (isStructuredExchangeRequestToolResult(entry)) { + rows.push({ id: entry.id, role: "user", text }) + continue + } + + const role = entry.message.role + if (role !== "assistant" && role !== "user") { continue } @@ -144,12 +162,33 @@ export function projectElicitationExchanges( const exchanges: ElicitationExchange[] = [] let promptIds: string[] = [] let responseIds: string[] = [] + let openStructuredExchange: StructuredExchangePresentDetails | undefined for (const entry of entries) { if (!isTranscriptEntry(entry)) { continue } + const presentDetails = structuredExchangePresentDetails(entry) + if (presentDetails) { + flushResponse() + promptIds.push(entry.id) + openStructuredExchange = presentDetails + continue + } + + const requestDetails = structuredExchangeRequestDetails(entry) + if (requestDetails) { + if ( + promptIds.length > 0 && + openStructuredExchange !== undefined && + requestClosesPresent(requestDetails, openStructuredExchange) + ) { + responseIds.push(entry.id) + } + continue + } + if (isPromptSideEntry(entry)) { flushResponse() promptIds.push(entry.id) @@ -193,6 +232,7 @@ export function projectElicitationExchanges( }) promptIds = [] responseIds = [] + openStructuredExchange = undefined } } @@ -221,6 +261,64 @@ function hasStringOrNullParentId(value: unknown): boolean { ) } +function requestClosesPresent( + request: StructuredExchangeRequestDetails, + present: StructuredExchangePresentDetails, +): boolean { + return ( + request.status === "answered" && + request.exchangeId === present.exchangeId && + request.respondsTo.exchangeId === present.exchangeId && + request.respondsTo.presentTool === present.presentTool && + (present.expectedRequest === undefined || + present.expectedRequest.tool === request.requestTool) + ) +} + +function structuredExchangePresentDetails( + entry: SessionEntry, +): StructuredExchangePresentDetails | undefined { + if (!isStructuredExchangePresentToolResult(entry)) return undefined + return (entry.message as { details?: unknown }) + .details as StructuredExchangePresentDetails +} + +function structuredExchangeRequestDetails( + entry: SessionEntry, +): StructuredExchangeRequestDetails | undefined { + if (!isStructuredExchangeRequestToolResult(entry)) return undefined + return (entry.message as { details?: unknown }) + .details as StructuredExchangeRequestDetails +} + +function isStructuredExchangePresentToolResult( + entry: SessionEntry, +): entry is SessionMessageEntry & { + message: SessionMessageEntry["message"] & { details?: unknown } +} { + return ( + isMessageEntry(entry) && + entry.message.role === "toolResult" && + isStructuredExchangePresentDetails( + (entry.message as { details?: unknown }).details, + ) + ) +} + +function isStructuredExchangeRequestToolResult( + entry: SessionEntry, +): entry is SessionMessageEntry & { + message: SessionMessageEntry["message"] & { details?: unknown } +} { + return ( + isMessageEntry(entry) && + entry.message.role === "toolResult" && + isStructuredExchangeRequestDetails( + (entry.message as { details?: unknown }).details, + ) + ) +} + function isPromptSideEntry(entry: SessionEntry): boolean { if (isCustomTranscriptEntry(entry)) { return PROMPT_SIDE_CUSTOM_TYPES.has(entry.customType) diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index fae8700d..e70cbf00 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -163,6 +163,61 @@ function appendBinding(manager: SessionManager): void { ) } +function presentQuestionEntry() { + return { + id: "present-question-1", + type: "message", + parentId: "binding-session-1-spec-1", + message: { + role: "toolResult", + toolCallId: "present-call-1", + toolName: "present_question", + content: [ + { type: "text", text: "## Domain?\n\nWhat are we specifying?" }, + ], + details: { + schema: "brunch.structured_exchange.present", + schemaVersion: 1, + exchangeId: "domain", + presentTool: "present_question", + kind: "question", + status: "presented", + expectedRequest: { tool: "request_answer", required: true }, + createdAtToolCallId: "present-call-1", + }, + isError: false, + }, + } +} + +function requestAnswerEntry(parentId = "present-question-1") { + return { + id: "request-answer-1", + type: "message", + parentId, + message: { + role: "toolResult", + toolCallId: "request-call-1", + toolName: "request_answer", + content: [{ type: "text", text: "### Response\n\nDeveloper tooling" }], + details: { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: "domain", + requestTool: "request_answer", + status: "answered", + respondsTo: { + exchangeId: "domain", + presentTool: "present_question", + }, + answer: "Developer tooling", + createdAtToolCallId: "request-call-1", + }, + isError: false, + }, + } +} + function sessionBindingEntry(sessionId = "session-1", specId = "spec-1") { return { id: `binding-${sessionId}-${specId}`, @@ -447,7 +502,7 @@ describe("JSON-RPC handlers", () => { expect(source).not.toContain("workspace-dialog") expect(source).not.toContain("createWorkspaceDialogComponent") - expect(source).not.toContain("structured-exchange") + expect(source).not.toContain("pi --mode rpc") }) it("serves a named workspace snapshot method", async () => { @@ -653,6 +708,96 @@ describe("JSON-RPC handlers", () => { }) }) + it("reads an explicit tuple-shaped pending exchange without a sidecar prompt store", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-tuple-pending-")) + await writeExplicitSessionFixture(cwd, [ + { type: "session", id: "session-1", cwd }, + sessionBindingEntry(), + presentQuestionEntry(), + ]) + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 149, + method: "session.pendingExchange", + params: { sessionId: "session-1", specId: "spec-1" }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 149, + result: { + status: "pending", + exchange: { + exchangeId: "domain", + mode: "text", + prompt: "Domain?", + details: expect.stringContaining("What are we specifying?"), + }, + }, + }) + }) + + it("serves tuple-shaped exchange and transcript projections explicitly", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-tuple-projection-")) + await writeExplicitSessionFixture(cwd, [ + { type: "session", id: "session-1", cwd }, + sessionBindingEntry(), + presentQuestionEntry(), + requestAnswerEntry(), + ]) + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 150, + method: "session.elicitationExchanges", + params: { sessionId: "session-1", specId: "spec-1" }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 150, + result: { + status: "ready", + exchanges: [ + { + promptEntryIds: ["present-question-1"], + responseEntryIds: ["request-answer-1"], + }, + ], + }, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 151, + method: "session.transcriptDisplay", + params: { sessionId: "session-1", specId: "spec-1" }, + }), + ).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 151, + result: { + rows: [ + { role: "prompt", text: expect.stringContaining("Domain?") }, + { + role: "user", + text: expect.stringContaining("Developer tooling"), + }, + ], + }, + }) + }) + it("reports idle pending state when the selected session has no open prompt", async () => { const sessionFile = await createSessionFile() const handlers = createRpcHandlers({ diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index d9a862bf..068175ec 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -13,6 +13,8 @@ import { projectLinearElicitationExchangeProjection, projectLinearTranscriptDisplayProjection, } from "../elicitation-exchange.js" +import { isStructuredExchangePresentDetails } from "../tui-client/.pi/extensions/structured-exchange/shared/recovery.js" +import type { StructuredExchangePresentDetails } from "../tui-client/.pi/extensions/structured-exchange/shared/model.js" import { createJsonRpcFailure, createJsonRpcSuccess, @@ -319,7 +321,11 @@ const PendingElicitationExchangeSchema = Type.Object( { exchangeId: NonBlankStringSchema, lens: Type.Literal("step-by-step"), - mode: Type.Literal("single-select"), + mode: Type.Union([ + Type.Literal("text"), + Type.Literal("single-select"), + Type.Literal("multi-select"), + ]), prompt: NonBlankStringSchema, details: Type.Optional(NonBlankStringSchema), options: Type.Array( @@ -771,9 +777,87 @@ function pendingExchangeFromEnvelope( } } + for (const entryId of projection.openPrompt.promptEntryIds) { + const entry = envelope.entries.find( + (candidate) => candidate.type === "message" && candidate.id === entryId, + ) + const details = structuredExchangePresentDetails(entry) + if (!details) continue + const text = textContent( + (entry as { message: { content?: unknown } }).message.content, + ) + return pendingExchangeFromStructuredPresent(details, text) + } + return null } +function pendingExchangeFromStructuredPresent( + details: StructuredExchangePresentDetails, + markdown: string, +): PendingElicitationExchange { + return { + exchangeId: details.exchangeId, + lens: "step-by-step", + mode: + details.expectedRequest?.tool === "request_choices" + ? "multi-select" + : details.presentTool === "present_question" + ? "text" + : "single-select", + prompt: firstNonEmptyMarkdownLine(markdown) ?? markdown, + ...(markdown.length > 0 ? { details: markdown } : {}), + options: [], + note: { allowed: true }, + } +} + +function structuredExchangePresentDetails( + entry: unknown, +): StructuredExchangePresentDetails | undefined { + if ( + typeof entry !== "object" || + entry === null || + (entry as { type?: unknown }).type !== "message" + ) { + return undefined + } + const message = (entry as { message?: unknown }).message + if ( + typeof message !== "object" || + message === null || + (message as { role?: unknown }).role !== "toolResult" + ) { + return undefined + } + const details = (message as { details?: unknown }).details + return isStructuredExchangePresentDetails(details) + ? details as StructuredExchangePresentDetails + : undefined +} + +function firstNonEmptyMarkdownLine(markdown: string): string | undefined { + return markdown + .split("\n") + .map((line) => line.replace(/^#+\s*/, "").trim()) + .find((line) => line.length > 0) +} + +function textContent(content: unknown): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content + .map((part) => + typeof part === "object" && + part !== null && + typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "", + ) + .filter((text) => text.length > 0) + .join("\n") +} + function projectPendingElicitationExchange( envelope: BrunchSessionEnvelope, ): Static<typeof PendingExchangeResultSchema> { From 7173caf59afe02e5880fdd7a49c266315da7501f Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:44:01 +0200 Subject: [PATCH 130/164] Move RPC elicitation onto tuple truth --- memory/CARDS.md | 4 +- src/rpc/handlers.test.ts | 104 ++++++++++- src/rpc/handlers.ts | 380 ++++++++++++++++++++++++++++++++------- 3 files changed, 416 insertions(+), 72 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index 2ff069fd..a588cfe9 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -133,7 +133,7 @@ Session projections recognize unmatched `present_*` tool results as pending exch ## Card 3 — Move public RPC start/respond onto structured-exchange tuple truth for one deterministic exchange -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior @@ -196,7 +196,7 @@ Session projections recognize unmatched `present_*` tool results as pending exch ## Card 4 — Add the deterministic ten-turn public-RPC parity proof -**Status:** queued +**Status:** next **Weight:** full scope card ### Target Behavior diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index e70cbf00..1cec0d84 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -619,7 +619,8 @@ describe("JSON-RPC handlers", () => { }) const sessionText = await readFile(workspace.session.file, "utf8") - expect(sessionText).toContain("brunch.elicitation_prompt") + expect(sessionText).toContain("brunch.structured_exchange.present") + expect(sessionText).toContain("present_options") expect(sessionText).toContain(exchangeId) expect(sessionText).toContain('"lens":"step-by-step"') }) @@ -703,7 +704,7 @@ describe("JSON-RPC handlers", () => { id: 49, result: { status: "pending", - exchange: { exchangeId: "deterministic-grounding-1" }, + exchange: { exchangeId: "deterministic-grounding-choice" }, }, }) }) @@ -931,7 +932,7 @@ describe("JSON-RPC handlers", () => { exchanges: [ { promptEntryIds: [expect.any(String)], - responseEntryIds: [expect.any(String), expect.any(String)], + responseEntryIds: [expect.any(String)], }, ], }, @@ -961,10 +962,105 @@ describe("JSON-RPC handlers", () => { }) const sessionText = await readFile(workspace.session.file, "utf8") - expect(sessionText).toContain("brunch.elicitation_response") + expect(sessionText).toContain("brunch.structured_exchange.request") + expect(sessionText).toContain("request_choice") expect(sessionText).toContain("This is a greenfield product.") }) + it("responds to deterministic text and multi-choice tuple exchanges", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-modes-")) + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: "Respond modes spec", + }) + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }) + + const first = await handlers.handle({ + jsonrpc: "2.0", + id: 250, + method: "session.startElicitation", + }) + const firstExchangeId = (first as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId + await handlers.handle({ + jsonrpc: "2.0", + id: 251, + method: "elicitation.respond", + params: { + exchangeId: firstExchangeId, + answer: { optionId: "new-from-scratch" }, + }, + }) + + const textStart = await handlers.handle({ + jsonrpc: "2.0", + id: 252, + method: "session.startElicitation", + }) + expect(textStart).toMatchObject({ + result: { + exchange: { mode: "text", exchangeId: "deterministic-grounding-text" }, + }, + }) + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 253, + method: "elicitation.respond", + params: { + exchangeId: "deterministic-grounding-text", + answer: { text: "A local product specification workspace." }, + }, + }), + ).resolves.toMatchObject({ + result: { + status: "accepted", + answer: { text: "A local product specification workspace." }, + }, + }) + + const multiStart = await handlers.handle({ + jsonrpc: "2.0", + id: 254, + method: "session.startElicitation", + }) + expect(multiStart).toMatchObject({ + result: { + exchange: { + mode: "multi-select", + exchangeId: "deterministic-grounding-multi", + }, + }, + }) + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 255, + method: "elicitation.respond", + params: { + exchangeId: "deterministic-grounding-multi", + answer: { optionIds: ["transcript", "other"] }, + note: "Also verify friction reporting.", + }, + }), + ).resolves.toMatchObject({ + result: { + status: "accepted", + answer: { optionIds: ["transcript", "other"] }, + }, + }) + + const sessionText = await readFile(workspace.session.file, "utf8") + expect(sessionText).toContain("request_answer") + expect(sessionText).toContain("request_choices") + expect(sessionText).not.toContain("brunch.elicitation_prompt") + expect(sessionText).not.toContain("brunch.elicitation_response") + }) + it("rejects mismatched elicitation response ids without appending transcript entries", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-respond-bad-id-")) const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }) diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index 068175ec..6f7e1005 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -362,9 +362,18 @@ const PendingExchangeResultSchema = Type.Union([ const ElicitationRespondParamsSchema = Type.Object( { exchangeId: NonBlankStringSchema, - answer: Type.Object({ optionId: NonBlankStringSchema }, { - additionalProperties: false, - }), + answer: Type.Union([ + Type.Object({ text: NonBlankStringSchema }, { + additionalProperties: false, + }), + Type.Object({ optionId: NonBlankStringSchema }, { + additionalProperties: false, + }), + Type.Object( + { optionIds: Type.Array(NonBlankStringSchema, { minItems: 1 }) }, + { additionalProperties: false }, + ), + ]), note: Type.Optional(Type.String()), }, { additionalProperties: false }, @@ -374,13 +383,7 @@ const ElicitationRespondResultSchema = Type.Object( { status: Type.Literal("accepted"), exchangeId: NonBlankStringSchema, - answer: Type.Object( - { - optionId: NonBlankStringSchema, - label: NonBlankStringSchema, - }, - { additionalProperties: false }, - ), + answer: Type.Object({}, { additionalProperties: true }), note: Type.Optional(Type.String()), }, { additionalProperties: false }, @@ -513,7 +516,7 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ { method: "elicitation.respond", description: - "Submit a listed-option answer for the selected session's current deterministic pending elicitation exchange.", + "Submit a text, single-choice, or multi-choice answer for the selected session's current deterministic tuple-shaped pending elicitation exchange.", paramsSchema: ElicitationRespondParamsSchema, resultSchema: ElicitationRespondResultSchema, examples: [ @@ -522,7 +525,7 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ id: 11, method: "elicitation.respond", params: { - exchangeId: "deterministic-grounding-1", + exchangeId: "deterministic-grounding-choice", answer: { optionId: "new-from-scratch" }, note: "This is a greenfield product.", }, @@ -611,14 +614,12 @@ async function handleStartElicitation( }) } - const exchange = firstDeterministicElicitationExchange() - const manager = state.session.manager - manager.appendCustomMessageEntry( - "brunch.elicitation_prompt", - exchange.prompt, - true, - exchange, + const exchange = nextDeterministicElicitationExchange( + projectLinearElicitationExchangeProjection(existingTarget.envelope) + .exchanges.length, ) + const manager = state.session.manager + manager.appendMessage(presentToolResultMessage(exchange)) flushSessionEntries(manager, state.session.file) const reloadedTarget = await selectedSessionFile(state) @@ -689,73 +690,289 @@ async function handleRespondToElicitation( ) } - const selectedOption = pending.options.find( - (option) => option.id === params.answer.optionId, - ) - if (!selectedOption) { - return createJsonRpcFailure(requestId, -32007, "Invalid elicitation option") + const accepted = acceptedResponseFromParams(pending, params) + if (!accepted.ok) { + return createJsonRpcFailure(requestId, -32007, accepted.message) } const result: ElicitationRespondResult = { status: "accepted", exchangeId: pending.exchangeId, - answer: { optionId: selectedOption.id, label: selectedOption.label }, + answer: accepted.answer, ...(params.note === undefined ? {} : { note: params.note }), } - state.session.manager.appendMessage({ - role: "user", - content: responseDisplayText(result), - timestamp: 0, - }) - state.session.manager.appendCustomEntry("brunch.elicitation_response", { - exchangeId: pending.exchangeId, - lens: pending.lens, - mode: pending.mode, - prompt: pending.prompt, - answer: { - type: "option", - optionId: selectedOption.id, - label: selectedOption.label, - }, - ...(params.note === undefined ? {} : { note: params.note }), - transport: { surface: "brunch-json-rpc" }, - }) + state.session.manager.appendMessage(accepted.toolResultMessage) flushSessionEntries(state.session.manager, state.session.file) return createJsonRpcSuccess(requestId, result) } -function responseDisplayText(response: ElicitationRespondResult): string { - const lines = [`Selected: ${response.answer.label}`] - if (response.note !== undefined && response.note.length > 0) { - lines.push(`Note: ${response.note}`) +interface AcceptedToolTextContent { + type: "text" + text: string +} + +interface AcceptedToolResultMessage { + role: "toolResult" + toolCallId: string + toolName: string + content: AcceptedToolTextContent[] + details: Record<string, unknown> + isError: false + timestamp: 0 +} + +type AcceptedResponse = { + ok: true + answer: Record<string, unknown> + toolResultMessage: AcceptedToolResultMessage +} | { + ok: false + message: string +} + +function acceptedResponseFromParams( + pending: PendingElicitationExchange, + params: ElicitationRespondParams, +): AcceptedResponse { + if ("text" in params.answer) { + if (pending.mode !== "text") return invalidResponseMode() + const details = requestDetailsBase(pending, "request_answer") + return { + ok: true, + answer: { text: params.answer.text }, + toolResultMessage: { + ...toolResultMessageBase(pending, "request_answer"), + content: [ + { type: "text", text: `### Response\n\n${params.answer.text}` }, + ], + details: { ...details, answer: params.answer.text }, + }, + } + } + + if ("optionId" in params.answer) { + if (pending.mode !== "single-select") return invalidResponseMode() + const optionId = params.answer.optionId + const choice = pending.options.find((option) => option.id === optionId) + if (!choice) return { ok: false, message: "Invalid elicitation option" } + const details = requestDetailsBase(pending, "request_choice") + if (params.note !== undefined && params.note.trim().length > 0) { + details.comment = params.note.trim() + } + return { + ok: true, + answer: { optionId: choice.id, label: choice.label }, + toolResultMessage: { + ...toolResultMessageBase(pending, "request_choice"), + content: [ + { type: "text", text: choiceResponseMarkdown([choice], params.note) }, + ], + details: { ...details, choice }, + }, + } + } + + if (pending.mode !== "multi-select") return invalidResponseMode() + const selected = params.answer.optionIds.map((id) => + pending.options.find((option) => option.id === id), + ) + if (selected.some((choice) => choice === undefined)) { + return { ok: false, message: "Invalid elicitation option" } + } + const choices = selected as PendingChoice[] + if ( + choices.some((choice) => choice.id === "other" || choice.id === "none") && + (params.note === undefined || params.note.trim().length === 0) + ) { + return { + ok: false, + message: + "Elicitation response requires a comment for Other or None selections", + } + } + const details = requestDetailsBase(pending, "request_choices") + if (params.note !== undefined && params.note.trim().length > 0) { + details.comment = params.note.trim() + } + return { + ok: true, + answer: { optionIds: choices.map((choice) => choice.id), choices }, + toolResultMessage: { + ...toolResultMessageBase(pending, "request_choices"), + content: [ + { type: "text", text: choiceResponseMarkdown(choices, params.note) }, + ], + details: { ...details, choices }, + }, + } +} + +function invalidResponseMode(): AcceptedResponse { + return { + ok: false, + message: "Elicitation response mode does not match pending exchange", + } +} + +function requestDetailsBase( + pending: PendingElicitationExchange, + requestTool: "request_answer" | "request_choice" | "request_choices", +): Record<string, unknown> { + return { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: pending.exchangeId, + requestTool, + status: "answered", + respondsTo: { + exchangeId: pending.exchangeId, + presentTool: + pending.mode === "text" ? "present_question" : "present_options", + }, + createdAtToolCallId: `${pending.exchangeId}:${requestTool}`, + } +} + +function toolResultMessageBase( + pending: PendingElicitationExchange, + requestTool: "request_answer" | "request_choice" | "request_choices", +) { + return { + role: "toolResult" as const, + toolCallId: `${pending.exchangeId}:${requestTool}`, + toolName: requestTool, + isError: false as const, + timestamp: 0 as const, + } +} + +function choiceResponseMarkdown( + choices: Array<{ label: string }>, + comment: string | undefined, +): string { + const lines = [ + "### Response", + "", + ...choices.map((choice) => `- ${choice.label}`), + ] + if (comment !== undefined && comment.trim().length > 0) { + lines.push("", "Comment:", "", `> ${comment.trim()}`) } return lines.join("\n") } +interface PendingChoice { + id: string + label: string +} + type PendingElicitationExchange = Static<typeof PendingElicitationExchangeSchema> -function firstDeterministicElicitationExchange(): PendingElicitationExchange { +function nextDeterministicElicitationExchange( + completedCount: number, +): PendingElicitationExchange { + const script: PendingElicitationExchange[] = [ + { + exchangeId: "deterministic-grounding-choice", + lens: "step-by-step", + mode: "single-select", + prompt: "Is this a new product or feature from scratch?", + details: + "Choose the best starting context so later elicitation can ask useful follow-ups.", + options: [ + { id: "new-from-scratch", label: "Yes — this is new from scratch" }, + { id: "existing-codebase", label: "No — this builds on existing code" }, + { + id: "relates-to-existing-spec", + label: "It relates to an existing spec", + }, + ], + note: { allowed: true }, + }, + { + exchangeId: "deterministic-grounding-text", + lens: "step-by-step", + mode: "text", + prompt: "What are we specifying?", + details: + "This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.", + options: [], + note: { allowed: true }, + }, + { + exchangeId: "deterministic-grounding-multi", + lens: "step-by-step", + mode: "multi-select", + prompt: "Which proof qualities matter for this parity run?", + details: + "Select all qualities the deterministic agent-as-user proof should preserve.", + options: [ + { id: "transcript", label: "Transcript fidelity" }, + { id: "projection", label: "Projection fidelity" }, + { id: "other", label: "Other" }, + { id: "none", label: "None" }, + ], + note: { allowed: true }, + }, + ] + return script[completedCount % script.length]! +} + +function presentToolResultMessage(exchange: PendingElicitationExchange) { + const presentTool = + exchange.mode === "text" ? "present_question" : "present_options" + const requestTool = + exchange.mode === "text" + ? "request_answer" + : exchange.mode === "multi-select" + ? "request_choices" + : "request_choice" + const toolCallId = `${exchange.exchangeId}:${presentTool}` return { - exchangeId: "deterministic-grounding-1", - lens: "step-by-step", - mode: "single-select", - prompt: "Is this a new product or feature from scratch?", - details: - "This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.", - options: [ - { id: "new-from-scratch", label: "Yes — this is new from scratch" }, - { id: "existing-codebase", label: "No — this builds on existing code" }, - { - id: "relates-to-existing-spec", - label: "It relates to an existing spec", - }, - ], - note: { allowed: true }, + role: "toolResult" as const, + toolCallId, + toolName: presentTool, + content: [{ type: "text" as const, text: presentMarkdown(exchange) }], + details: { + schema: "brunch.structured_exchange.present", + schemaVersion: 1, + exchangeId: exchange.exchangeId, + presentTool, + kind: exchange.mode === "text" ? "question" : "options", + status: "presented", + expectedRequest: { tool: requestTool, required: true }, + createdAtToolCallId: toolCallId, + prompt: exchange.prompt, + details: exchange.details, + lens: exchange.lens, + options: exchange.options, + }, + isError: false as const, + timestamp: 0 as const, } } +function presentMarkdown(exchange: PendingElicitationExchange): string { + if (exchange.mode === "text") { + return [`## ${exchange.prompt}`, exchange.details] + .filter(Boolean) + .join("\n\n") + } + const lines = [`## ${exchange.prompt}`] + if (exchange.details) lines.push("", exchange.details) + exchange.options.forEach((option, index) => { + lines.push( + "", + `### ${index + 1}. ${option.label}`, + "", + `<!-- option-id: ${option.id} -->`, + ) + }) + return lines.join("\n") +} + function pendingExchangeFromEnvelope( envelope: BrunchSessionEnvelope, ): PendingElicitationExchange | null { @@ -796,6 +1013,17 @@ function pendingExchangeFromStructuredPresent( details: StructuredExchangePresentDetails, markdown: string, ): PendingElicitationExchange { + const richDetails = details as StructuredExchangePresentDetails & { + prompt?: unknown + details?: unknown + options?: unknown + } + const prompt = + typeof richDetails.prompt === "string" + ? richDetails.prompt + : (firstNonEmptyMarkdownLine(markdown) ?? markdown) + const detailsText = + typeof richDetails.details === "string" ? richDetails.details : markdown return { exchangeId: details.exchangeId, lens: "step-by-step", @@ -805,13 +1033,33 @@ function pendingExchangeFromStructuredPresent( : details.presentTool === "present_question" ? "text" : "single-select", - prompt: firstNonEmptyMarkdownLine(markdown) ?? markdown, - ...(markdown.length > 0 ? { details: markdown } : {}), - options: [], + prompt, + ...(detailsText.length > 0 ? { details: detailsText } : {}), + options: parsePendingOptions(richDetails.options), note: { allowed: true }, } } +function parsePendingOptions(value: unknown): PendingChoice[] { + if (!Array.isArray(value)) return [] + return value.flatMap((option) => { + if ( + typeof option === "object" && + option !== null && + typeof (option as { id?: unknown }).id === "string" && + typeof (option as { label?: unknown }).label === "string" + ) { + return [ + { + id: (option as { id: string }).id, + label: (option as { label: string }).label, + }, + ] + } + return [] + }) +} + function structuredExchangePresentDetails( entry: unknown, ): StructuredExchangePresentDetails | undefined { From 02ff9b96c4a51aa3f5910fa42305186b8f0e5382 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:47:16 +0200 Subject: [PATCH 131/164] Add public RPC parity proof --- memory/CARDS.md | 259 ------------------ memory/PLAN.md | 4 +- src/probes/public-rpc-parity-proof.test.ts | 31 +++ src/probes/public-rpc-parity-proof.ts | 294 +++++++++++++++++++++ 4 files changed, 327 insertions(+), 261 deletions(-) delete mode 100644 memory/CARDS.md create mode 100644 src/probes/public-rpc-parity-proof.test.ts create mode 100644 src/probes/public-rpc-parity-proof.ts diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index a588cfe9..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,259 +0,0 @@ -<!-- CARDS.md — temporary scope-card queue for one active frontier item. - Created by ln-scope. Delete or overwrite when exhausted/superseded. - Canonical planning state remains memory/SPEC.md and memory/PLAN.md. --> - -# Scope Card Queue — FE-744 public RPC structured-exchange parity - -## Orientation - -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch JSON-RPC structured-elicitation relay over Pi transcript truth. -- **Frontier boundary:** one existing Linear/branch unit: FE-744 / `ln/fe-744-pi-ui-extension-patterns`. These cards are commit-sized slices inside that frontier, not new Linear issues or branches. -- **Volatile state:** `HANDOFF.md` is transfer state only. The handoff flagged `memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md` as protected cleanup; current git status does not show a tracked deletion, but do not recreate/delete/overwrite that path without confirmation. -- **Main risk:** accidentally proving the old lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop instead of the durable structured-exchange tuple model, or exposing raw Pi RPC/editor fallback as the product API. - -## Queue Discipline - -- Consume cards in order unless implementation reveals a blocker that invalidates later scopes. -- Each card should be verified and committed independently. -- Stop and rescope if a card requires changing `memory/SPEC.md` requirements/decisions/invariants rather than merely implementing the existing D37-L/D38-L/D49-L shape. -- Inner loop after meaningful edits: `npm run fix`. Gate before each commit: `npm run verify`. - -## Card 1 — Implement `request_choices` as a durable structured-exchange request tool - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`request_choices` is a registered structured-exchange request tool that collects one-or-more option choices through the RPC-compatible editor fallback and persists terminal `brunch.structured_exchange.request` details. - -### Boundary Crossings - -```text -→ structured-exchange tool registry -→ request_choices tool parameter/result schema -→ Pi UI adapter (`ctx.ui.editor` JSON fallback for RPC-compatible multi-select) -→ durable toolResult.content/details -``` - -### Risks and Assumptions - -- RISK: The existing editor-fallback helper emits the legacy `brunch.structured_exchange.result` details shape rather than the current present/request request schema. - → MITIGATION: Add or refactor a request-schema-specific editor prefill/parser/result helper; keep the legacy helper only for old probe support if still needed. -- RISK: “Other” and “None” comment rules get flattened into an optional note and stop being enforceable. - → MITIGATION: Model `allowOther` / `allowNone` explicitly; parser rejects answered responses containing `other` or `none` without a nonblank comment. -- ASSUMPTION: Multi-select over public-RPC-compatible UI can be represented as schema-tagged JSON over `ctx.ui.editor` until a richer product form lands. - → IMPACT IF FALSE: Card 4 cannot exercise the required custom-UI-over-RPC fallback without raw Pi RPC or a bespoke product form. - → VALIDATE: request tool execute tests with fake editor contexts for answered/cancelled/invalid JSON and a probe-compatible payload. - → memory/SPEC.md: D38-L, I23-L, A23-L. - -### Tracer-bullet Check - -- **Proof of life:** lights up the currently stubbed multi-choice response tool needed by the parity proof. -- **Invariants:** reinforces that semantic response truth lives in request `toolResult.details`, not editor lifecycle state. -- **Uncertainty:** retires the local risk that multi-choice needs a new raw Pi RPC command shape. - -### Acceptance Criteria - -✓ `structured-exchange request_choices registry test` — `request_choices` moves from `STRUCTURED_EXCHANGE_STUB_TOOL_NAMES` into `STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS` and is registered by the extension entrypoint. -✓ `request_choices editor fallback tests` — answered multi-choice results persist `schema: "brunch.structured_exchange.request"`, `requestTool: "request_choices"`, `status: "answered"`, `choices`, optional `comment`, `respondsTo.presentTool: "present_options"`, and `transport` only if the request model deliberately carries one. -✓ `request_choices comment validation tests` — `other` or `none` answers without a nonblank comment are rejected or returned as `unavailable` with an explicit validation message; listed-option-only responses may omit comment. -✓ `request_choices markdown test` — `toolResult.content` is readable markdown summarizing selected choices and any comment. - -### Verification Approach - -- Inner: focused Vitest unit tests for schemas, parser, execute behavior, and markdown rendering. -- Middle: existing structured-exchange extension registry tests prove the active/stub split is updated intentionally. -- Outer: none for this card; Card 4 supplies the public-RPC parity proof using `request_choices`. - -### Cross-cutting Obligations - -- Preserve D37-L: `renderCall` remains transient; semantic display/response truth is in `renderResult` / `toolResult.content` and `toolResult.details`. -- Preserve D38-L: JSON-over-editor is an adapter behind Brunch/Pi, not the public product API. -- Do not implement review-set or candidate stubs as collateral work. - ---- - -## Card 2 — Project present/request structured-exchange tuples as pending and completed elicitation exchanges - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -Session projections recognize unmatched `present_*` tool results as pending exchanges and matching terminal `request_*` tool results as response-side exchange closures. - -### Boundary Crossings - -```text -→ Pi JSONL session envelope -→ structured-exchange present/request details classifiers -→ elicitation-exchange projection -→ session.pendingExchange / session.transcriptDisplay RPC projections -``` - -### Risks and Assumptions - -- RISK: Current projection code classifies only the legacy `brunch.structured_exchange.result` terminal details, so new request details could remain prompt-side or invisible. - → MITIGATION: Add classifiers for `brunch.structured_exchange.present` and `brunch.structured_exchange.request`; keep legacy support only while old probes require it. -- RISK: Transcript display omits toolResult content even though D37-L treats it as durable user-facing transcript content. - → MITIGATION: Render present tool results as assistant/prompt display rows and terminal request results as user/response display rows in Brunch projections. -- ASSUMPTION: Tuple recovery from `exchangeId` + expected request is sufficient without a parallel pending table. - → IMPACT IF FALSE: Cards 3–4 would need an alternate in-memory or store-backed pending-exchange model, changing the FE-744 proof shape. - → VALIDATE: synthetic JSONL projection tests for open, closed, mismatched, and multiple sequential tuples. - → memory/SPEC.md: D13-L, D37-L, I23-L, I32-L, A23-L. - -### Tracer-bullet Check - -- **Proof of life:** makes tuple-shaped transcript truth visible through the public read projections. -- **Invariants:** stabilizes the no-parallel-chat/turn-store rule for pending state. -- **Uncertainty:** tests whether unmatched-present recovery is enough for public RPC pending state. - -### Acceptance Criteria - -✓ `elicitation projection open tuple test` — a linear transcript containing `present_question` without a terminal `request_answer` projects `status: "open_prompt"` and `session.pendingExchange` returns a product-shaped pending exchange. -✓ `elicitation projection closed tuple test` — a matching terminal `request_answer`, `request_choice`, or `request_choices` result closes the exchange and appears in `responseEntryIds`. -✓ `projection mismatch test` — a terminal request with a different `exchangeId` or incompatible `respondsTo.presentTool` does not close the open prompt silently. -✓ `transcript display tuple test` — present markdown is visible as assistant/prompt text and terminal request markdown is visible as user/response text. -✓ `legacy prompt/response guard` — existing lightweight custom prompt/response projection tests are either intentionally preserved as backward probe support or retired when no longer used by public RPC. - -### Verification Approach - -- Inner: projection/unit tests over synthetic session entries and TypeBox/runtime classifiers. -- Middle: RPC handler tests for `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` reading tuple-shaped sessions by selected and explicit session ids. -- Outer: none for this card; Card 4 supplies end-to-end parity. - -### Cross-cutting Obligations - -- Preserve linear transcript rejection for branched Pi JSONL. -- Do not introduce a canonical chat/turn table or sidecar pending-exchange store. -- Public projection shape should describe Brunch product semantics, not raw Pi RPC events. - ---- - -## Card 3 — Move public RPC start/respond onto structured-exchange tuple truth for one deterministic exchange - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -`session.startElicitation` and `elicitation.respond` operate on one deterministic structured-exchange tuple instead of appending the old lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` pair. - -### Boundary Crossings - -```text -→ Brunch JSON-RPC handler (`session.startElicitation`) -→ selected workspace/spec/session coordinator state -→ deterministic structured-exchange present builder -→ Pi JSONL toolResult-shaped transcript entries -→ Brunch JSON-RPC handler (`elicitation.respond`) -→ deterministic structured-exchange request builder -→ projection-backed pending/closed exchange reads -``` - -### Risks and Assumptions - -- RISK: Public RPC cannot honestly “use structured-exchange tools” without running a raw Pi RPC agent loop. - → MITIGATION: Route through shared structured-exchange builder/helper code that produces the same `toolResult.content/details` contract; if a real Pi invocation is cheap and stable, it may be hidden behind the Brunch adapter, but the public client still speaks only Brunch RPC. -- RISK: Handler code imports TUI-only picker/custom UI modules while adopting structured-exchange helpers. - → MITIGATION: Keep the architectural source test for no `workspace-dialog` imports; replace the broad “no structured-exchange” assertion with a narrower “no TUI picker/raw Pi RPC public dependency” assertion. -- ASSUMPTION: A product-RPC response can append the terminal request result details directly and still be comparable to TUI/Pi tool execution transcript semantics. - → IMPACT IF FALSE: The parity proof must delegate to an internal Pi RPC adapter rather than in-process tuple append helpers. - → VALIDATE: one-exchange contract tests compare JSONL/projections against expected present/request details and display rows. - → memory/SPEC.md: D37-L, D38-L, D49-L, I23-L, I32-L, A23-L. - -### Tracer-bullet Check - -- **Proof of life:** first product-RPC exchange uses the real tuple shape. -- **Invariants:** establishes public pending/respond semantics over transcript truth rather than custom prompt/response side entries. -- **Uncertainty:** proves whether an in-process Brunch adapter can produce parity-quality transcript artifacts without exposing raw Pi RPC. - -### Acceptance Criteria - -✓ `rpc discover schema update` — `rpc.discover` describes tuple-shaped pending exchange/result schemas for text, single-choice, and multi-choice responses, with examples that do not mention raw Pi RPC. -✓ `start one tuple test` — starting elicitation in an activated session appends exactly one deterministic `present_*` toolResult-shaped transcript entry and returns the projection-backed pending exchange. -✓ `resume open tuple test` — calling `session.startElicitation` while that tuple is open returns the same pending exchange without duplicating transcript entries. -✓ `respond text test` — `elicitation.respond` can close a `present_question → request_answer` pending exchange with a freeform answer. -✓ `respond single-choice test` — `elicitation.respond` can close a `present_options → request_choice` pending exchange with one listed choice and optional comment. -✓ `respond multi-choice test` — `elicitation.respond` can close a `present_options → request_choices` pending exchange with one-or-more choices and required comment for `other`/`none`. -✓ `respond guard tests` — mismatched exchange id, invalid choice id, missing required comment, and duplicate response do not append transcript entries. -✓ `old lightweight loop retired` — public start/respond no longer appends `brunch.elicitation_prompt` / `brunch.elicitation_response` for the deterministic proof path; stale tests are updated or removed. - -### Verification Approach - -- Inner: RPC handler contract tests and transcript JSONL assertions. -- Middle: projection round-trip tests after each start/respond path prove pending closes through `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay`. -- Outer: none for this card; Card 4 expands to ten turns from a fresh cwd. - -### Cross-cutting Obligations - -- Preserve D49-L: public clients use Brunch JSON-RPC methods only. -- Preserve D36-L/I22-L: RPC/headless activation uses structured selection state and activation decisions, not TUI picker code. -- Preserve D37-L: do not encode semantic display in `renderCall` or raw extension UI event order. - ---- - -## Card 4 — Add the deterministic ten-turn public-RPC parity proof - -**Status:** next -**Weight:** full scope card - -### Target Behavior - -A scripted public Brunch JSON-RPC agent-as-user creates a spec/session from a fresh cwd and completes establishment plus ten structured-exchange elicitation turns with parity assertions over JSONL and projections. - -### Boundary Crossings - -```text -→ probe/test client over Brunch JSON-RPC handlers or stdio host -→ rpc.discover -→ workspace.selectionState -→ workspace.activate(newSpec) -→ session.startElicitation / session.pendingExchange / elicitation.respond loop -→ Pi JSONL transcript in .brunch/sessions -→ session.transcriptDisplay + session.elicitationExchanges projections -→ parity oracle report -``` - -### Risks and Assumptions - -- RISK: The proof counts handler unit tests as parity without exercising fresh project entry and spec/session creation. - → MITIGATION: The probe starts from an empty temp cwd and must create a new spec/session through public activation methods before the first elicitation exchange. -- RISK: The ten-turn script overfits one response mode and fails to prove editor-fallback multi-choice semantics. - → MITIGATION: Fixed script includes at minimum: establishment/framing exchange(s), `present_question → request_answer`, multiple `present_options → request_choice`, and at least one `present_options → request_choices` case that uses the comment-required `other` or `none` path. -- RISK: The proof asserts only method success and misses transcript/projection quality. - → MITIGATION: Parity oracle checks tool names, exchange ids, present-before-request order, response modes, options/rationales, answers, comments, display rows, exchange spans, and absence of old lightweight public-RPC prompt/response entries. -- ASSUMPTION: Public Brunch RPC can drive at least ten assistant-first structured exchanges without raw Pi RPC, graph persistence, or a parallel prompt/turn store. - → IMPACT IF FALSE: FE-744 cannot close A23-L and PLAN sequencing should not move to sealed profile/runtime-state yet. - → VALIDATE: deterministic public-RPC parity test/probe with blocker/friction report. - → memory/SPEC.md: A5-L, A23-L, D5-L, D37-L, D48-L, D49-L, I23-L, I32-L. - -### Tracer-bullet Check - -- **Proof of life:** lights up the full public product path from empty cwd to ten answered assistant-first exchanges. -- **Invariants:** proves selected-session activation, linear transcript truth, pending/respond lifecycle, and tuple projection stay coherent together. -- **Uncertainty:** directly attacks A23-L, the active FE-744 risk blocking profile/runtime-state work. - -### Acceptance Criteria - -✓ `public rpc parity probe` — a deterministic script/test starts from a temp cwd, calls `rpc.discover`, observes selection required, activates `{ action: "newSpec" }`, and obtains a ready spec/session without invoking TUI picker code. -✓ `ten-turn loop oracle` — the probe completes at least ten structured exchanges through `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` only. -✓ `tool coverage oracle` — the resulting transcript includes `present_question`, `request_answer`, `present_options`, `request_choice`, and `request_choices` tuple entries. -✓ `establishment/framing oracle` — initial system/assistant-generated questions establish enough specification/session kind/framing metadata in transcript text/details to explain why later turns are being asked, without requiring graph persistence. -✓ `projection parity oracle` — `session.elicitationExchanges` reports ten completed exchanges with prompt and response spans; `session.transcriptDisplay` preserves prompt/question/option/rationale/answer/comment artifacts at TUI-comparable quality. -✓ `JSONL parity oracle` — every exchange has a recoverable `exchangeId`; each present precedes its matching request; terminal request details contain the correct mode-specific answer payload; no public-proof exchange is represented by `brunch.elicitation_prompt` / `brunch.elicitation_response`. -✓ `blocker/friction report` — the probe returns a compact scenario report with mission, evaluation focus, max-turn budget, completed turns, and any friction encountered. - -### Verification Approach - -- Inner: deterministic handler/probe tests for the ten-turn loop and parity oracle. -- Middle: executable probe under `src/probes/` or equivalent Vitest integration test using public Brunch RPC only; JSONL/projection postcondition checker. -- Outer: not required for this card; web real-time observation smoke is the next FE-744 slice after this queue. - -### Cross-cutting Obligations - -- No graph/data-layer capture is required or expected; transcript/projection is the proof surface for now. -- Do not expose raw Pi RPC/editor fallback as public product API. -- Do not introduce a parallel chat/turn store or durable pending-exchange table. -- Keep the proof deterministic enough to run in `npm run verify` without network/model access. diff --git a/memory/PLAN.md b/memory/PLAN.md index 18e8ea56..d0bb3b85 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -217,7 +217,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, and the discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts` have landed. That proof is supporting evidence only; current missing product seams are public Brunch RPC discovery, assistant-first pending/respond elicitation driving, ten-turn JSONL/projection parity, web real-time structured-exchange observation, and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, and public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** The public RPC discovery registry, deterministic `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` tracer bullets have landed: `rpc.discover` lists the current Brunch methods, an activated selected session can start/resume a transcript-backed `brunch.elicitation_prompt` pending exchange, clients can poll pending state from Pi JSONL, and a listed-option response appends `brunch.elicitation_response` evidence without raw Pi RPC or a parallel prompt store. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, and `request_choice` are registered; review/candidate/multi-choice tools are named stubs but intentionally not registered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope the parity sequence inside this same FE-744 frontier: (1) let the deterministic elicitor advance through at least ten structured exchanges; (2) build the ten-turn agent-as-user parity proof and projection oracle; (3) then run web real-time observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. ### flue-pattern-adoption diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts new file mode 100644 index 00000000..fb0f537c --- /dev/null +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" + +import { runPublicRpcParityProof } from "./public-rpc-parity-proof.js" + +describe("public Brunch RPC structured-exchange parity proof", () => { + it("drives ten assistant-first structured exchanges from a fresh cwd", async () => { + const report = await runPublicRpcParityProof() + + expect(report).toMatchObject({ + mission: expect.stringContaining("public JSON-RPC only"), + evaluationFocus: expect.stringContaining( + "tuple transcript/projection parity", + ), + maxTurnBudget: 10, + completedTurns: 10, + friction: [], + specId: expect.any(String), + sessionId: expect.any(String), + }) + expect(report.toolCoverage).toEqual([ + "present_options", + "present_question", + "request_answer", + "request_choice", + "request_choices", + ]) + expect(report.exchangeIds).toHaveLength(10) + expect(new Set(report.exchangeIds).size).toBeGreaterThanOrEqual(3) + expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(20) + }) +}) diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts new file mode 100644 index 00000000..01e60548 --- /dev/null +++ b/src/probes/public-rpc-parity-proof.ts @@ -0,0 +1,294 @@ +import { mkdtemp, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { createRpcHandlers } from "../rpc/handlers.js" +import { createWorkspaceSessionCoordinator } from "../workspace-session-coordinator.js" + +interface JsonRpcSuccess<T> { + jsonrpc: "2.0" + id: number + result: T +} + +interface PendingOption { + id: string + label: string +} + +interface PendingExchange { + exchangeId: string + mode: "text" | "single-select" | "multi-select" + prompt: string + options: PendingOption[] +} + +interface RpcExchange { + promptEntryIds: string[] + responseEntryIds: string[] +} + +interface RpcExchangeProjection { + status: string + exchanges: RpcExchange[] +} + +interface TranscriptDisplayRow { + role: string + text: string +} + +interface TranscriptDisplayProjection { + rows: TranscriptDisplayRow[] +} + +interface WorkspaceSelectionResult { + requiresSelection: boolean +} + +interface PendingResult { + status: "pending" + exchange: PendingExchange +} + +export interface PublicRpcParityProofReport { + mission: string + evaluationFocus: string + maxTurnBudget: number + completedTurns: number + friction: string[] + cwd: string + specId: string + sessionId: string + toolCoverage: string[] + exchangeIds: string[] + transcriptDisplayRows: number +} + +function success<T>(response: unknown): T { + if ( + typeof response === "object" && + response !== null && + "result" in response + ) { + return (response as JsonRpcSuccess<T>).result + } + throw new Error( + `Expected JSON-RPC success response: ${JSON.stringify(response)}`, + ) +} + +interface ToolResultDetails { + exchangeId?: string + schema?: string + requestTool?: string + presentTool?: string +} + +interface ToolResultEntry { + toolName: string + details?: ToolResultDetails +} + +interface JsonlMessageEntry { + message?: { + role?: string + toolName?: string + details?: unknown + } +} + +function toolResultEntries(sessionText: string): ToolResultEntry[] { + return sessionText + .trim() + .split("\n") + .map((line) => JSON.parse(line) as JsonlMessageEntry) + .filter((entry) => entry.message?.role === "toolResult") + .map((entry) => ({ + toolName: entry.message?.toolName ?? "", + details: entry.message?.details as never, + })) +} + +interface ProofResponse { + answer: unknown + note?: string +} + +function responseFor(exchange: PendingExchange): ProofResponse { + if (exchange.mode === "text") { + return { answer: { text: `Answer for ${exchange.exchangeId}` } } + } + if (exchange.mode === "multi-select") { + return { + answer: { optionIds: ["transcript", "other"] }, + note: "Other: keep a compact blocker/friction report.", + } + } + return { + answer: { optionId: exchange.options[0]?.id ?? "new-from-scratch" }, + note: "Chosen by deterministic public-RPC proof.", + } +} + +export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofReport> { + const cwd = await mkdtemp(join(tmpdir(), "brunch-public-rpc-parity-")) + const coordinator = createWorkspaceSessionCoordinator({ cwd }) + const handlers = createRpcHandlers({ coordinator, cwd }) + const friction: string[] = [] + + const discovery = success<{ methods: Array<{ method: string }> }>( + await handlers.handle({ jsonrpc: "2.0", id: 1, method: "rpc.discover" }), + ) + for (const method of [ + "workspace.selectionState", + "workspace.activate", + "session.startElicitation", + "session.pendingExchange", + "elicitation.respond", + "session.elicitationExchanges", + "session.transcriptDisplay", + ]) { + if (!discovery.methods.some((entry) => entry.method === method)) { + throw new Error(`rpc.discover did not include ${method}`) + } + } + + const selection = success<WorkspaceSelectionResult>( + await handlers.handle({ + jsonrpc: "2.0", + id: 2, + method: "workspace.selectionState", + }), + ) + if (!selection.requiresSelection) { + friction.push("Fresh cwd did not report selection-required state.") + } + + await handlers.handle({ + jsonrpc: "2.0", + id: 3, + method: "workspace.activate", + params: { + decision: { action: "newSpec", title: "Public RPC parity spec" }, + }, + }) + const workspace = await coordinator.openDefaultWorkspace() + if (workspace.status !== "ready") { + throw new Error( + "workspace.activate(newSpec) did not create a ready workspace", + ) + } + + const exchangeIds: string[] = [] + for (let turn = 0; turn < 10; turn += 1) { + const started = success<PendingResult>( + await handlers.handle({ + jsonrpc: "2.0", + id: 10 + turn * 3, + method: "session.startElicitation", + }), + ) + const pending = success<PendingResult>( + await handlers.handle({ + jsonrpc: "2.0", + id: 11 + turn * 3, + method: "session.pendingExchange", + }), + ) + if (pending.exchange.exchangeId !== started.exchange.exchangeId) { + friction.push( + `Turn ${turn + 1}: pendingExchange differed from startElicitation.`, + ) + } + exchangeIds.push(started.exchange.exchangeId) + const response = responseFor(started.exchange) + await handlers.handle({ + jsonrpc: "2.0", + id: 12 + turn * 3, + method: "elicitation.respond", + params: { + exchangeId: started.exchange.exchangeId, + answer: response.answer, + ...(response.note === undefined ? {} : { note: response.note }), + }, + }) + } + + const exchanges = success<RpcExchangeProjection>( + await handlers.handle({ + jsonrpc: "2.0", + id: 50, + method: "session.elicitationExchanges", + }), + ) + const display = success<TranscriptDisplayProjection>( + await handlers.handle({ + jsonrpc: "2.0", + id: 51, + method: "session.transcriptDisplay", + }), + ) + if (exchanges.exchanges.length !== 10) { + throw new Error( + `Expected 10 completed exchanges, got ${exchanges.exchanges.length}`, + ) + } + + const sessionText = await readFile(workspace.session.file, "utf8") + if ( + sessionText.includes("brunch.elicitation_prompt") || + sessionText.includes("brunch.elicitation_response") + ) { + throw new Error( + "Public RPC parity transcript used the retired lightweight elicitation entries", + ) + } + const tools = toolResultEntries(sessionText) + const toolCoverage = [...new Set(tools.map((entry) => entry.toolName))].sort() + for (const required of [ + "present_question", + "request_answer", + "present_options", + "request_choice", + "request_choices", + ]) { + if (!toolCoverage.includes(required)) { + throw new Error(`Missing tool coverage for ${required}`) + } + } + + for (const exchangeId of new Set(exchangeIds)) { + const presentIndex = tools.findIndex( + (entry) => + entry.details?.exchangeId === exchangeId && + entry.details.schema === "brunch.structured_exchange.present", + ) + const requestIndex = tools.findIndex( + (entry) => + entry.details?.exchangeId === exchangeId && + entry.details.schema === "brunch.structured_exchange.request", + ) + if (presentIndex < 0 || requestIndex < 0 || presentIndex > requestIndex) { + throw new Error( + `Exchange ${exchangeId} did not preserve present-before-request order`, + ) + } + } + + return { + mission: + "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", + evaluationFocus: + "Ten-turn tuple transcript/projection parity without raw Pi RPC or legacy prompt/response entries.", + maxTurnBudget: 10, + completedTurns: exchanges.exchanges.length, + friction, + cwd, + specId: workspace.spec.id, + sessionId: workspace.session.id, + toolCoverage, + exchangeIds, + transcriptDisplayRows: display.rows.length, + } +} From a35378db873960def7fda935330ccfc072beedd5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:57:50 +0200 Subject: [PATCH 132/164] Harden public RPC parity exchange identity --- memory/CARDS.md | 124 +++++++++++++++++++++ src/probes/public-rpc-parity-proof.test.ts | 2 +- src/probes/public-rpc-parity-proof.ts | 6 +- src/rpc/handlers.test.ts | 16 ++- src/rpc/handlers.ts | 7 +- 5 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..e1b3f387 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,124 @@ +<!-- CARDS.md — temporary scope-card queue for one active frontier item. + Created by ln-scope. Delete or overwrite when exhausted/superseded. + Canonical planning state remains memory/SPEC.md and memory/PLAN.md. --> + +# Scope Card Queue — FE-744 RPC parity hardening + +## Orientation + +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch JSON-RPC structured-exchange parity proof and tuple-shaped Pi JSONL projections. +- **Frontier boundary:** one existing Linear/branch unit: FE-744 / `ln/fe-744-pi-ui-extension-patterns`. These are hardening slices inside that frontier, not new Linear issues or branches. +- **Current state:** four RPC parity slices landed and `npm run verify` passed; `memory/PLAN.md` says ten-turn public RPC tuple parity is landed, and the next feature slice is web real-time structured-exchange observation smoke. +- **Main open risk:** the parity proof currently passes while under-witnessing tuple identity, terminal request status handling, and option content/rationale fidelity. Harden those before web observation builds on the projection contract. + +## Queue Discipline + +- Consume cards in order unless implementation reveals a blocker that invalidates later cards. +- Each card should be verified and committed independently. +- These cards should not alter FE-744’s requirements; they tighten implementation and oracles against existing SPEC decisions/invariants (D13-L, D37-L, D38-L, D49-L, I23-L, I32-L). +- Inner loop after meaningful edits: `npm run fix`. Gate before each commit: `npm run verify`. + +--- + +## Card 1 — Make the ten-turn parity proof assert ten distinct tuple instances + +**Status:** done +**Weight:** light scope card + +### Objective + +The public RPC parity proof completes ten exchanges with ten distinct exchange IDs and validates each present/request pair independently. + +### Acceptance Criteria + +✓ The deterministic elicitation script no longer reuses the same exchange IDs across the ten-turn parity run. +✓ `src/probes/public-rpc-parity-proof.test.ts` asserts all 10 exchange IDs are distinct, not merely at least three. +✓ The JSONL parity oracle checks present-before-request ordering for every completed exchange instance, not `new Set(exchangeIds)` first occurrences. +✓ The proof still covers `present_question`, `request_answer`, `present_options`, `request_choice`, and `request_choices`. +✓ Existing resume behavior still returns an already-open pending exchange without appending a duplicate present result. + +### Verification Approach + +- Inner: focused RPC handler/probe tests for deterministic sequencing and resume-no-duplicate behavior. +- Middle: `src/probes/public-rpc-parity-proof.test.ts` validates ten distinct completed tuple instances and per-instance ordering. +- Gate: `npm run verify`. + +### Cross-cutting Obligations + +- Preserve I32-L: public RPC clients drive the loop through Brunch JSON-RPC only. +- Preserve I23-L: each structured exchange remains a recoverable present/request tuple with one matching terminal request. +- Do not reintroduce `brunch.elicitation_prompt` / `brunch.elicitation_response` into the public proof path. + +### Assumption Dependency + +Depends on: A23-L — this card strengthens the landed validation of public RPC elicitation parity; it does not introduce a new assumption. + +--- + +## Card 2 — Treat matching cancelled and unavailable request tuples as terminal in projections + +**Status:** next +**Weight:** light scope card + +### Objective + +Elicitation projection closes an open structured exchange when the matching `request_*` result is `answered`, `cancelled`, or `unavailable`. + +### Acceptance Criteria + +✓ `projectElicitationExchanges` closes a matching present/request tuple for `status: "cancelled"`. +✓ `projectElicitationExchanges` closes a matching present/request tuple for `status: "unavailable"`. +✓ Mismatched `exchangeId`, `respondsTo.exchangeId`, `respondsTo.presentTool`, or unexpected request tool still does not silently close the prompt. +✓ `session.pendingExchange` returns `idle` after a matching cancelled/unavailable terminal request in selected and explicit session projection paths. +✓ Tests cover at least one `request_choices` invalid/editor-unavailable result so the editor-fallback error path cannot leave the session permanently open. + +### Verification Approach + +- Inner: projection tests in `src/elicitation-exchange.test.ts` for answered/cancelled/unavailable terminal statuses and mismatch guards. +- Middle: RPC projection tests in `src/rpc/handlers.test.ts` for selected and explicit sessions with terminal non-answered request tuples. +- Gate: `npm run verify`. + +### Cross-cutting Obligations + +- Preserve D13-L: terminal structured-exchange tool results are response-side transcript entries. +- Preserve I23-L: terminal means `answered`, `cancelled`, or `unavailable`; do not make “open prompt” depend on successful answer only. +- Do not introduce a sidecar pending-exchange store to track terminal state. + +### Assumption Dependency + +None — this is a bugfix/hardening against existing SPEC semantics. + +--- + +## Card 3 — Preserve option content and rationale through pending/proof projections + +**Status:** queued +**Weight:** light scope card + +### Objective + +Public RPC pending exchange and parity-proof assertions preserve structured option `content` and optional `rationale`, not only id/label. + +### Acceptance Criteria + +✓ The public pending exchange schema/result can expose option `content` and optional `rationale` for `present_options` exchanges while keeping a stable label for compact choice display. +✓ Deterministic `present_options` data includes at least one option with distinct `content` and `rationale` so the oracle can catch flattening. +✓ `session.pendingExchange` tests assert option content/rationale survive from tuple details or markdown-backed projection. +✓ `src/probes/public-rpc-parity-proof.ts` asserts JSONL/projection parity for option content/rationale on single-select and multi-select exchanges. +✓ `session.transcriptDisplay` still renders human-readable option markdown with option artifacts at TUI-comparable quality. + +### Verification Approach + +- Inner: RPC handler tests for pending option shape and transcript display rows. +- Middle: public RPC parity proof asserts content/rationale fidelity across JSONL and projections. +- Gate: `npm run verify`. + +### Cross-cutting Obligations + +- Preserve D37-L: `toolResult.content` remains the durable user/model-readable markdown; `toolResult.details` carries structured projection/recovery data. +- Preserve D49-L: public clients read product-shaped pending exchange data through Brunch RPC, not raw Pi RPC events. +- Keep the response payload compact; do not expose assistant tool-call internals as the product contract. + +### Assumption Dependency + +Depends on: A23-L — this strengthens semantic parity quality for public RPC elicitation; it does not change the public proof’s boundary. diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index fb0f537c..67eddacb 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -25,7 +25,7 @@ describe("public Brunch RPC structured-exchange parity proof", () => { "request_choices", ]) expect(report.exchangeIds).toHaveLength(10) - expect(new Set(report.exchangeIds).size).toBeGreaterThanOrEqual(3) + expect(new Set(report.exchangeIds).size).toBe(10) expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(20) }) }) diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 01e60548..0f5c3716 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -258,7 +258,11 @@ export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofRep } } - for (const exchangeId of new Set(exchangeIds)) { + if (new Set(exchangeIds).size !== exchangeIds.length) { + throw new Error("Public RPC parity proof reused exchange IDs") + } + + for (const exchangeId of exchangeIds) { const presentIndex = tools.findIndex( (entry) => entry.details?.exchangeId === exchangeId && diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 1cec0d84..0fc49025 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -704,7 +704,7 @@ describe("JSON-RPC handlers", () => { id: 49, result: { status: "pending", - exchange: { exchangeId: "deterministic-grounding-choice" }, + exchange: { exchangeId: "deterministic-grounding-choice-1" }, }, }) }) @@ -1003,16 +1003,22 @@ describe("JSON-RPC handlers", () => { }) expect(textStart).toMatchObject({ result: { - exchange: { mode: "text", exchangeId: "deterministic-grounding-text" }, + exchange: { + mode: "text", + exchangeId: "deterministic-grounding-text-2", + }, }, }) + const textExchangeId = (textStart as { + result: { exchange: { exchangeId: string } } + }).result.exchange.exchangeId await expect( handlers.handle({ jsonrpc: "2.0", id: 253, method: "elicitation.respond", params: { - exchangeId: "deterministic-grounding-text", + exchangeId: textExchangeId, answer: { text: "A local product specification workspace." }, }, }), @@ -1032,7 +1038,7 @@ describe("JSON-RPC handlers", () => { result: { exchange: { mode: "multi-select", - exchangeId: "deterministic-grounding-multi", + exchangeId: "deterministic-grounding-multi-3", }, }, }) @@ -1042,7 +1048,7 @@ describe("JSON-RPC handlers", () => { id: 255, method: "elicitation.respond", params: { - exchangeId: "deterministic-grounding-multi", + exchangeId: "deterministic-grounding-multi-3", answer: { optionIds: ["transcript", "other"] }, note: "Also verify friction reporting.", }, diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index 6f7e1005..cf14149d 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -873,9 +873,10 @@ type PendingElicitationExchange = Static<typeof PendingElicitationExchangeSchema function nextDeterministicElicitationExchange( completedCount: number, ): PendingElicitationExchange { + const turnNumber = completedCount + 1 const script: PendingElicitationExchange[] = [ { - exchangeId: "deterministic-grounding-choice", + exchangeId: `deterministic-grounding-choice-${turnNumber}`, lens: "step-by-step", mode: "single-select", prompt: "Is this a new product or feature from scratch?", @@ -892,7 +893,7 @@ function nextDeterministicElicitationExchange( note: { allowed: true }, }, { - exchangeId: "deterministic-grounding-text", + exchangeId: `deterministic-grounding-text-${turnNumber}`, lens: "step-by-step", mode: "text", prompt: "What are we specifying?", @@ -902,7 +903,7 @@ function nextDeterministicElicitationExchange( note: { allowed: true }, }, { - exchangeId: "deterministic-grounding-multi", + exchangeId: `deterministic-grounding-multi-${turnNumber}`, lens: "step-by-step", mode: "multi-select", prompt: "Which proof qualities matter for this parity run?", From 8d7d4d2d8a09978310ecf4c39f08a3633e93c5e4 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 10:59:30 +0200 Subject: [PATCH 133/164] Close pending exchange on terminal request status --- memory/CARDS.md | 4 +- src/elicitation-exchange.test.ts | 91 ++++++++++++++++++++------ src/elicitation-exchange.ts | 4 +- src/rpc/handlers.test.ts | 108 +++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 21 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index e1b3f387..118bb8df 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -57,7 +57,7 @@ Depends on: A23-L — this card strengthens the landed validation of public RPC ## Card 2 — Treat matching cancelled and unavailable request tuples as terminal in projections -**Status:** next +**Status:** done **Weight:** light scope card ### Objective @@ -92,7 +92,7 @@ None — this is a bugfix/hardening against existing SPEC semantics. ## Card 3 — Preserve option content and rationale through pending/proof projections -**Status:** queued +**Status:** next **Weight:** light scope card ### Objective diff --git a/src/elicitation-exchange.test.ts b/src/elicitation-exchange.test.ts index 7945bbae..63690756 100644 --- a/src/elicitation-exchange.test.ts +++ b/src/elicitation-exchange.test.ts @@ -354,31 +354,86 @@ describe("elicitation exchange projection", () => { ]) }) - it("closes present_options with a terminal request_choices result", () => { - const presentOptions = { - ...presentQuestionToolResult, - id: "present-options-1", + it.each(["answered", "cancelled", "unavailable"] as const)( + "closes present_options with a terminal %s request_choices result", + (status) => { + const presentOptions = { + ...presentQuestionToolResult, + id: "present-options-1", + message: { + ...presentQuestionToolResult.message, + toolName: "present_options", + details: { + ...presentQuestionToolResult.message.details, + presentTool: "present_options", + kind: "options", + expectedRequest: { tool: "request_choices", required: true }, + }, + }, + } + const requestChoices = { + ...requestChoicesToolResult, + id: `request-choices-${status}`, + message: { + ...requestChoicesToolResult.message, + details: { + ...requestChoicesToolResult.message.details, + status, + }, + }, + } + + const projection = projectElicitationExchanges([ + presentOptions, + requestChoices, + ]) + + expect(projection.exchanges[0]?.responseEntryIds).toEqual([ + `request-choices-${status}`, + ]) + expect(projection.openPrompt).toBeNull() + }, + ) + + it("does not close a present when request tuple identity or tool expectations mismatch", () => { + const wrongPresentToolRequest = { + ...requestAnswerToolResult, + id: "request-answer-wrong-present-tool", + message: { + ...requestAnswerToolResult.message, + details: { + ...requestAnswerToolResult.message.details, + respondsTo: { exchangeId: "domain", presentTool: "present_options" }, + }, + }, + } + const unexpectedRequestTool = { + ...requestChoicesToolResult, + id: "request-choices-unexpected-tool", message: { - ...presentQuestionToolResult.message, - toolName: "present_options", + ...requestChoicesToolResult.message, details: { - ...presentQuestionToolResult.message.details, - presentTool: "present_options", - kind: "options", - expectedRequest: { tool: "request_choices", required: true }, + ...requestChoicesToolResult.message.details, + exchangeId: "domain", + respondsTo: { + exchangeId: "domain", + presentTool: "present_question", + }, }, }, } - const projection = projectElicitationExchanges([ - presentOptions, - requestChoicesToolResult, - ]) + for (const request of [wrongPresentToolRequest, unexpectedRequestTool]) { + const projection = projectElicitationExchanges([ + presentQuestionToolResult, + request, + ]) - expect(projection.exchanges[0]?.responseEntryIds).toEqual([ - "request-choices-1", - ]) - expect(projection.openPrompt).toBeNull() + expect(projection.exchanges).toEqual([]) + expect(projection.openPrompt?.promptEntryIds).toEqual([ + "present-question-1", + ]) + } }) it("renders structured-exchange present/request tool markdown as transcript rows", () => { diff --git a/src/elicitation-exchange.ts b/src/elicitation-exchange.ts index 24b87dc4..0dea852b 100644 --- a/src/elicitation-exchange.ts +++ b/src/elicitation-exchange.ts @@ -266,7 +266,9 @@ function requestClosesPresent( present: StructuredExchangePresentDetails, ): boolean { return ( - request.status === "answered" && + (request.status === "answered" || + request.status === "cancelled" || + request.status === "unavailable") && request.exchangeId === present.exchangeId && request.respondsTo.exchangeId === present.exchangeId && request.respondsTo.presentTool === present.presentTool && diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 0fc49025..05f0411d 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -819,6 +819,114 @@ describe("JSON-RPC handlers", () => { }) }) + it("reports idle pending state after selected and explicit terminal unavailable request tuples", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-unavailable-idle-")) + const sessionFile = join(cwd, ".brunch", "sessions", "session.jsonl") + await writeExplicitSessionFixture(cwd, [ + { type: "session", id: "session-1", cwd }, + sessionBindingEntry(), + presentQuestionEntry(), + { + ...requestAnswerEntry(), + id: "request-answer-unavailable", + message: { + ...requestAnswerEntry().message, + details: { + ...requestAnswerEntry().message.details, + status: "unavailable", + message: "Editor unavailable.", + }, + }, + }, + ]) + const handlers = createRpcHandlers({ + coordinator: coordinator(readyState(sessionFile)), + cwd, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 152, + method: "session.pendingExchange", + }), + ).resolves.toMatchObject({ + result: { status: "idle", exchange: null }, + }) + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 153, + method: "session.pendingExchange", + params: { sessionId: "session-1", specId: "spec-1" }, + }), + ).resolves.toMatchObject({ + result: { status: "idle", exchange: null }, + }) + }) + + it("reports idle pending state after an explicit terminal cancelled request_choices tuple", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-rpc-cancelled-idle-")) + await writeExplicitSessionFixture(cwd, [ + { type: "session", id: "session-1", cwd }, + sessionBindingEntry(), + { + ...presentQuestionEntry(), + id: "present-options-1", + message: { + ...presentQuestionEntry().message, + toolName: "present_options", + details: { + ...presentQuestionEntry().message.details, + presentTool: "present_options", + kind: "options", + expectedRequest: { tool: "request_choices", required: true }, + }, + }, + }, + { + id: "request-choices-cancelled", + type: "message", + parentId: "present-options-1", + message: { + role: "toolResult", + toolCallId: "request-call-choices-cancelled", + toolName: "request_choices", + content: [{ type: "text", text: "### Response\n\nCancelled." }], + details: { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: "domain", + requestTool: "request_choices", + status: "cancelled", + respondsTo: { + exchangeId: "domain", + presentTool: "present_options", + }, + message: "User cancelled the selection.", + createdAtToolCallId: "request-call-choices-cancelled", + }, + isError: false, + }, + }, + ]) + const handlers = createRpcHandlers({ + coordinator: coordinator(selectSpecState()), + cwd, + }) + + await expect( + handlers.handle({ + jsonrpc: "2.0", + id: 154, + method: "session.pendingExchange", + params: { sessionId: "session-1", specId: "spec-1" }, + }), + ).resolves.toMatchObject({ + result: { status: "idle", exchange: null }, + }) + }) + it("returns a product-shaped no-session error when reading pending without a selected session", async () => { const handlers = createRpcHandlers({ coordinator: coordinator(selectSpecState()), From 2ccf22330b885f092761677a838fad2bb742cd3e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 11:03:40 +0200 Subject: [PATCH 134/164] Preserve option artifacts in RPC parity --- memory/CARDS.md | 124 -------------------- src/probes/public-rpc-parity-proof.ts | 78 +++++++++++++ src/rpc/handlers.test.ts | 25 +++- src/rpc/handlers.ts | 157 ++++++++++++++++++++------ 4 files changed, 226 insertions(+), 158 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 118bb8df..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,124 +0,0 @@ -<!-- CARDS.md — temporary scope-card queue for one active frontier item. - Created by ln-scope. Delete or overwrite when exhausted/superseded. - Canonical planning state remains memory/SPEC.md and memory/PLAN.md. --> - -# Scope Card Queue — FE-744 RPC parity hardening - -## Orientation - -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch JSON-RPC structured-exchange parity proof and tuple-shaped Pi JSONL projections. -- **Frontier boundary:** one existing Linear/branch unit: FE-744 / `ln/fe-744-pi-ui-extension-patterns`. These are hardening slices inside that frontier, not new Linear issues or branches. -- **Current state:** four RPC parity slices landed and `npm run verify` passed; `memory/PLAN.md` says ten-turn public RPC tuple parity is landed, and the next feature slice is web real-time structured-exchange observation smoke. -- **Main open risk:** the parity proof currently passes while under-witnessing tuple identity, terminal request status handling, and option content/rationale fidelity. Harden those before web observation builds on the projection contract. - -## Queue Discipline - -- Consume cards in order unless implementation reveals a blocker that invalidates later cards. -- Each card should be verified and committed independently. -- These cards should not alter FE-744’s requirements; they tighten implementation and oracles against existing SPEC decisions/invariants (D13-L, D37-L, D38-L, D49-L, I23-L, I32-L). -- Inner loop after meaningful edits: `npm run fix`. Gate before each commit: `npm run verify`. - ---- - -## Card 1 — Make the ten-turn parity proof assert ten distinct tuple instances - -**Status:** done -**Weight:** light scope card - -### Objective - -The public RPC parity proof completes ten exchanges with ten distinct exchange IDs and validates each present/request pair independently. - -### Acceptance Criteria - -✓ The deterministic elicitation script no longer reuses the same exchange IDs across the ten-turn parity run. -✓ `src/probes/public-rpc-parity-proof.test.ts` asserts all 10 exchange IDs are distinct, not merely at least three. -✓ The JSONL parity oracle checks present-before-request ordering for every completed exchange instance, not `new Set(exchangeIds)` first occurrences. -✓ The proof still covers `present_question`, `request_answer`, `present_options`, `request_choice`, and `request_choices`. -✓ Existing resume behavior still returns an already-open pending exchange without appending a duplicate present result. - -### Verification Approach - -- Inner: focused RPC handler/probe tests for deterministic sequencing and resume-no-duplicate behavior. -- Middle: `src/probes/public-rpc-parity-proof.test.ts` validates ten distinct completed tuple instances and per-instance ordering. -- Gate: `npm run verify`. - -### Cross-cutting Obligations - -- Preserve I32-L: public RPC clients drive the loop through Brunch JSON-RPC only. -- Preserve I23-L: each structured exchange remains a recoverable present/request tuple with one matching terminal request. -- Do not reintroduce `brunch.elicitation_prompt` / `brunch.elicitation_response` into the public proof path. - -### Assumption Dependency - -Depends on: A23-L — this card strengthens the landed validation of public RPC elicitation parity; it does not introduce a new assumption. - ---- - -## Card 2 — Treat matching cancelled and unavailable request tuples as terminal in projections - -**Status:** done -**Weight:** light scope card - -### Objective - -Elicitation projection closes an open structured exchange when the matching `request_*` result is `answered`, `cancelled`, or `unavailable`. - -### Acceptance Criteria - -✓ `projectElicitationExchanges` closes a matching present/request tuple for `status: "cancelled"`. -✓ `projectElicitationExchanges` closes a matching present/request tuple for `status: "unavailable"`. -✓ Mismatched `exchangeId`, `respondsTo.exchangeId`, `respondsTo.presentTool`, or unexpected request tool still does not silently close the prompt. -✓ `session.pendingExchange` returns `idle` after a matching cancelled/unavailable terminal request in selected and explicit session projection paths. -✓ Tests cover at least one `request_choices` invalid/editor-unavailable result so the editor-fallback error path cannot leave the session permanently open. - -### Verification Approach - -- Inner: projection tests in `src/elicitation-exchange.test.ts` for answered/cancelled/unavailable terminal statuses and mismatch guards. -- Middle: RPC projection tests in `src/rpc/handlers.test.ts` for selected and explicit sessions with terminal non-answered request tuples. -- Gate: `npm run verify`. - -### Cross-cutting Obligations - -- Preserve D13-L: terminal structured-exchange tool results are response-side transcript entries. -- Preserve I23-L: terminal means `answered`, `cancelled`, or `unavailable`; do not make “open prompt” depend on successful answer only. -- Do not introduce a sidecar pending-exchange store to track terminal state. - -### Assumption Dependency - -None — this is a bugfix/hardening against existing SPEC semantics. - ---- - -## Card 3 — Preserve option content and rationale through pending/proof projections - -**Status:** next -**Weight:** light scope card - -### Objective - -Public RPC pending exchange and parity-proof assertions preserve structured option `content` and optional `rationale`, not only id/label. - -### Acceptance Criteria - -✓ The public pending exchange schema/result can expose option `content` and optional `rationale` for `present_options` exchanges while keeping a stable label for compact choice display. -✓ Deterministic `present_options` data includes at least one option with distinct `content` and `rationale` so the oracle can catch flattening. -✓ `session.pendingExchange` tests assert option content/rationale survive from tuple details or markdown-backed projection. -✓ `src/probes/public-rpc-parity-proof.ts` asserts JSONL/projection parity for option content/rationale on single-select and multi-select exchanges. -✓ `session.transcriptDisplay` still renders human-readable option markdown with option artifacts at TUI-comparable quality. - -### Verification Approach - -- Inner: RPC handler tests for pending option shape and transcript display rows. -- Middle: public RPC parity proof asserts content/rationale fidelity across JSONL and projections. -- Gate: `npm run verify`. - -### Cross-cutting Obligations - -- Preserve D37-L: `toolResult.content` remains the durable user/model-readable markdown; `toolResult.details` carries structured projection/recovery data. -- Preserve D49-L: public clients read product-shaped pending exchange data through Brunch RPC, not raw Pi RPC events. -- Keep the response payload compact; do not expose assistant tool-call internals as the product contract. - -### Assumption Dependency - -Depends on: A23-L — this strengthens semantic parity quality for public RPC elicitation; it does not change the public proof’s boundary. diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 0f5c3716..521b0a27 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -14,6 +14,8 @@ interface JsonRpcSuccess<T> { interface PendingOption { id: string label: string + content?: string + rationale?: string } interface PendingExchange { @@ -78,15 +80,24 @@ function success<T>(response: unknown): T { ) } +interface ToolResultOptionDetails { + id?: string + label?: string + content?: string + rationale?: string +} + interface ToolResultDetails { exchangeId?: string schema?: string requestTool?: string presentTool?: string + options?: ToolResultOptionDetails[] } interface ToolResultEntry { toolName: string + content: string details?: ToolResultDetails } @@ -94,6 +105,7 @@ interface JsonlMessageEntry { message?: { role?: string toolName?: string + content?: unknown details?: unknown } } @@ -106,10 +118,25 @@ function toolResultEntries(sessionText: string): ToolResultEntry[] { .filter((entry) => entry.message?.role === "toolResult") .map((entry) => ({ toolName: entry.message?.toolName ?? "", + content: textContent(entry.message?.content), details: entry.message?.details as never, })) } +function textContent(content: unknown): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content + .map((part) => + typeof part === "object" && + part !== null && + typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "", + ) + .join("\n") +} + interface ProofResponse { answer: unknown note?: string @@ -201,6 +228,22 @@ export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofRep `Turn ${turn + 1}: pendingExchange differed from startElicitation.`, ) } + if (started.exchange.mode !== "text") { + const richOption = started.exchange.options.find( + (option) => + option.content !== undefined && option.rationale !== undefined, + ) + if (!richOption) { + throw new Error( + `Turn ${turn + 1}: pending options dropped content/rationale`, + ) + } + if (richOption.content === richOption.label) { + throw new Error( + `Turn ${turn + 1}: pending option content collapsed into label`, + ) + } + } exchangeIds.push(started.exchange.exchangeId) const response = responseFor(started.exchange) await handlers.handle({ @@ -262,6 +305,41 @@ export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofRep throw new Error("Public RPC parity proof reused exchange IDs") } + const optionPresentResults = tools.filter( + (entry) => entry.toolName === "present_options", + ) + for (const entry of optionPresentResults) { + const richOption = entry.details?.options?.find( + (option) => + option.content !== undefined && option.rationale !== undefined, + ) + if (!richOption) { + throw new Error( + `Exchange ${entry.details?.exchangeId ?? "unknown"} JSONL option details dropped content/rationale`, + ) + } + const optionContent = richOption.content + const optionRationale = richOption.rationale + if (optionContent === undefined || optionRationale === undefined) { + throw new Error( + `Exchange ${entry.details?.exchangeId ?? "unknown"} JSONL option details dropped content/rationale`, + ) + } + if (optionContent === richOption.label) { + throw new Error( + `Exchange ${entry.details?.exchangeId ?? "unknown"} JSONL option content collapsed into label`, + ) + } + if ( + !entry.content.includes(optionContent) || + !entry.content.includes(optionRationale) + ) { + throw new Error( + `Exchange ${entry.details?.exchangeId ?? "unknown"} transcript markdown dropped option artifacts`, + ) + } + } + for (const exchangeId of exchangeIds) { const presentIndex = tools.findIndex( (entry) => diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 05f0411d..a78d2765 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -579,7 +579,13 @@ describe("JSON-RPC handlers", () => { mode: "single-select", prompt: expect.stringContaining("new product or feature"), options: expect.arrayContaining([ - expect.objectContaining({ id: "new-from-scratch" }), + expect.objectContaining({ + id: "new-from-scratch", + label: "Yes — this is new from scratch", + content: "Start a new spec workspace from a blank slate.", + rationale: + "This keeps the parity run focused on initial grounding.", + }), ]), note: { allowed: true }, }, @@ -617,6 +623,15 @@ describe("JSON-RPC handlers", () => { ], }, }) + const displayText = (display as { + result: { rows: Array<{ text: string }> } + }).result.rows[0]!.text + expect(displayText).toContain( + "Start a new spec workspace from a blank slate.", + ) + expect(displayText).toContain( + "This keeps the parity run focused on initial grounding.", + ) const sessionText = await readFile(workspace.session.file, "utf8") expect(sessionText).toContain("brunch.structured_exchange.present") @@ -658,6 +673,14 @@ describe("JSON-RPC handlers", () => { }).result.exchange.exchangeId, prompt: expect.stringContaining("new product or feature"), lens: "step-by-step", + options: expect.arrayContaining([ + expect.objectContaining({ + id: "new-from-scratch", + content: "Start a new spec workspace from a blank slate.", + rationale: + "This keeps the parity run focused on initial grounding.", + }), + ]), note: { allowed: true }, }, }, diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index cf14149d..3f86b0e9 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -329,9 +329,15 @@ const PendingElicitationExchangeSchema = Type.Object( prompt: NonBlankStringSchema, details: Type.Optional(NonBlankStringSchema), options: Type.Array( - Type.Object({ id: NonBlankStringSchema, label: NonBlankStringSchema }, { - additionalProperties: false, - }), + Type.Object( + { + id: NonBlankStringSchema, + label: NonBlankStringSchema, + content: NonBlankStringSchema, + rationale: Type.Optional(NonBlankStringSchema), + }, + { additionalProperties: false }, + ), ), note: Type.Object({ allowed: Type.Boolean() }, { additionalProperties: false, @@ -866,6 +872,8 @@ function choiceResponseMarkdown( interface PendingChoice { id: string label: string + content: string + rationale?: string } type PendingElicitationExchange = Static<typeof PendingElicitationExchangeSchema> @@ -883,11 +891,24 @@ function nextDeterministicElicitationExchange( details: "Choose the best starting context so later elicitation can ask useful follow-ups.", options: [ - { id: "new-from-scratch", label: "Yes — this is new from scratch" }, - { id: "existing-codebase", label: "No — this builds on existing code" }, + { + id: "new-from-scratch", + label: "Yes — this is new from scratch", + content: "Start a new spec workspace from a blank slate.", + rationale: "This keeps the parity run focused on initial grounding.", + }, + { + id: "existing-codebase", + label: "No — this builds on existing code", + content: "Ground the spec in existing implementation constraints.", + rationale: + "Existing code changes what the elicitor should inspect next.", + }, { id: "relates-to-existing-spec", label: "It relates to an existing spec", + content: "Connect this work to a prior specification thread.", + rationale: "Continuity matters when prior graph intent exists.", }, ], note: { allowed: true }, @@ -910,10 +931,32 @@ function nextDeterministicElicitationExchange( details: "Select all qualities the deterministic agent-as-user proof should preserve.", options: [ - { id: "transcript", label: "Transcript fidelity" }, - { id: "projection", label: "Projection fidelity" }, - { id: "other", label: "Other" }, - { id: "none", label: "None" }, + { + id: "transcript", + label: "Transcript fidelity", + content: "Pi JSONL keeps every present/request tuple recoverable.", + rationale: "The transcript is the durable source of truth.", + }, + { + id: "projection", + label: "Projection fidelity", + content: "Brunch projections preserve semantic option artifacts.", + rationale: + "Public clients depend on projected structured exchange data.", + }, + { + id: "other", + label: "Other", + content: "Another proof quality should be captured in the note.", + rationale: + "Other requires a comment so the transcript stays explicit.", + }, + { + id: "none", + label: "None", + content: "No additional proof qualities matter for this run.", + rationale: "None requires a comment to avoid silent dismissal.", + }, ], note: { allowed: true }, }, @@ -964,12 +1007,11 @@ function presentMarkdown(exchange: PendingElicitationExchange): string { const lines = [`## ${exchange.prompt}`] if (exchange.details) lines.push("", exchange.details) exchange.options.forEach((option, index) => { - lines.push( - "", - `### ${index + 1}. ${option.label}`, - "", - `<!-- option-id: ${option.id} -->`, - ) + lines.push("", `### ${index + 1}. ${option.content}`) + if (option.rationale) { + lines.push("", `**Rationale:** ${option.rationale}`) + } + lines.push("", `<!-- option-id: ${option.id} -->`) }) return lines.join("\n") } @@ -1036,29 +1078,78 @@ function pendingExchangeFromStructuredPresent( : "single-select", prompt, ...(detailsText.length > 0 ? { details: detailsText } : {}), - options: parsePendingOptions(richDetails.options), + options: parsePendingOptions(richDetails.options, markdown), note: { allowed: true }, } } -function parsePendingOptions(value: unknown): PendingChoice[] { - if (!Array.isArray(value)) return [] - return value.flatMap((option) => { - if ( - typeof option === "object" && - option !== null && - typeof (option as { id?: unknown }).id === "string" && - typeof (option as { label?: unknown }).label === "string" - ) { - return [ - { - id: (option as { id: string }).id, - label: (option as { label: string }).label, - }, - ] - } - return [] +function parsePendingOptions( + value: unknown, + markdown: string = "", +): PendingChoice[] { + if (!Array.isArray(value)) return parseMarkdownPendingOptions(markdown) + const options = value.flatMap((option) => { + if (typeof option !== "object" || option === null) return [] + const id = (option as { id?: unknown }).id + const label = (option as { label?: unknown }).label + const content = (option as { content?: unknown }).content + const rationale = (option as { rationale?: unknown }).rationale + if (typeof id !== "string") return [] + const optionContent = + typeof content === "string" + ? content + : typeof label === "string" + ? label + : undefined + if (optionContent === undefined) return [] + return [ + { + id, + label: typeof label === "string" ? label : optionContent, + content: optionContent, + ...(typeof rationale === "string" ? { rationale } : {}), + }, + ] }) + return options.length > 0 ? options : parseMarkdownPendingOptions(markdown) +} + +function parseMarkdownPendingOptions(markdown: string): PendingChoice[] { + const options: PendingChoice[] = [] + let pending: { + content: string + rationale?: string + } | undefined + + for (const line of markdown.split("\n")) { + const heading = /^###\s+\d+\.\s+(.+)$/.exec(line.trim()) + if (heading) { + pending = { content: heading[1]!.trim() } + continue + } + + const rationale = /^\*\*Rationale:\*\*\s+(.+)$/.exec(line.trim()) + if (rationale && pending) { + pending.rationale = rationale[1]!.trim() + continue + } + + const optionId = /<!--\s*option-id:\s*([^>]+?)\s*-->/.exec(line.trim()) + if (optionId && pending) { + const content = pending.content + options.push({ + id: optionId[1]!.trim(), + label: content, + content, + ...(pending.rationale === undefined + ? {} + : { rationale: pending.rationale }), + }) + pending = undefined + } + } + + return options } function structuredExchangePresentDetails( From e597a54c0380f2c0f1e05a39ff67ec1e593392d8 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 11:12:10 +0200 Subject: [PATCH 135/164] Sync FE-744 RPC parity state --- HANDOFF.md | 159 ++++++++---------- docs/architecture/pi-ui-extension-patterns.md | 12 +- memory/PLAN.md | 12 +- memory/SPEC.md | 10 +- 4 files changed, 84 insertions(+), 109 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 31936e61..ce8eccb5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,118 +1,93 @@ # Handoff -> Generated by `ln-handoff` at 2026-05-28T19:49:02Z. Read this file to resume work. -> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. +> Refreshed by `ln-sync` at 2026-05-29. This file is volatile transfer state only. +> Delete or overwrite it once the next session scopes/builds the web real-time observation slice or creates a newer handoff. ## Goal -Finish FE-744 by proving Brunch's Pi structured-exchange and public-RPC elicitation loop deeply enough to move on to sealed Pi profile/runtime-state work. +Finish FE-744 by closing the remaining Pi-wrapping proof seams after public RPC structured-exchange parity: web real-time structured-exchange observation, then branded/themed chrome recovery. ## Session State -- **Last completed skill**: `ln-build` — implemented and committed the same-assistant-message structured-exchange ordering proof. -- **Current skill**: `ln-handoff` — refreshing volatile transfer state after the ordering proof. -- **Flow position**: `grill → spec → plan → scope → build → handoff` -- **Handoff trigger**: user requested an updated handoff that retires stale/resolved items while preserving future-relevant notes. +- **Last completed implementation flow:** builder completed the FE-744 RPC parity hardening queue after the ten-turn parity proof. +- **Current skill:** `ln-sync` — reconciling canonical docs and refreshing this handoff. +- **Flow position:** `scope → build ×4 → review → scope hardening queue → build ×3 → sync/handoff`. +- **Branch:** `ln/fe-744-pi-ui-extension-patterns`. -## In-flight work +## Completed Since Previous Handoff -> There is no uncommitted scope card or half-built implementation artifact to preserve. The last scope card was completed by commit `e6251724` and reconciled into canonical docs. +### Public RPC tuple parity queue -### Next scope target — not yet scoped +- `5fa4ab45` — Implement structured exchange request choices +- `7f4c6318` — Project structured exchange tuples +- `929ea746` — Move RPC elicitation onto tuple truth +- `5e323437` — Add public RPC parity proof -The next actionable item is still inside the FE-744 `pi-ui-extension-patterns` frontier: - -- Let the deterministic elicitor advance through **at least ten structured exchanges**. -- Then build the ten-turn public Brunch RPC agent-as-user parity proof and projection oracle. -- Then run web real-time observation smoke. +### Review hardening queue -Suggested next `/ln-scope` target: +- `faa4dbc2` — Harden public RPC parity exchange identity +- `f1216fbc` — Close pending exchange on terminal request status +- `a9b3abb9` — Preserve option artifacts in RPC parity -> Scope the next FE-744 parity slice: deterministic assistant-first elicitor advances through at least ten structured exchanges using the now-proven same-message `present_* → request_*` tuple shape. +The builder reported `npm run verify` passed after the final hardening slice, and `memory/CARDS.md` was deleted as exhausted. -### Review findings +## Current Canonical State -No `ln-review` findings were produced in this session. +- Public RPC parity is now a landed FE-744 baseline, not open scope. +- `rpc.discover`, `workspace.selectionState`, `workspace.activate`, `session.startElicitation`, `session.pendingExchange`, `elicitation.respond`, `session.elicitationExchanges`, and `session.transcriptDisplay` form the public proof surface. +- `src/probes/public-rpc-parity-proof.ts` drives ten **distinct** assistant-first structured exchanges from a fresh cwd through Brunch JSON-RPC only. +- Tuple-shaped transcript truth is the active model: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered structured-exchange tools; review/candidate tools remain named stubs. +- Hardened projection behavior: matching terminal `answered`, `cancelled`, and `unavailable` request tuples close pending exchanges; option `content` and optional `rationale` survive public pending/proof projections. +- `memory/PLAN.md`, `memory/SPEC.md`, and `docs/architecture/pi-ui-extension-patterns.md` have been refreshed in this sync to reflect that parity/hardening has landed. -| # | Finding | Status | Implications | -| --- | --- | --- | --- | -| 1 | None | n/a | n/a | +## Next Scope Target -### Diagnostic evidence +The next actionable item is still inside the FE-744 `pi-ui-extension-patterns` frontier: -- `src/probes/structured-exchange-ordering-proof.test.ts` passes under `npm run verify`: proves a real Pi RPC run can process same-assistant-message sibling tool calls `present_options` then `request_choice` with both tools marked `executionMode: "sequential"`. -- Observed event order from the proof: `present_options:start`, `present_options:end`, `ui:select`, `request_choice:start`, `ui:input`, `request_choice:end`. - - This proves the present result completes before request UI opens. - - It also shows a caveat: RPC may emit the request UI before `request_choice` `tool_execution_start`, so future RPC consumers should not require request tool-start before dialog UI. -- JSONL oracle from the proof: persisted toolResult order is `present_options`, then `request_choice` for the same `exchangeId`. -- Earlier stale handoff items are resolved: - - `src/pi-extensions.ts` was moved to `src/tui-client/pi-extension-shell.ts` in commit `f24669b6`. - - `src/tui-client/.pi/README.md` now documents extension iteration (`cd src/tui-client`, `pi`, edit `.pi/extensions/...`, `/reload`). - - The old single `structured_exchange` tool model was replaced by the present/request family. +> Scope the web real-time structured-exchange observation smoke: a browser/web client observes selected session/exchange state updating when TUI or public RPC interactions append tuple-shaped structured-exchange transcript truth. -## Decisions and assumptions +Suggested acceptance shape: -| Item | Type | Status | Source | -| --- | --- | --- | --- | -| Structured exchanges are durable `present_*` / `request_*` `toolResult` tuples; `renderCall` is transient. | decision | persisted | `memory/SPEC.md` D37-L / I23-L | -| Same-assistant-message `present_options → request_choice` is acceptable for the next parity proof when tools use `executionMode: "sequential"`. | assumption/proof result | persisted | `memory/SPEC.md` D37-L / I23-L; `src/probes/structured-exchange-ordering-proof.ts` | -| RPC event consumers should not assume request `tool_execution_start` precedes request extension UI. | implementation caveat | persisted | `memory/SPEC.md` D37-L; `memory/PLAN.md` FE-744 pointer; `docs/architecture/pi-ui-extension-patterns.md` | -| Questionnaire/multi-question surfaces and distinct `skipped` terminal state remain deferred. | decision | persisted | `memory/SPEC.md` R17 / lexicon | -| Broader source-tree cleanup remains opportunistic, not a standalone current slice. | preference | volatile but low urgency | conversation; not needed for immediate next step | +- Web client subscribes or otherwise observes the currently selected spec/session state over the Brunch public surface. +- Starting/responding to a structured exchange through public RPC updates the browser view without a manual reload. +- The smoke covers pending exchange appearance, response/closure, transcript display/exchange projection change, and selected session identity. +- The proof stays read/observe-only from the web side unless an explicit product write path is already scoped. -## Repo state +After that, recover branded/themed chrome before FE-744 closeout by inspecting the retired probe implementation named in `memory/PLAN.md`: -- **Branch**: `ln/fe-744-pi-ui-extension-patterns` -- **Recent commits**: - - `e6251724 Prove structured exchange ordering` - - `f24669b6 Remodel structured exchange tools` - - `c1989aae Rename runbook checks to probe scripts` - - `9e077b21 Move probe harnesses under probes tree` - - `1dfbd259 Move public RPC modules under rpc tree` -- **Dirty files**: only `HANDOFF.md` is untracked/modified as volatile transfer state. -- **Test status**: `npm run verify` passed after `e6251724`. - - 26 test files / 210 tests passed. - - Typecheck and build passed. +```sh +git show 6c2e3823:.pi/extensions/brunch-chrome.ts +``` -## Artifact status +## Decisions and Assumptions -| Artifact | Exists | Current vs conversation | +| Item | Status | Source | | --- | --- | --- | -| `memory/SPEC.md` | yes | current; reconciled through ordering proof | -| `memory/PLAN.md` | yes | current; FE-744 execution pointer names next parity sequence | -| `memory/CARDS.md` | no | n/a | -| `memory/REFACTOR.md` | no | n/a | -| `docs/architecture/pi-ui-extension-patterns.md` | yes | current; records ordering proof and remaining FE-744 gaps | -| `HANDOFF.md` | yes | volatile; this file | - -## Next steps - -1. Run `/ln-scope` for the next FE-744 parity slice: deterministic elicitor advances through at least ten structured exchanges. -2. Then `/ln-build` that slice, preserving the present/request tuple invariant and using Brunch public RPC rather than raw Pi RPC for product-facing proof work. -3. After the ten-turn parity proof lands, scope web real-time observation smoke. -4. Before FE-744 closeout, recover branded/themed chrome from retired probe evidence per `memory/PLAN.md` current execution pointer. - -## Retirement rule - -- Overwrite or delete this file once the next session has scoped/built the ten-turn deterministic elicitor/parity work or creates a newer handoff. -- Do not commit `HANDOFF.md` as canonical planning truth unless explicitly requested. - -## Open questions - -- What exact deterministic elicitor mechanism should the next slice use for ten exchanges: extend current `session.startElicitation` dummy flow, or introduce a small dedicated parity driver around the structured-exchange present/request tools? -- Should the ten-turn proof assert only JSONL/projection parity first, or also include a lightweight transcript display snapshot in the same slice? - -## Resume prompt - -Paste this into a new session: - -> Read `HANDOFF.md`, `memory/SPEC.md`, and the FE-744 section of `memory/PLAN.md`. -> The immediate next step is to run `/ln-scope` for the next FE-744 parity slice: deterministic assistant-first elicitor advances through at least ten structured exchanges. -> Start by reviewing `src/probes/structured-exchange-ordering-proof.ts`, `src/tui-client/.pi/extensions/structured-exchange/`, and the current RPC elicitation handlers/tests so the next scope card builds on the proven same-message present/request ordering rather than re-investigating it. - -## Addendum — after final reconciliation check - -- The public Brunch RPC tracer bullets have landed and should be treated as baseline, not open scope: `rpc.discover`, `session.startElicitation`, `session.pendingExchange`, and listed-option `elicitation.respond` are implemented in the current RPC handler module (`src/rpc/handlers.ts`) with tests in `src/rpc/handlers.test.ts`. -- `memory/PLAN.md`'s detailed FE-744 current execution pointer is accurate about the next sequence, but any older status prose saying public RPC discovery/pending/respond are still missing is stale and should be cleaned during the next `ln-plan`/`ln-sync` pass. -- The next slice should decide explicitly whether the ten-turn deterministic elicitor remains on the current lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` public-RPC loop, or whether it mirrors the newer structured-exchange `present_* → request_*` tuple shape for stronger TUI parity. Do not accidentally mix the two without naming the adapter boundary. -- Working-tree caution for the next session: `memory/STRUCTURED_EXCHANGE_SIDE_MISSION.md` is currently deleted in the worktree and should be treated as protected concurrent cleanup unless the user confirms it is intentional. +| Structured exchanges are durable `present_*` / `request_*` `toolResult` tuples; `renderCall` is transient. | persisted | `memory/SPEC.md` D37-L / I23-L | +| Public Brunch RPC can drive ten assistant-first structured exchanges without raw Pi RPC or a parallel prompt/turn store. | validated | `memory/SPEC.md` A23-L / I32-L; `src/probes/public-rpc-parity-proof.ts` | +| `request_choices` is now implemented and registered; multi-choice uses JSON-editor fallback semantics where needed. | persisted | `memory/SPEC.md` I23-L; structured-exchange tests | +| Matching `cancelled` and `unavailable` request tuples are terminal for projection/pending state. | persisted | `memory/SPEC.md` I23-L; projection tests | +| RPC event consumers should not assume request `tool_execution_start` precedes request extension UI. | persisted | `memory/SPEC.md` D37-L; `docs/architecture/pi-ui-extension-patterns.md` | +| Questionnaire/multi-question surfaces and distinct `skipped` terminal state remain deferred. | persisted | `memory/SPEC.md` R17 / lexicon | + +## Artifact Status + +| Artifact | Status | +| --- | --- | +| `memory/SPEC.md` | refreshed; A23/I23/I32 updated for landed parity/hardening | +| `memory/PLAN.md` | refreshed; FE-744 pointer now names web observation then chrome recovery | +| `docs/architecture/pi-ui-extension-patterns.md` | refreshed; no longer says ten-turn public RPC parity is missing | +| `memory/CARDS.md` | absent; last hardening queue exhausted and deleted | +| `memory/REFACTOR.md` | absent | +| `HANDOFF.md` | this volatile handoff | + +## Repo State Notes + +- At the start of sync, git status was clean and the branch was ahead of origin by 7 commits. +- This sync intentionally edits canonical docs plus this handoff; commit or discard those doc edits according to the session plan. +- No code changes were made in this sync. + +## Resume Prompt + +> Read `memory/SPEC.md`, the FE-744 section of `memory/PLAN.md`, and `HANDOFF.md`. +> Public RPC structured-exchange parity and its review hardening have landed. The immediate next step is `/ln-scope` for web real-time structured-exchange observation smoke inside FE-744. Preserve tuple-shaped transcript truth, public Brunch RPC boundaries, and the read-only observer posture for web unless a write path is explicitly scoped. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index a61fd8e7..7d32b69c 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -229,7 +229,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Structured-exchange / RPC-relay gap -The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop and has started the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gaps are the ten-turn public Brunch RPC parity run, web observation, and chrome recovery. +The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gaps are web observation and chrome recovery. Pi source/docs already give strong evidence for the primitive: @@ -240,14 +240,14 @@ Pi source/docs already give strong evidence for the primitive: - `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch must still prove is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from web/CLI probe or TUI custom UI → durable present/request tool results in Pi JSONL → existing response-side exchange projection. The trimmed working plan remains in `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` until the ten-turn parity proof lands or the remaining relay work is deliberately moved into a named M5 slice. +The seam Brunch has now proven is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from a CLI probe over Brunch RPC → durable present/request tool results in Pi JSONL → response-side exchange projection. The next proof applies the same relay to the browser observer path: web clients should see selected session/exchange state update in real time when TUI or RPC interactions append tuple-shaped transcript truth. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | -| Elicitation-first session loop | POC-critical and partially proven. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, cancelled, marked unavailable, or explicitly display-only. | -| Registered structured-exchange tool seam | Brunch present/request tests cover markdown `toolResult.content`, self-contained `toolResult.details`, non-semantic `renderCall`, unmatched-present recovery, and a real Pi RPC same-assistant-message sequential ordering proof for `present_options → request_choice`. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side; RPC consumers should not require `request_*` `tool_execution_start` before extension UI because the UI request can arrive first. | -| TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for freeform and listed-choice responses; multi-choice/review/candidate tools are named stubs until their product flows land. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | -| JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; public product relay is still missing. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | +| Elicitation-first session loop | Proven for deterministic public RPC parity; web observation still pending. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, cancelled, marked unavailable, or explicitly display-only. | +| Registered structured-exchange tool seam | Brunch present/request tests cover markdown `toolResult.content`, self-contained `toolResult.details`, non-semantic `renderCall`, unmatched-present recovery, `request_choices` JSON-editor fallback, terminal cancelled/unavailable closure, option content/rationale parity, and a real Pi RPC same-assistant-message sequential ordering proof for `present_options → request_choice`. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side; RPC consumers should not require `request_*` `tool_execution_start` before extension UI because the UI request can arrive first. | +| TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for freeform and listed-choice responses; multi-choice now has an RPC-compatible `request_choices` path, while review/candidate tools remain named stubs until their product flows land. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | +| JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; the public product relay now exercises the same multi-choice semantics through Brunch RPC. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | | Review-set decisions | Depends on the same terminal structured-result discipline. | Approve routes to one `acceptReviewSet` command; request-changes appends a successor proposal; reject persists a terminal response. | | Pickers and orientation views | Workspace switcher proves pure decision UI. | Reuse the same decision-returning shape; coordinator or command-layer code owns mutations. | | Live Pi harness probes | Useful for fast source/API validation but not Brunch-host proof. | Keep scratch extensions temporary, record evidence tier, and promote only product-named wrappers that survive the spike. | diff --git a/memory/PLAN.md b/memory/PLAN.md index d0bb3b85..fe7245fb 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved the raw Pi RPC editor fallback for one structured exchange, but must re-aim at the product boundary by proving a public Brunch JSON-RPC, assistant-first, ten-turn elicitation session parity run before chrome/web closeout. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first ten-turn tuple parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure @@ -42,14 +42,14 @@ The POC should maximize assumption falsification rather than merely implement mi | A20-L Drizzle 1.0 beta | Beta blocks migrations, SQLite fidelity, or TypeBox derivation. | `graph-data-plane` starts with a version/schema spike before broad imports. | | A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad fixtures earlier so the rubric is falsifiable. | | A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture fixtures before async observer backstops are reconsidered. | -| A23-L public RPC elicitation parity | A public Brunch RPC client cannot discover methods, activate workspace/spec/session, drive assistant-first pending exchanges, or produce TUI-comparable JSONL without speaking raw Pi RPC or adding a parallel turn store. | FE-744 is not done until `rpc.discover`, pending/respond lifecycle, deterministic assistant-first harness, and ten-turn transcript parity proof land. | +| A23-L public RPC elicitation parity | A public Brunch RPC client cannot discover methods, activate workspace/spec/session, drive assistant-first pending exchanges, or produce TUI-comparable JSONL without speaking raw Pi RPC or adding a parallel turn store. | Validated by FE-744 public-RPC tuple parity and hardening commits; remaining FE-744 work observes the same session/exchange state from web and recovers branded chrome. | ## Sequencing ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof by moving from the completed raw Pi RPC editor-fallback proof to a public Brunch JSON-RPC elicitation session parity proof: runtime method discovery, workspace/spec/session activation, assistant-first start/resume, pending-exchange respond lifecycle, deterministic ten-turn agent-as-user run, TUI-comparable JSONL/projections, then web real-time observation and branded/themed chrome recovery. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback and public Brunch JSON-RPC ten-turn tuple parity are covered: prove web real-time structured-exchange observation, then recover branded/themed chrome. ### Next @@ -217,15 +217,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, and public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges, and parity hardening for distinct exchange ids, terminal non-answered statuses, and option content/rationale have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. The remaining active acceptance is a public RPC elicitation session parity proof. `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new or existing workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; a deterministic dummy elicitor asks at least ten structured exchanges using the same result-details semantics proven by the raw Pi RPC fallback; the agent-as-user driver answers through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option/answer/note/mode/status/transport artifacts at TUI-comparable quality; web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state; and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC elicitation session parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers ten distinct structured exchanges through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. The remaining active acceptance is that web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state, and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until public RPC elicitation parity and chrome recovery close the active A10-L/A18-L/A23-L risk. +- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, and preserves option `content` plus optional `rationale` through pending/proof projections. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 2d3f1fb7..88e67f8c 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -119,7 +119,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | | A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | | A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness/posture updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | open | D18-L, D26-L, D45-L, I30-L | M5 agent-graph-integration fixtures and review: compare elicitor-captured graph updates against transcript evidence; track over-capture, missed obvious facts, and whether preface-led disambiguation resolves low-confidence material without an async observer owning primary extraction. | -| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive an assistant-first elicitation session for at least ten turns without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | medium | open | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC elicitation parity proof: discover methods, activate workspace/spec/session, start/resume a deterministic elicitor, answer pending exchanges through Brunch methods, and compare the resulting Pi JSONL/projections against TUI-shaped session expectations. | +| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive an assistant-first elicitation session for at least ten turns without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | high | validated | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC elicitation parity proof landed: method discovery, workspace/spec/session activation, deterministic start/resume/pending/respond lifecycle, ten distinct structured-exchange tuples, terminal non-answered status handling, option artifact parity, and Pi JSONL/projection comparison. | ### Active Decisions @@ -217,7 +217,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, so the next parity proof may use same-message tuples. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives ten distinct tuple-shaped exchanges over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -262,7 +262,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | partial (FE-744 now registers sequential `present_question`, `present_options`, `request_answer`, and `request_choice` tools from `src/tui-client/.pi/extensions/structured-exchange/`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, and the legacy JSON-editor fallback helper used by the raw Pi RPC proof. `present_review_set`, `present_candidates`, `request_choices`, and `request_review` are named stubs but intentionally not registered. `src/probes/structured-exchange-ordering-proof.ts` proves same-assistant-message `present_options → request_choice` sequential ordering over a real Pi RPC run: present tool result before request UI, and present JSONL toolResult before request JSONL toolResult.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | @@ -271,7 +271,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | -| I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | planned (FE-744 `rpc.discover` contract tests, pending/respond lifecycle tests, ten-turn public-RPC elicitation parity proof, and transcript-projection parity oracle) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | +| I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, ten distinct public-RPC structured-exchange tuples, terminal non-answered status handling, option content/rationale parity, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | ## Future Direction Register @@ -393,7 +393,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` family. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | | **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. Its details include `exchangeId`, `presentTool`, `kind`, `status: presented`, `expectedRequest`, and `createdAtToolCallId`. | -| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, future `request_choices`, `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. It references the present half by `exchangeId` and present tool rather than repeating the presented markdown. | +| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, future `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. It references the present half by `exchangeId` and present tool rather than repeating the presented markdown. | | **Structured exchange result details** | The structured payload in a structured-exchange toolResult. Present details support tuple recovery; request details carry terminal status (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review/comment data. Brunch projection should not need render lifecycle state to rebuild the exchange. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained `request_*` toolResult details. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | From 3d852e5db8379381e295d0abd8da77d9ba2f4899 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 11:22:33 +0200 Subject: [PATCH 136/164] Add session transcript renderer --- src/session-transcript.test.ts | 90 ++++++++++++ src/session-transcript.ts | 248 +++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 src/session-transcript.test.ts create mode 100644 src/session-transcript.ts diff --git a/src/session-transcript.test.ts b/src/session-transcript.test.ts new file mode 100644 index 00000000..a8b2a639 --- /dev/null +++ b/src/session-transcript.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" + +import { renderSessionTranscript } from "./session-transcript.js" + +function line(value: unknown): string { + return JSON.stringify(value) +} + +describe("session transcript renderer", () => { + it("renders structured-exchange tuple JSONL as a readable transcript", () => { + const jsonl = [ + line({ type: "session", id: "session-1", cwd: "/tmp/brunch" }), + line({ + id: "binding-1", + type: "custom", + customType: "brunch.session_binding", + data: { specId: "spec-1", specTitle: "Demo spec" }, + }), + line({ + id: "present-1", + type: "message", + message: { + role: "toolResult", + toolName: "present_options", + content: [ + { + type: "text", + text: "## Which direction?\n\n### 1. Fast\n\n**Rationale:** validates the seam.", + }, + ], + details: { + schema: "brunch.structured_exchange.present", + schemaVersion: 1, + exchangeId: "turn-1", + presentTool: "present_options", + kind: "options", + status: "presented", + expectedRequest: { tool: "request_choice", required: true }, + createdAtToolCallId: "present-call-1", + }, + }, + }), + line({ + id: "request-1", + type: "message", + message: { + role: "toolResult", + toolName: "request_choice", + content: [ + { + type: "text", + text: "### Response\n\n- Fast\n\nComment:\n\n> Keep it deterministic.", + }, + ], + details: { + schema: "brunch.structured_exchange.request", + schemaVersion: 1, + exchangeId: "turn-1", + requestTool: "request_choice", + status: "answered", + respondsTo: { + exchangeId: "turn-1", + presentTool: "present_options", + }, + choice: { id: "fast", label: "Fast" }, + comment: "Keep it deterministic.", + createdAtToolCallId: "request-call-1", + }, + }, + }), + ].join("\n") + + const transcript = renderSessionTranscript(jsonl, { + title: "session.jsonl", + }) + + expect(transcript).toContain("# Transcript — session.jsonl") + expect(transcript).toContain("## Session") + expect(transcript).toContain("- session: session-1") + expect(transcript).toContain("## Session binding") + expect(transcript).toContain( + "## Exchange turn-1 — prompt (present_options → request_choice)", + ) + expect(transcript).toContain("**Rationale:** validates the seam.") + expect(transcript).toContain( + "## Exchange turn-1 — response (request_choice, answered)", + ) + expect(transcript).toContain("Keep it deterministic.") + }) +}) diff --git a/src/session-transcript.ts b/src/session-transcript.ts new file mode 100644 index 00000000..3d66590f --- /dev/null +++ b/src/session-transcript.ts @@ -0,0 +1,248 @@ +import { readFile } from "node:fs/promises" +import { basename, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import type { ToolResultMessage } from "@earendil-works/pi-ai" +import type { + CustomEntry, + CustomMessageEntry, + FileEntry, + SessionHeader, + SessionMessageEntry, +} from "@earendil-works/pi-coding-agent" + +import { + isStructuredExchangePresentDetails, + isStructuredExchangeRequestDetails, +} from "./tui-client/.pi/extensions/structured-exchange/shared/recovery.js" + +type TranscriptEntry = FileEntry + +type TranscriptToolResultMessage = ToolResultMessage<unknown> + +export async function renderSessionTranscriptFile( + sessionFile: string, +): Promise<string> { + const text = await readFile(sessionFile, "utf8") + return renderSessionTranscript(text, { title: basename(sessionFile) }) +} + +export function renderSessionTranscript( + jsonl: string, + options: { title?: string } = {}, +): string { + const entries = parseJsonl(jsonl) + const lines: string[] = [ + `# Transcript${options.title ? ` — ${options.title}` : ""}`, + ] + + for (const entry of entries) { + lines.push("", ...renderEntry(entry)) + } + + return `${lines.join("\n").trimEnd()}\n` +} + +function parseJsonl(jsonl: string): FileEntry[] { + return jsonl + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line, index) => { + try { + return JSON.parse(line) as TranscriptEntry + } catch (error) { + throw new Error( + `Invalid JSONL at line ${index + 1}: ${(error as Error).message}`, + ) + } + }) +} + +function renderEntry(entry: TranscriptEntry): string[] { + if (isSessionHeaderEntry(entry)) { + return renderSessionHeader(entry) + } + + if (isCustomTranscriptEntry(entry)) { + return renderCustomEntry(entry) + } + + if (isMessageEntry(entry)) { + return renderMessageEntry(entry) + } + + return [ + `## Entry ${entryId(entry)}`, + "", + "```json", + JSON.stringify(entry, null, 2), + "```", + ] +} + +function renderSessionHeader(entry: SessionHeader): string[] { + const fields = [ + typeof entry.id === "string" ? `- session: ${entry.id}` : undefined, + typeof entry.cwd === "string" ? `- cwd: ${entry.cwd}` : undefined, + ].filter((line): line is string => line !== undefined) + return [ + "## Session", + "", + ...(fields.length > 0 ? fields : ["- session metadata present"]), + ] +} + +function renderCustomEntry(entry: CustomEntry | CustomMessageEntry): string[] { + const customType = + typeof entry.customType === "string" ? entry.customType : "custom" + const title = + customType === "brunch.session_binding" + ? "Session binding" + : `Custom: ${customType}` + const payload = entry.type === "custom_message" ? entry.details : entry.data + const text = textContent( + entry.type === "custom_message" ? entry.content : undefined, + ) + const body: string[] = [] + if (text.length > 0) body.push(text) + if (payload !== undefined) { + body.push("```json", JSON.stringify(payload, null, 2), "```") + } + return [ + `## ${title}`, + "", + ...(body.length > 0 ? body : ["_(no display content)_"]), + ] +} + +function renderMessageEntry(entry: SessionMessageEntry): string[] { + const message = entry.message + if (!message || typeof message !== "object") { + return [`## Message ${entryId(entry)}`, "", "_(missing message payload)_"] + } + + if (isToolResultMessage(message)) { + return renderToolResult(entry, message) + } + + const role = + typeof message.role === "string" ? titleCase(message.role) : "Message" + const text = textContent( + (message as unknown as Record<string, unknown>).content, + ) + return [`## ${role}`, "", text.length > 0 ? text : "_(empty)_"] +} + +function renderToolResult( + _entry: SessionMessageEntry, + message: TranscriptToolResultMessage, +): string[] { + const details = message.details + const present = structuredPresent(details) + if (present) { + const expected = + present.expectedRequest && + typeof present.expectedRequest.tool === "string" + ? ` → ${present.expectedRequest.tool}` + : "" + return [ + `## Exchange ${present.exchangeId} — prompt (${present.presentTool}${expected})`, + "", + textContent(message.content) || "_(empty prompt)_", + ] + } + + const request = structuredRequest(details) + if (request) { + return [ + `## Exchange ${request.exchangeId} — response (${request.requestTool}, ${request.status})`, + "", + textContent(message.content) || "_(empty response)_", + ] + } + + const body = textContent(message.content) + return [ + `## Tool result: ${message.toolName}`, + "", + body.length > 0 ? body : "_(empty tool result)_", + ...(details === undefined + ? [] + : ["", "```json", JSON.stringify(details, null, 2), "```"]), + ] +} + +function structuredPresent(value: unknown) { + return isStructuredExchangePresentDetails(value) ? value : null +} + +function structuredRequest(value: unknown) { + return isStructuredExchangeRequestDetails(value) ? value : null +} + +function textContent(content: unknown): string { + if (typeof content === "string") return content.trim() + if (!Array.isArray(content)) return "" + return content + .map((part) => + isRecord(part) && typeof part.text === "string" ? part.text : "", + ) + .filter((text) => text.length > 0) + .join("\n") + .trim() +} + +function isSessionHeaderEntry(entry: TranscriptEntry): entry is SessionHeader { + return entry.type === "session" +} + +function isCustomTranscriptEntry( + entry: TranscriptEntry, +): entry is CustomEntry | CustomMessageEntry { + return entry.type === "custom" || entry.type === "custom_message" +} + +function isMessageEntry(entry: TranscriptEntry): entry is SessionMessageEntry { + return entry.type === "message" +} + +function isToolResultMessage( + message: SessionMessageEntry["message"], +): message is TranscriptToolResultMessage { + return message.role === "toolResult" +} + +function entryId(entry: TranscriptEntry): string { + return typeof entry.id === "string" ? entry.id : "(unknown)" +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +async function main(): Promise<void> { + const [, , sessionFile] = process.argv + if (!sessionFile) { + process.stderr.write( + "Usage: tsx src/session-transcript.ts <session.jsonl>\n", + ) + process.exitCode = 1 + return + } + process.stdout.write(await renderSessionTranscriptFile(sessionFile)) +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + void main().catch((error) => { + process.stderr.write(`${(error as Error).stack ?? String(error)}\n`) + process.exitCode = 1 + }) +} From 56125ca448039244e6a56dea6b09311fa8d2d0b7 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 11:23:54 +0200 Subject: [PATCH 137/164] Discover prod-ready Brunch Pi extensions --- src/brunch-tui.test.ts | 8 +- .../auto-discovered-extensions.test.ts | 76 ++++++++ src/tui-client/.pi/extensions/alternatives.ts | 7 + src/tui-client/.pi/extensions/chrome.ts | 14 ++ .../.pi/extensions/command-policy.ts | 7 + .../.pi/extensions/mention-autocomplete.ts | 12 ++ .../.pi/extensions/operational-mode.ts | 8 + .../.pi/extensions/session-lifecycle.ts | 18 ++ .../extensions/structured-exchange/index.ts | 9 + .../.pi/extensions/workspace-dialog.ts | 12 ++ src/tui-client/pi-extension-shell.ts | 178 ++++++++++++++---- 11 files changed, 304 insertions(+), 45 deletions(-) diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 9ac8d29b..ff414a6d 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -213,7 +213,7 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => Promise<void>> = [] - createBrunchPiExtensionShell( + await createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), (sessionManager) => { boundSessionIds.push(sessionManager.getSessionId()) @@ -265,7 +265,7 @@ describe("Brunch TUI boot", () => { new Map<string, Omit<RegisteredCommand, "name" | "sourceInfo">>() const registeredTools: string[] = [] - createBrunchPiExtensionShell( + await createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), undefined, { @@ -527,7 +527,7 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => unknown>() - createBrunchPiExtensionShell( + await createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace(cwd, manager.getSessionId())), undefined, { coordinator: noOpWorkspaceCoordinator(cwd) }, @@ -625,7 +625,7 @@ describe("Brunch TUI boot", () => { ctx: FakeExtensionContext, ) => Promise<void> | void> = [] - createBrunchPiExtensionShell( + await createBrunchPiExtensionShell( chromeStateForWorkspace(readyWorkspace("/tmp/project", "session-1")), undefined, { coordinator: noOpWorkspaceCoordinator("/tmp/project") }, diff --git a/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts b/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts index 92905003..0d3ec9fc 100644 --- a/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts +++ b/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts @@ -1,5 +1,13 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + import { describe, expect, it } from "vitest" +import { + BRUNCH_PRODUCT_EXTENSION_READY, + discoverBrunchProductExtensionEntries, +} from "../../pi-extension-shell.js" import alternatives from "../extensions/alternatives.js" import chrome from "../extensions/chrome.js" import commandPolicy from "../extensions/command-policy.js" @@ -26,4 +34,72 @@ describe("Pi auto-discovered extensions", () => { expect(factory, path).toEqual(expect.any(Function)) } }) + + it("discovers prod-ready Brunch extension entrypoints from local metadata", async () => { + const entries = await discoverBrunchProductExtensionEntries() + + expect(entries.map((entry) => entry.path)).toEqual([ + "session-lifecycle.ts", + "chrome.ts", + "command-policy.ts", + "operational-mode.ts", + "mention-autocomplete.ts", + "alternatives.ts", + "structured-exchange/index.ts", + "workspace-dialog.ts", + ]) + for (const entry of entries) { + expect(entry.meta.productStatus, entry.path).toBe( + BRUNCH_PRODUCT_EXTENSION_READY, + ) + expect(entry.registerProductExtension, entry.path).toEqual( + expect.any(Function), + ) + } + }) + + it("does not treat support modules or WIP modules as product extension entrypoints", async () => { + const entries = await discoverBrunchProductExtensionEntries() + const paths = entries.map((entry) => entry.path) + + expect(paths).not.toContain("structured-exchange/request-choice.ts") + expect(paths).not.toContain("structured-exchange/shared/model.ts") + expect(paths).not.toContain("subagents/config.json") + expect(paths).not.toContain("auto-compaction-anchors.json") + }) + + it("requires local ready metadata before product loading an entrypoint", async () => { + const extensionsDir = await mkdtemp(join(tmpdir(), "brunch-extensions-")) + await writeFile( + join(extensionsDir, "ready.js"), + `export const brunchExtensionMeta = { productStatus: "ready" }; + export function registerBrunchProductExtension() {}`, + ) + await writeFile( + join(extensionsDir, "implicit.js"), + `export function registerBrunchProductExtension() {}`, + ) + await writeFile( + join(extensionsDir, "wip.js"), + `export const brunchExtensionMeta = { productStatus: "wip" }; + export function registerBrunchProductExtension() {}`, + ) + await mkdir(join(extensionsDir, "nested")) + await writeFile( + join(extensionsDir, "nested", "index.js"), + `export const brunchExtensionMeta = { productStatus: "ready", loadOrder: -1 }; + export function registerBrunchProductExtension() {}`, + ) + await writeFile( + join(extensionsDir, "nested", "helper.js"), + `throw new Error("support files must not be imported")`, + ) + + const entries = await discoverBrunchProductExtensionEntries(extensionsDir) + + expect(entries.map((entry) => entry.path)).toEqual([ + "nested/index.js", + "ready.js", + ]) + }) }) diff --git a/src/tui-client/.pi/extensions/alternatives.ts b/src/tui-client/.pi/extensions/alternatives.ts index 406c33cf..99825d8a 100644 --- a/src/tui-client/.pi/extensions/alternatives.ts +++ b/src/tui-client/.pi/extensions/alternatives.ts @@ -206,4 +206,11 @@ export function registerBrunchAlternatives(pi: ExtensionAPI) { }) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 60, +} as const + +export const registerBrunchProductExtension = registerBrunchAlternatives + export default registerBrunchAlternatives diff --git a/src/tui-client/.pi/extensions/chrome.ts b/src/tui-client/.pi/extensions/chrome.ts index aa1ad41d..52d608eb 100644 --- a/src/tui-client/.pi/extensions/chrome.ts +++ b/src/tui-client/.pi/extensions/chrome.ts @@ -161,6 +161,20 @@ export function renderBrunchChrome( ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 20, +} as const + +export function registerBrunchProductExtension( + pi: ExtensionAPI, + context: { chrome: BrunchChromeState }, +): void { + pi.on("session_start", async (_event, ctx) => { + renderBrunchChrome(ctx.ui, context.chrome) + }) +} + export default function brunchChrome(_pi: ExtensionAPI): void {} function formatSpec(chrome: BrunchChromeState): string { diff --git a/src/tui-client/.pi/extensions/command-policy.ts b/src/tui-client/.pi/extensions/command-policy.ts index 4b8649b6..c74ebfc9 100644 --- a/src/tui-client/.pi/extensions/command-policy.ts +++ b/src/tui-client/.pi/extensions/command-policy.ts @@ -14,4 +14,11 @@ export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { }) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 30, +} as const + +export const registerBrunchProductExtension = registerBrunchBranchPolicyHandlers + export default registerBrunchBranchPolicyHandlers diff --git a/src/tui-client/.pi/extensions/mention-autocomplete.ts b/src/tui-client/.pi/extensions/mention-autocomplete.ts index 69176272..3d7dde47 100644 --- a/src/tui-client/.pi/extensions/mention-autocomplete.ts +++ b/src/tui-client/.pi/extensions/mention-autocomplete.ts @@ -154,4 +154,16 @@ function candidateToAutocompleteItem( } } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 50, +} as const + +export function registerBrunchProductExtension( + pi: ExtensionAPI, + context: { graphMentionSource: GraphMentionSource }, +): void { + registerBrunchMentionAutocomplete(pi, context.graphMentionSource) +} + export default registerBrunchMentionAutocomplete diff --git a/src/tui-client/.pi/extensions/operational-mode.ts b/src/tui-client/.pi/extensions/operational-mode.ts index ef8c52ce..290b427d 100644 --- a/src/tui-client/.pi/extensions/operational-mode.ts +++ b/src/tui-client/.pi/extensions/operational-mode.ts @@ -602,4 +602,12 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { })) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 40, +} as const + +export const registerBrunchProductExtension = + registerBrunchOperationalModePolicy + export default registerBrunchOperationalModePolicy diff --git a/src/tui-client/.pi/extensions/session-lifecycle.ts b/src/tui-client/.pi/extensions/session-lifecycle.ts index 2b0d6d52..fd762b55 100644 --- a/src/tui-client/.pi/extensions/session-lifecycle.ts +++ b/src/tui-client/.pi/extensions/session-lifecycle.ts @@ -34,4 +34,22 @@ export function registerBrunchSessionBoundaryRefreshHandlers( }) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 10, +} as const + +export function registerBrunchProductExtension( + pi: ExtensionAPI, + context: { onSessionBoundary?: BrunchSessionBoundaryHandler }, +): void { + pi.on("session_start", async (_event, ctx) => { + await bindBrunchSessionBoundary( + ctx.sessionManager as SessionManager, + context.onSessionBoundary, + ) + }) + registerBrunchSessionBoundaryRefreshHandlers(pi, context.onSessionBoundary) +} + export default registerBrunchSessionBoundaryRefreshHandlers diff --git a/src/tui-client/.pi/extensions/structured-exchange/index.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts index 32d5e1a2..ea32fdb1 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/index.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/index.ts @@ -68,6 +68,15 @@ void presentReviewSetTool void presentCandidatesTool void requestReviewTool +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 70, +} as const + +export function registerBrunchProductExtension(pi: ExtensionAPI): void { + registerStructuredExchange(pi) +} + export default function registerStructuredExchange(pi: ExtensionAPI) { for (const tool of STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS) { pi.registerTool(tool) diff --git a/src/tui-client/.pi/extensions/workspace-dialog.ts b/src/tui-client/.pi/extensions/workspace-dialog.ts index 97f86d7f..c9e8f7eb 100644 --- a/src/tui-client/.pi/extensions/workspace-dialog.ts +++ b/src/tui-client/.pi/extensions/workspace-dialog.ts @@ -42,6 +42,18 @@ export function registerBrunchWorkspaceDialog( }) } +export const brunchExtensionMeta = { + productStatus: "ready", + loadOrder: 80, +} as const + +export function registerBrunchProductExtension( + pi: ExtensionAPI, + context: { options: BrunchSpecSessionPickerOptions }, +): void { + registerBrunchWorkspaceDialog(pi, context.options) +} + export default function brunchWorkspaceDialog(pi: ExtensionAPI): void { pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { description: "Open the Brunch spec/session picker", diff --git a/src/tui-client/pi-extension-shell.ts b/src/tui-client/pi-extension-shell.ts index 1c26d861..89398ee3 100644 --- a/src/tui-client/pi-extension-shell.ts +++ b/src/tui-client/pi-extension-shell.ts @@ -1,30 +1,17 @@ +import { access, readdir } from "node:fs/promises" +import { dirname, extname, join, relative, sep } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + import { - SessionManager, + type ExtensionAPI, type ExtensionFactory, } from "@earendil-works/pi-coding-agent" -import { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" -import { registerBrunchBranchPolicyHandlers } from "./.pi/extensions/command-policy.js" -import { - FIXTURE_GRAPH_MENTION_SOURCE, - registerBrunchMentionAutocomplete, - type GraphMentionSource, -} from "./.pi/extensions/mention-autocomplete.js" -import { registerBrunchOperationalModePolicy } from "./.pi/extensions/operational-mode.js" -import registerBrunchStructuredExchange from "./.pi/extensions/structured-exchange/index.js" -import { - renderBrunchChrome, - type BrunchChromeState, -} from "./.pi/extensions/chrome.js" -import { - bindBrunchSessionBoundary, - registerBrunchSessionBoundaryRefreshHandlers, - type BrunchSessionBoundaryHandler, -} from "./.pi/extensions/session-lifecycle.js" -import { - registerBrunchWorkspaceDialog, - type BrunchSpecSessionPickerOptions, -} from "./.pi/extensions/workspace-dialog.js" +import { type GraphMentionSource } from "./.pi/extensions/mention-autocomplete.js" +import { FIXTURE_GRAPH_MENTION_SOURCE } from "./.pi/extensions/mention-autocomplete.js" +import { type BrunchChromeState } from "./.pi/extensions/chrome.js" +import { type BrunchSessionBoundaryHandler } from "./.pi/extensions/session-lifecycle.js" +import { type BrunchSpecSessionPickerOptions } from "./.pi/extensions/workspace-dialog.js" export { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./.pi/extensions/command-policy.js" @@ -81,28 +68,137 @@ export interface BrunchPiExtensionShellOptions graphMentionSource?: GraphMentionSource } +export interface BrunchProductExtensionContext { + chrome: BrunchChromeState + onSessionBoundary?: BrunchSessionBoundaryHandler + options: BrunchPiExtensionShellOptions + graphMentionSource: GraphMentionSource +} + +export const BRUNCH_PRODUCT_EXTENSION_READY = "ready" as const + +export interface BrunchExtensionMeta { + productStatus: typeof BRUNCH_PRODUCT_EXTENSION_READY | "wip" | "dev-only" + loadOrder?: number +} + +export type BrunchProductExtensionRegistration = ( + pi: ExtensionAPI, + context: BrunchProductExtensionContext, +) => void | Promise<void> + +export interface BrunchProductExtensionEntry { + path: string + meta: BrunchExtensionMeta & { + productStatus: typeof BRUNCH_PRODUCT_EXTENSION_READY + } + registerProductExtension: BrunchProductExtensionRegistration +} + +interface BrunchExtensionModule { + brunchExtensionMeta?: BrunchExtensionMeta + registerBrunchProductExtension?: BrunchProductExtensionRegistration +} + +const EXTENSIONS_DIR = join( + dirname(fileURLToPath(import.meta.url)), + ".pi", + "extensions", +) + +export async function discoverBrunchProductExtensionEntries( + extensionsDir: string = EXTENSIONS_DIR, +): Promise<BrunchProductExtensionEntry[]> { + const entryFiles = await discoverExtensionEntryFiles(extensionsDir) + const entries = await Promise.all( + entryFiles.map(async (file) => { + const module = (await import( + pathToFileURL(file).href + )) as BrunchExtensionModule + const meta = module.brunchExtensionMeta + if (meta?.productStatus !== BRUNCH_PRODUCT_EXTENSION_READY) { + return undefined + } + if (module.registerBrunchProductExtension === undefined) { + throw new Error( + `Prod-ready Brunch extension ${file} must export registerBrunchProductExtension`, + ) + } + return { + path: normalizeExtensionPath(relative(extensionsDir, file)), + meta: { + ...meta, + productStatus: BRUNCH_PRODUCT_EXTENSION_READY, + }, + registerProductExtension: module.registerBrunchProductExtension, + } + }), + ) + return entries + .filter( + (entry): entry is BrunchProductExtensionEntry => entry !== undefined, + ) + .sort( + (left, right) => + (left.meta.loadOrder ?? 0) - (right.meta.loadOrder ?? 0) || + left.path.localeCompare(right.path), + ) +} + +async function discoverExtensionEntryFiles( + extensionsDir: string, +): Promise<string[]> { + const dirents = await readdir(extensionsDir, { withFileTypes: true }) + const files: string[] = [] + for (const dirent of dirents) { + const file = join(extensionsDir, dirent.name) + if (dirent.isFile() && isExtensionEntrypointFile(dirent.name)) { + files.push(file) + } + if (dirent.isDirectory()) { + for (const extension of [".ts", ".js"]) { + const indexFile = join(file, `index${extension}`) + if (await fileExists(indexFile)) files.push(indexFile) + } + } + } + return files +} + +function isExtensionEntrypointFile(file: string): boolean { + const extension = extname(file) + return (extension === ".ts" || extension === ".js") && !file.endsWith(".d.ts") +} + +async function fileExists(file: string): Promise<boolean> { + try { + await access(file) + return true + } catch { + return false + } +} + +function normalizeExtensionPath(path: string): string { + return path.split(sep).join("/") +} + export function createBrunchPiExtensionShell( chrome: BrunchChromeState, onSessionBoundary: BrunchSessionBoundaryHandler | undefined, options: BrunchPiExtensionShellOptions, ): ExtensionFactory { - return (pi) => { - pi.on("session_start", async (_event, ctx) => { - await bindBrunchSessionBoundary( - ctx.sessionManager as SessionManager, - onSessionBoundary, - ) - renderBrunchChrome(ctx.ui, chrome) - }) - registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) - registerBrunchBranchPolicyHandlers(pi) - registerBrunchOperationalModePolicy(pi) - registerBrunchMentionAutocomplete( - pi, - options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE, - ) - registerBrunchAlternatives(pi) - registerBrunchStructuredExchange(pi) - registerBrunchWorkspaceDialog(pi, options) + return async (pi) => { + const context: BrunchProductExtensionContext = { + chrome, + ...(onSessionBoundary === undefined ? {} : { onSessionBoundary }), + options, + graphMentionSource: + options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE, + } + const entries = await discoverBrunchProductExtensionEntries() + for (const entry of entries) { + await entry.registerProductExtension(pi, context) + } } } From ae38284921200dac1e5392c22180c42d73a15b11 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 13:19:39 +0200 Subject: [PATCH 138/164] Add public RPC parity probe artifacts --- .../2026-05-29-public-rpc-parity/report.json | 36 ++ .../session.jsonl | 22 ++ .../transcript.md | 312 ++++++++++++++++++ memory/PLAN.md | 4 +- memory/SPEC.md | 8 +- src/probes/public-rpc-parity-proof.test.ts | 58 ++++ src/probes/public-rpc-parity-proof.ts | 76 ++++- 7 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 .fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json create mode 100644 .fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl create mode 100644 .fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json new file mode 100644 index 00000000..42adb322 --- /dev/null +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json @@ -0,0 +1,36 @@ +{ + "mission": "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", + "evaluationFocus": "Ten-turn tuple transcript/projection parity without raw Pi RPC or legacy prompt/response entries.", + "maxTurnBudget": 10, + "completedTurns": 10, + "friction": [], + "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu", + "specId": "spec-3107835e-7dde-4a30-9f26-7f49d52d31b3", + "sessionId": "019e7373-6b3d-7bdb-bccb-fbf5d974efea", + "toolCoverage": [ + "present_options", + "present_question", + "request_answer", + "request_choice", + "request_choices" + ], + "exchangeIds": [ + "deterministic-grounding-choice-1", + "deterministic-grounding-text-2", + "deterministic-grounding-multi-3", + "deterministic-grounding-choice-4", + "deterministic-grounding-text-5", + "deterministic-grounding-multi-6", + "deterministic-grounding-choice-7", + "deterministic-grounding-text-8", + "deterministic-grounding-multi-9", + "deterministic-grounding-choice-10" + ], + "transcriptDisplayRows": 20, + "artifacts": { + "runDir": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity", + "sessionJsonl": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl", + "transcriptMarkdown": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md", + "reportJson": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json" + } +} diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl new file mode 100644 index 00000000..a5200a3c --- /dev/null +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":3,"id":"019e7373-6b3d-7bdb-bccb-fbf5d974efea","timestamp":"2026-05-29T11:16:44.477Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu"} +{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e7373-6b3d-7bdb-bccb-fbf5d974efea","specId":"spec-3107835e-7dde-4a30-9f26-7f49d52d31b3","specTitle":"Public RPC parity spec"},"id":"a228b29f","parentId":null,"timestamp":"2026-05-29T11:16:44.477Z"} +{"type":"message","id":"9710b04c","parentId":"a228b29f","timestamp":"2026-05-29T11:16:44.479Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-1:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"f430065c","parentId":"9710b04c","timestamp":"2026-05-29T11:16:44.481Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-1:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"65fbedfe","parentId":"f430065c","timestamp":"2026-05-29T11:16:44.482Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-2:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"47f540d7","parentId":"65fbedfe","timestamp":"2026-05-29T11:16:44.484Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-2"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-2:request_answer","answer":"Answer for deterministic-grounding-text-2"}}} +{"type":"message","id":"ef7b7b45","parentId":"47f540d7","timestamp":"2026-05-29T11:16:44.484Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-3:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"aae98b56","parentId":"ef7b7b45","timestamp":"2026-05-29T11:16:44.486Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-3:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"abcf453f","parentId":"aae98b56","timestamp":"2026-05-29T11:16:44.487Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-4:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"26ec8bd8","parentId":"abcf453f","timestamp":"2026-05-29T11:16:44.488Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-4:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"3ad0dfe7","parentId":"26ec8bd8","timestamp":"2026-05-29T11:16:44.489Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-5:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"28622b16","parentId":"3ad0dfe7","timestamp":"2026-05-29T11:16:44.491Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-5"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-5:request_answer","answer":"Answer for deterministic-grounding-text-5"}}} +{"type":"message","id":"8db48b41","parentId":"28622b16","timestamp":"2026-05-29T11:16:44.492Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-6:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"ec32f5c2","parentId":"8db48b41","timestamp":"2026-05-29T11:16:44.493Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-6:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"0543102b","parentId":"ec32f5c2","timestamp":"2026-05-29T11:16:44.494Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-7:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"8f384d50","parentId":"0543102b","timestamp":"2026-05-29T11:16:44.497Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-7:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"7352541f","parentId":"8f384d50","timestamp":"2026-05-29T11:16:44.498Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-8:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"e0bbbc2c","parentId":"7352541f","timestamp":"2026-05-29T11:16:44.499Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-8"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-8:request_answer","answer":"Answer for deterministic-grounding-text-8"}}} +{"type":"message","id":"7ded84d8","parentId":"e0bbbc2c","timestamp":"2026-05-29T11:16:44.500Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-9:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"e3fb215e","parentId":"7ded84d8","timestamp":"2026-05-29T11:16:44.502Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-9:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"e11c63cc","parentId":"e3fb215e","timestamp":"2026-05-29T11:16:44.503Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-10:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"c6da683b","parentId":"e11c63cc","timestamp":"2026-05-29T11:16:44.505Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-10:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md new file mode 100644 index 00000000..bb9559ab --- /dev/null +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md @@ -0,0 +1,312 @@ +# Transcript — session.jsonl + +## Session + +- session: 019e7373-6b3d-7bdb-bccb-fbf5d974efea +- cwd: /var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu + +## Session binding + +```json +{ + "schemaVersion": 1, + "sessionId": "019e7373-6b3d-7bdb-bccb-fbf5d974efea", + "specId": "spec-3107835e-7dde-4a30-9f26-7f49d52d31b3", + "specTitle": "Public RPC parity spec" +} +``` + +## Exchange deterministic-grounding-choice-1 — prompt (present_options → request_choice) + +## Is this a new product or feature from scratch? + +Choose the best starting context so later elicitation can ask useful follow-ups. + +### 1. Start a new spec workspace from a blank slate. + +**Rationale:** This keeps the parity run focused on initial grounding. + +<!-- option-id: new-from-scratch --> + +### 2. Ground the spec in existing implementation constraints. + +**Rationale:** Existing code changes what the elicitor should inspect next. + +<!-- option-id: existing-codebase --> + +### 3. Connect this work to a prior specification thread. + +**Rationale:** Continuity matters when prior graph intent exists. + +<!-- option-id: relates-to-existing-spec --> + +## Exchange deterministic-grounding-choice-1 — response (request_choice, answered) + +### Response + +- Yes — this is new from scratch + +Comment: + +> Chosen by deterministic public-RPC proof. + +## Exchange deterministic-grounding-text-2 — prompt (present_question → request_answer) + +## What are we specifying? + +This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. + +## Exchange deterministic-grounding-text-2 — response (request_answer, answered) + +### Response + +Answer for deterministic-grounding-text-2 + +## Exchange deterministic-grounding-multi-3 — prompt (present_options → request_choices) + +## Which proof qualities matter for this parity run? + +Select all qualities the deterministic agent-as-user proof should preserve. + +### 1. Pi JSONL keeps every present/request tuple recoverable. + +**Rationale:** The transcript is the durable source of truth. + +<!-- option-id: transcript --> + +### 2. Brunch projections preserve semantic option artifacts. + +**Rationale:** Public clients depend on projected structured exchange data. + +<!-- option-id: projection --> + +### 3. Another proof quality should be captured in the note. + +**Rationale:** Other requires a comment so the transcript stays explicit. + +<!-- option-id: other --> + +### 4. No additional proof qualities matter for this run. + +**Rationale:** None requires a comment to avoid silent dismissal. + +<!-- option-id: none --> + +## Exchange deterministic-grounding-multi-3 — response (request_choices, answered) + +### Response + +- Transcript fidelity +- Other + +Comment: + +> Other: keep a compact blocker/friction report. + +## Exchange deterministic-grounding-choice-4 — prompt (present_options → request_choice) + +## Is this a new product or feature from scratch? + +Choose the best starting context so later elicitation can ask useful follow-ups. + +### 1. Start a new spec workspace from a blank slate. + +**Rationale:** This keeps the parity run focused on initial grounding. + +<!-- option-id: new-from-scratch --> + +### 2. Ground the spec in existing implementation constraints. + +**Rationale:** Existing code changes what the elicitor should inspect next. + +<!-- option-id: existing-codebase --> + +### 3. Connect this work to a prior specification thread. + +**Rationale:** Continuity matters when prior graph intent exists. + +<!-- option-id: relates-to-existing-spec --> + +## Exchange deterministic-grounding-choice-4 — response (request_choice, answered) + +### Response + +- Yes — this is new from scratch + +Comment: + +> Chosen by deterministic public-RPC proof. + +## Exchange deterministic-grounding-text-5 — prompt (present_question → request_answer) + +## What are we specifying? + +This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. + +## Exchange deterministic-grounding-text-5 — response (request_answer, answered) + +### Response + +Answer for deterministic-grounding-text-5 + +## Exchange deterministic-grounding-multi-6 — prompt (present_options → request_choices) + +## Which proof qualities matter for this parity run? + +Select all qualities the deterministic agent-as-user proof should preserve. + +### 1. Pi JSONL keeps every present/request tuple recoverable. + +**Rationale:** The transcript is the durable source of truth. + +<!-- option-id: transcript --> + +### 2. Brunch projections preserve semantic option artifacts. + +**Rationale:** Public clients depend on projected structured exchange data. + +<!-- option-id: projection --> + +### 3. Another proof quality should be captured in the note. + +**Rationale:** Other requires a comment so the transcript stays explicit. + +<!-- option-id: other --> + +### 4. No additional proof qualities matter for this run. + +**Rationale:** None requires a comment to avoid silent dismissal. + +<!-- option-id: none --> + +## Exchange deterministic-grounding-multi-6 — response (request_choices, answered) + +### Response + +- Transcript fidelity +- Other + +Comment: + +> Other: keep a compact blocker/friction report. + +## Exchange deterministic-grounding-choice-7 — prompt (present_options → request_choice) + +## Is this a new product or feature from scratch? + +Choose the best starting context so later elicitation can ask useful follow-ups. + +### 1. Start a new spec workspace from a blank slate. + +**Rationale:** This keeps the parity run focused on initial grounding. + +<!-- option-id: new-from-scratch --> + +### 2. Ground the spec in existing implementation constraints. + +**Rationale:** Existing code changes what the elicitor should inspect next. + +<!-- option-id: existing-codebase --> + +### 3. Connect this work to a prior specification thread. + +**Rationale:** Continuity matters when prior graph intent exists. + +<!-- option-id: relates-to-existing-spec --> + +## Exchange deterministic-grounding-choice-7 — response (request_choice, answered) + +### Response + +- Yes — this is new from scratch + +Comment: + +> Chosen by deterministic public-RPC proof. + +## Exchange deterministic-grounding-text-8 — prompt (present_question → request_answer) + +## What are we specifying? + +This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. + +## Exchange deterministic-grounding-text-8 — response (request_answer, answered) + +### Response + +Answer for deterministic-grounding-text-8 + +## Exchange deterministic-grounding-multi-9 — prompt (present_options → request_choices) + +## Which proof qualities matter for this parity run? + +Select all qualities the deterministic agent-as-user proof should preserve. + +### 1. Pi JSONL keeps every present/request tuple recoverable. + +**Rationale:** The transcript is the durable source of truth. + +<!-- option-id: transcript --> + +### 2. Brunch projections preserve semantic option artifacts. + +**Rationale:** Public clients depend on projected structured exchange data. + +<!-- option-id: projection --> + +### 3. Another proof quality should be captured in the note. + +**Rationale:** Other requires a comment so the transcript stays explicit. + +<!-- option-id: other --> + +### 4. No additional proof qualities matter for this run. + +**Rationale:** None requires a comment to avoid silent dismissal. + +<!-- option-id: none --> + +## Exchange deterministic-grounding-multi-9 — response (request_choices, answered) + +### Response + +- Transcript fidelity +- Other + +Comment: + +> Other: keep a compact blocker/friction report. + +## Exchange deterministic-grounding-choice-10 — prompt (present_options → request_choice) + +## Is this a new product or feature from scratch? + +Choose the best starting context so later elicitation can ask useful follow-ups. + +### 1. Start a new spec workspace from a blank slate. + +**Rationale:** This keeps the parity run focused on initial grounding. + +<!-- option-id: new-from-scratch --> + +### 2. Ground the spec in existing implementation constraints. + +**Rationale:** Existing code changes what the elicitor should inspect next. + +<!-- option-id: existing-codebase --> + +### 3. Connect this work to a prior specification thread. + +**Rationale:** Continuity matters when prior graph intent exists. + +<!-- option-id: relates-to-existing-spec --> + +## Exchange deterministic-grounding-choice-10 — response (request_choice, answered) + +### Response + +- Yes — this is new from scratch + +Comment: + +> Chosen by deterministic public-RPC proof. diff --git a/memory/PLAN.md b/memory/PLAN.md index fe7245fb..e4667699 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -217,7 +217,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges, and parity hardening for distinct exchange ids, terminal non-answered statuses, and option content/rationale have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges, parity hardening for distinct exchange ids, terminal non-answered statuses, and option content/rationale, and committed `.fixtures` public-RPC parity probe artifacts have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC elicitation session parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers ten distinct structured exchanges through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. The remaining active acceptance is that web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state, and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, and preserves option `content` plus optional `rationale` through pending/proof projections. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope whether to harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested) before returning to web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 88e67f8c..11ec9137 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -415,7 +415,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Framing-as** | Orthogonal modality classifying a node's product role (e.g. `problem`, `persona`, `non_goal`) within an allowed matrix. | | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | | **Brief** | A short curated product brief in `.brunch-fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | -| **Capture / Run / Fixture** | A captured agent-as-user run produces a `.jsonl` transcript, `.graph.json`, `.coherence.json`, and `.meta.json` bundle under `.brunch-fixtures/<brief-id>/<run-id>/`. | +| **Capture / Run / Fixture** | A captured agent-as-user run produces durable review artifacts. Brief-library captures use `.brunch-fixtures/<brief-id>/<run-id>/` with `.jsonl`, deferred `.graph.json` / `.coherence.json`, and `.meta.json` artifacts; product probe-oracle captures may use `.fixtures/runs/<probe-id>/<run-id>/` with `session.jsonl`, `transcript.md`, and `report.json` when the review object is a probe run rather than a curated brief. | | **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent roles (`elicitor` / `reviewer` / `reconciler` / deferred observer-auditor roles) remain orthogonal. | | **Single-exchange elicitation flow** | A prompt/answer exchange such as step-by-step questioning or contrastive disambiguation. The elicitor captures high-confidence extractive content synchronously post-exchange; low-confidence implications stay in preface/question material. | | **Batch-proposal flow** | A proposal/review flow with structured entity-draft payloads in `brunch.review_set_proposal` entries. Durable graph changes land only through review-set approval. | @@ -480,7 +480,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo - **Gate before commit:** `npm run verify`. All steps must pass; no override. - **Failure protocol:** stop on first failure; the failure becomes the must-fix task; re-run the stack from step 1; only proceed when all checks pass. - **Frontier completion:** manual smoke can prove presentation life, but any durable product claim must also have an artifact/query oracle, property/round-trip test, contract test, or fixture assertion tied to the canonical store or projection handler that owns the fact. -- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.brunch-fixtures/`. +- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.brunch-fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. ### Oracle Strategy by Loop Tier @@ -495,7 +495,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, and compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, rendered `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -548,7 +548,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` ### Design Notes - **Deterministic before generative.** M1 should prefer a deterministic or tightly scripted user-agent path for the first captured run before relying on LLM persona variance. Generative/adversarial probes come after the transcript and fixture substrate is trusted. M1 scripted captures prove the transport/projection/fixture substrate on its current terms; they do not settle the final elicitation interaction logic, knowledge flow, or prompt/response expectation model. -- **Public RPC parity before LLM quality.** FE-744's next product proof should use a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, and JSONL/projection parity. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. +- **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. - **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index 67eddacb..9b2ddbc0 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + import { describe, expect, it } from "vitest" import { runPublicRpcParityProof } from "./public-rpc-parity-proof.js" @@ -28,4 +32,58 @@ describe("public Brunch RPC structured-exchange parity proof", () => { expect(new Set(report.exchangeIds).size).toBe(10) expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(20) }) + + it("writes a reviewable artifact bundle when given a fixture root", async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "brunch-fixtures-")) + + const report = await runPublicRpcParityProof({ + fixtureRoot, + runId: "artifact-test", + }) + + const artifacts = report.artifacts + expect(artifacts).toEqual({ + runDir: join(fixtureRoot, "runs", "public-rpc-parity", "artifact-test"), + sessionJsonl: join( + fixtureRoot, + "runs", + "public-rpc-parity", + "artifact-test", + "session.jsonl", + ), + transcriptMarkdown: join( + fixtureRoot, + "runs", + "public-rpc-parity", + "artifact-test", + "transcript.md", + ), + reportJson: join( + fixtureRoot, + "runs", + "public-rpc-parity", + "artifact-test", + "report.json", + ), + }) + if (artifacts === undefined) throw new Error("Expected artifact paths") + + const sessionJsonl = await readFile(artifacts.sessionJsonl, "utf8") + const transcript = await readFile(artifacts.transcriptMarkdown, "utf8") + const persistedReport = JSON.parse( + await readFile(artifacts.reportJson, "utf8"), + ) as typeof report + + expect(sessionJsonl).toContain('"toolName":"present_options"') + expect(transcript).toContain("# Transcript — session.jsonl") + expect(transcript).toContain("## Exchange") + expect(transcript).toContain("— prompt (present_") + expect(transcript).toContain("— response (request_") + expect(persistedReport).toMatchObject({ + mission: report.mission, + completedTurns: 10, + exchangeIds: report.exchangeIds, + artifacts: report.artifacts, + }) + }) }) diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 521b0a27..51fb5a6e 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { createRpcHandlers } from "../rpc/handlers.js" +import { renderSessionTranscript } from "../session-transcript.js" import { createWorkspaceSessionCoordinator } from "../workspace-session-coordinator.js" interface JsonRpcSuccess<T> { @@ -53,6 +54,18 @@ interface PendingResult { exchange: PendingExchange } +export interface PublicRpcParityProofArtifacts { + runDir: string + sessionJsonl: string + transcriptMarkdown: string + reportJson: string +} + +export interface PublicRpcParityProofOptions { + fixtureRoot?: string + runId?: string +} + export interface PublicRpcParityProofReport { mission: string evaluationFocus: string @@ -65,6 +78,7 @@ export interface PublicRpcParityProofReport { toolCoverage: string[] exchangeIds: string[] transcriptDisplayRows: number + artifacts?: PublicRpcParityProofArtifacts } function success<T>(response: unknown): T { @@ -158,7 +172,9 @@ function responseFor(exchange: PendingExchange): ProofResponse { } } -export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofReport> { +export async function runPublicRpcParityProof( + options: PublicRpcParityProofOptions = {}, +): Promise<PublicRpcParityProofReport> { const cwd = await mkdtemp(join(tmpdir(), "brunch-public-rpc-parity-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) const handlers = createRpcHandlers({ coordinator, cwd }) @@ -358,7 +374,7 @@ export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofRep } } - return { + const report: PublicRpcParityProofReport = { mission: "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", evaluationFocus: @@ -373,4 +389,58 @@ export async function runPublicRpcParityProof(): Promise<PublicRpcParityProofRep exchangeIds, transcriptDisplayRows: display.rows.length, } + + if (options.fixtureRoot !== undefined) { + report.artifacts = await writeProofArtifacts({ + fixtureRoot: options.fixtureRoot, + runId: options.runId ?? defaultRunId(), + sessionText, + report, + }) + } + + return report +} + +async function writeProofArtifacts(options: { + fixtureRoot: string + runId: string + sessionText: string + report: PublicRpcParityProofReport +}): Promise<PublicRpcParityProofArtifacts> { + const runDir = join( + options.fixtureRoot, + "runs", + "public-rpc-parity", + options.runId, + ) + const artifacts: PublicRpcParityProofArtifacts = { + runDir, + sessionJsonl: join(runDir, "session.jsonl"), + transcriptMarkdown: join(runDir, "transcript.md"), + reportJson: join(runDir, "report.json"), + } + const persistedReport: PublicRpcParityProofReport = { + ...options.report, + artifacts, + } + + await mkdir(runDir, { recursive: true }) + await writeFile(artifacts.sessionJsonl, options.sessionText, "utf8") + await writeFile( + artifacts.transcriptMarkdown, + renderSessionTranscript(options.sessionText, { title: "session.jsonl" }), + "utf8", + ) + await writeFile( + artifacts.reportJson, + `${JSON.stringify(persistedReport, null, 2)}\n`, + "utf8", + ) + + return artifacts +} + +function defaultRunId(): string { + return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-") } From 33a512e432f6955802af8083ab79cca051e83a0e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 13:49:00 +0200 Subject: [PATCH 139/164] Harden parity artifact witness --- memory/CARDS.md | 155 +++++++++++++++++++++ src/probes/public-rpc-parity-proof.test.ts | 8 ++ 2 files changed, 163 insertions(+) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..fd96362b --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,155 @@ +# Scope Cards — FE-744 public RPC parity artifact hardening + +> Prepared by `ln-scope` for build in a separate thread. These cards are scoped slices inside the existing `pi-ui-extension-patterns` frontier / FE-744 branch. Do not create new Linear issues or Graphite branches for these cards by default. + +## Orientation + +- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch RPC parity proof and probe-oracle artifact layer. +- **Relevant frontier item:** `pi-ui-extension-patterns` remains the branch/tracker boundary; these are follow-through hardening slices after committed `.fixtures` public-RPC parity artifacts landed in `baa08cbe`. +- **Canonicalized handoff state:** `HANDOFF.md` has been retired; durable decisions now live in `memory/SPEC.md` / `memory/PLAN.md`. Future `capture_*` ANALYSIS work is specified at the carrier/visibility level only and requires a separate `ln-design` pass before implementation. +- **Main open risk:** the new review bundle is human-legible, but its report/test witness is still too thin to prove every completed exchange is represented in both source JSONL and transcript Markdown. + +## Queue discipline + +- Build cards in order unless implementation reveals a reason to stop. +- Each card should be committed independently after `npm run verify` passes. +- Ordinary test runs must not mutate committed `.fixtures` outputs; committed seed bundles may be regenerated only by an explicit artifact-writing path. +- Canonical SPEC/PLAN reconciliation should be a no-op for these cards unless implementation changes the already-recorded `.fixtures` bundle shape, Brunch-semantic transcript default, or `capture_*` deferral boundary. + +## Card 1 — Strengthen parity artifact witness + +**Status:** done +**Weight:** light scope card + +### Objective + +The public RPC parity artifact test proves that every completed exchange id in the report is present in both the persisted session JSONL and rendered transcript Markdown. + +### Acceptance Criteria + +✓ `src/probes/public-rpc-parity-proof.test.ts` asserts that the persisted `report.json` exchange ids exactly match the in-memory report exchange ids and contain ten unique ids. +✓ The artifact-writing test asserts that every persisted exchange id appears in `session.jsonl`. +✓ The artifact-writing test asserts that every persisted exchange id appears in `transcript.md`. +✓ Ordinary `runPublicRpcParityProof()` calls without `fixtureRoot` still do not write `.fixtures` artifacts. + +### Verification Approach + +- Inner: `npm run test -- src/probes/public-rpc-parity-proof.test.ts` — proves the artifact bundle is witnessed through the public probe boundary. +- Inner: `npm run fix` after edits. +- Gate: `npm run verify` before committing. + +### Cross-cutting obligations + +- Preserve I32-L: public RPC elicitation driving must not require raw Pi RPC. +- Preserve I23-L: structured-exchange transcript evidence comes from durable `toolResult.content` / `toolResult.details`. +- Preserve explicit artifact persistence: tests should write only to a temp fixture root unless a builder intentionally regenerates committed `.fixtures` output. + +### Assumption dependency + +Depends on: A23-L — already validated by the public RPC parity proof; this card only strengthens the artifact oracle over that proof. + +### Promotion checklist + +- [ ] Changes a requirement +- [ ] Creates/retires/invalidates an assumption +- [ ] Depends on an unvalidated high-impact assumption +- [ ] Makes/reverses a non-trivial design decision +- [ ] Establishes a new seam-level invariant +- [ ] Changes a frontier-level obligation or verification architecture layer +- [ ] Crosses more than two major seams +- [ ] First touch in an unfamiliar seam +- [ ] Cannot name containing seam/current rationale + +## Card 2 — Add a self-describing parity report envelope + +**Status:** next +**Weight:** light scope card + +### Objective + +The public RPC parity `report.json` identifies its schema, probe id, run id, and generation timestamp without relying on directory layout. + +### Acceptance Criteria + +✓ `PublicRpcParityProofReport` includes an explicit `schemaVersion: 1`. +✓ `PublicRpcParityProofReport` includes `probeId: "public-rpc-parity"`. +✓ `PublicRpcParityProofReport` includes the `runId` used for artifact output. +✓ `PublicRpcParityProofReport` includes `generatedAt` as an ISO timestamp. +✓ The artifact-writing test proves `report.json.artifacts.runDir` ends in `/runs/public-rpc-parity/<report.runId>` and that the report's `probeId` matches the artifact path's probe segment. +✓ The committed seed bundle at `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` is regenerated or edited so `report.json` matches the new envelope. + +### Verification Approach + +- Inner: `npm run test -- src/probes/public-rpc-parity-proof.test.ts` — proves the report envelope and path coherence. +- Middle: inspect `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json` — confirms the committed review artifact is self-describing. +- Inner: `npm run fix` after edits. +- Gate: `npm run verify` before committing. + +### Cross-cutting obligations + +- Keep `.fixtures/runs/<probe-id>/<run-id>/` as the probe-oracle review-bundle shape documented in SPEC. +- Do not change the public RPC parity behavior while changing only the report envelope. +- Do not mutate committed `.fixtures` during ordinary tests; seed regeneration must be an explicit builder action. + +### Assumption dependency + +Depends on: A5-L and A23-L — the artifact envelope improves fixture-driver/probe quality over the already-validated parity path; it does not introduce a new substrate assumption. + +### Promotion checklist + +- [ ] Changes a requirement +- [ ] Creates/retires/invalidates an assumption +- [ ] Depends on an unvalidated high-impact assumption +- [ ] Makes/reverses a non-trivial design decision +- [ ] Establishes a new seam-level invariant +- [ ] Changes a frontier-level obligation or verification architecture layer +- [ ] Crosses more than two major seams +- [ ] First touch in an unfamiliar seam +- [ ] Cannot name containing seam/current rationale + +## Card 3 — Make transcript rendering Brunch-semantic by default + +**Status:** queued +**Weight:** light scope card + +### Objective + +The session transcript renderer's default output omits generic non-Brunch tool results while retaining Brunch semantic transcript evidence for the currently implemented structured-exchange families. + +### Acceptance Criteria + +✓ `src/session-transcript.test.ts` covers a JSONL session containing both a generic tool result and structured-exchange `present_*` / `request_*` tool results. +✓ `renderSessionTranscript(...)` default output includes the structured-exchange prompt/response sections. +✓ `renderSessionTranscript(...)` default output omits the generic tool result heading/body. +✓ `runPublicRpcParityProof({ fixtureRoot, runId })` still writes a transcript containing all ten exchange ids from the report. +✓ This card does not implement `capture_analysis`; it leaves `capture_*` ANALYSIS rendering for the separate design pass unless a minimal classifier falls out naturally without choosing the details schema. + +### Verification Approach + +- Inner: `npm run test -- src/session-transcript.test.ts src/probes/public-rpc-parity-proof.test.ts` — proves the renderer default and parity artifact integration. +- Middle: inspect generated `transcript.md` from a temp artifact-writing parity run if tests fail to make the behavior obvious. +- Inner: `npm run fix` after edits. +- Gate: `npm run verify` before committing. + +### Cross-cutting obligations + +- Preserve I23-L: durable semantic display comes from `toolResult.content` / `toolResult.details`, not `renderCall` or live UI state. +- Preserve I33-L / D50-L at the boundary: `capture_*` is transcript evidence only and should be included in Brunch-semantic transcripts once designed, but this card must not invent the `capture_analysis` details schema or shared component API. +- Keep the transcript renderer aligned with the human-review oracle: Brunch semantic transcript evidence should be visible; unrelated generic tool noise should not obscure it. +- Defer raw/debug transcript mode unless implementation shows it is cheaper to preserve during this slice; if raw mode is added, it must be explicit and covered by tests. + +### Assumption dependency + +Depends on: A23-L — the implemented structured-exchange tool families and parity transcript are already validated; this card narrows the default human transcript view over that known substrate. It references I33-L only as a deferral guard, not as an implementation dependency. + +### Promotion checklist + +- [ ] Changes a requirement +- [ ] Creates/retires/invalidates an assumption +- [ ] Depends on an unvalidated high-impact assumption +- [ ] Makes/reverses a non-trivial design decision +- [ ] Establishes a new seam-level invariant +- [ ] Changes a frontier-level obligation or verification architecture layer +- [ ] Crosses more than two major seams +- [ ] First touch in an unfamiliar seam +- [ ] Cannot name containing seam/current rationale diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index 9b2ddbc0..69716671 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -30,6 +30,7 @@ describe("public Brunch RPC structured-exchange parity proof", () => { ]) expect(report.exchangeIds).toHaveLength(10) expect(new Set(report.exchangeIds).size).toBe(10) + expect(report.artifacts).toBeUndefined() expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(20) }) @@ -85,5 +86,12 @@ describe("public Brunch RPC structured-exchange parity proof", () => { exchangeIds: report.exchangeIds, artifacts: report.artifacts, }) + expect(persistedReport.exchangeIds).toEqual(report.exchangeIds) + expect(persistedReport.exchangeIds).toHaveLength(10) + expect(new Set(persistedReport.exchangeIds).size).toBe(10) + for (const exchangeId of persistedReport.exchangeIds) { + expect(sessionJsonl).toContain(exchangeId) + expect(transcript).toContain(exchangeId) + } }) }) From 89ea09c7fda2da3135d8cd2b66c4a7a86739c513 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 13:50:16 +0200 Subject: [PATCH 140/164] Add parity report envelope --- .../2026-05-29-public-rpc-parity/report.json | 10 +++-- .../session.jsonl | 44 +++++++++---------- .../transcript.md | 8 ++-- memory/CARDS.md | 4 +- src/probes/public-rpc-parity-proof.test.ts | 25 ++++++++--- src/probes/public-rpc-parity-proof.ts | 12 ++++- 6 files changed, 66 insertions(+), 37 deletions(-) diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json index 42adb322..73ee0465 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json @@ -1,12 +1,16 @@ { + "schemaVersion": 1, + "probeId": "public-rpc-parity", + "runId": "2026-05-29-public-rpc-parity", + "generatedAt": "2026-05-29T11:49:37.655Z", "mission": "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", "evaluationFocus": "Ten-turn tuple transcript/projection parity without raw Pi RPC or legacy prompt/response entries.", "maxTurnBudget": 10, "completedTurns": 10, "friction": [], - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu", - "specId": "spec-3107835e-7dde-4a30-9f26-7f49d52d31b3", - "sessionId": "019e7373-6b3d-7bdb-bccb-fbf5d974efea", + "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc", + "specId": "spec-9a7e0a75-0932-4caa-a848-285e476dcb85", + "sessionId": "019e7391-86fb-78bd-a3b9-e54b48f316af", "toolCoverage": [ "present_options", "present_question", diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl index a5200a3c..377738bb 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl @@ -1,22 +1,22 @@ -{"type":"session","version":3,"id":"019e7373-6b3d-7bdb-bccb-fbf5d974efea","timestamp":"2026-05-29T11:16:44.477Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu"} -{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e7373-6b3d-7bdb-bccb-fbf5d974efea","specId":"spec-3107835e-7dde-4a30-9f26-7f49d52d31b3","specTitle":"Public RPC parity spec"},"id":"a228b29f","parentId":null,"timestamp":"2026-05-29T11:16:44.477Z"} -{"type":"message","id":"9710b04c","parentId":"a228b29f","timestamp":"2026-05-29T11:16:44.479Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-1:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"f430065c","parentId":"9710b04c","timestamp":"2026-05-29T11:16:44.481Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-1:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"65fbedfe","parentId":"f430065c","timestamp":"2026-05-29T11:16:44.482Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-2:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"47f540d7","parentId":"65fbedfe","timestamp":"2026-05-29T11:16:44.484Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-2"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-2:request_answer","answer":"Answer for deterministic-grounding-text-2"}}} -{"type":"message","id":"ef7b7b45","parentId":"47f540d7","timestamp":"2026-05-29T11:16:44.484Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-3:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"aae98b56","parentId":"ef7b7b45","timestamp":"2026-05-29T11:16:44.486Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-3:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"abcf453f","parentId":"aae98b56","timestamp":"2026-05-29T11:16:44.487Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-4:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"26ec8bd8","parentId":"abcf453f","timestamp":"2026-05-29T11:16:44.488Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-4:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"3ad0dfe7","parentId":"26ec8bd8","timestamp":"2026-05-29T11:16:44.489Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-5:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"28622b16","parentId":"3ad0dfe7","timestamp":"2026-05-29T11:16:44.491Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-5"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-5:request_answer","answer":"Answer for deterministic-grounding-text-5"}}} -{"type":"message","id":"8db48b41","parentId":"28622b16","timestamp":"2026-05-29T11:16:44.492Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-6:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"ec32f5c2","parentId":"8db48b41","timestamp":"2026-05-29T11:16:44.493Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-6:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"0543102b","parentId":"ec32f5c2","timestamp":"2026-05-29T11:16:44.494Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-7:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"8f384d50","parentId":"0543102b","timestamp":"2026-05-29T11:16:44.497Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-7:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"7352541f","parentId":"8f384d50","timestamp":"2026-05-29T11:16:44.498Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-8:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"e0bbbc2c","parentId":"7352541f","timestamp":"2026-05-29T11:16:44.499Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-8"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-8:request_answer","answer":"Answer for deterministic-grounding-text-8"}}} -{"type":"message","id":"7ded84d8","parentId":"e0bbbc2c","timestamp":"2026-05-29T11:16:44.500Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-9:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"e3fb215e","parentId":"7ded84d8","timestamp":"2026-05-29T11:16:44.502Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-9:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"e11c63cc","parentId":"e3fb215e","timestamp":"2026-05-29T11:16:44.503Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-10:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"c6da683b","parentId":"e11c63cc","timestamp":"2026-05-29T11:16:44.505Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-10:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"session","version":3,"id":"019e7391-86fb-78bd-a3b9-e54b48f316af","timestamp":"2026-05-29T11:49:37.659Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc"} +{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e7391-86fb-78bd-a3b9-e54b48f316af","specId":"spec-9a7e0a75-0932-4caa-a848-285e476dcb85","specTitle":"Public RPC parity spec"},"id":"f645d5b2","parentId":null,"timestamp":"2026-05-29T11:49:37.659Z"} +{"type":"message","id":"fd1ba453","parentId":"f645d5b2","timestamp":"2026-05-29T11:49:37.661Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-1:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"c66de118","parentId":"fd1ba453","timestamp":"2026-05-29T11:49:37.662Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-1:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"5fef45b3","parentId":"c66de118","timestamp":"2026-05-29T11:49:37.663Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-2:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"7d034ba2","parentId":"5fef45b3","timestamp":"2026-05-29T11:49:37.664Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-2"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-2:request_answer","answer":"Answer for deterministic-grounding-text-2"}}} +{"type":"message","id":"1d53ebd5","parentId":"7d034ba2","timestamp":"2026-05-29T11:49:37.665Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-3:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"13315b5e","parentId":"1d53ebd5","timestamp":"2026-05-29T11:49:37.666Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-3:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"c6b177a4","parentId":"13315b5e","timestamp":"2026-05-29T11:49:37.667Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-4:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"e34d52f8","parentId":"c6b177a4","timestamp":"2026-05-29T11:49:37.668Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-4:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"2c626019","parentId":"e34d52f8","timestamp":"2026-05-29T11:49:37.669Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-5:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"eb17b2b5","parentId":"2c626019","timestamp":"2026-05-29T11:49:37.671Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-5"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-5:request_answer","answer":"Answer for deterministic-grounding-text-5"}}} +{"type":"message","id":"03dfaf20","parentId":"eb17b2b5","timestamp":"2026-05-29T11:49:37.671Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-6:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"c1de4979","parentId":"03dfaf20","timestamp":"2026-05-29T11:49:37.673Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-6:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"ff244d3e","parentId":"c1de4979","timestamp":"2026-05-29T11:49:37.674Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-7:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"0a1e6873","parentId":"ff244d3e","timestamp":"2026-05-29T11:49:37.676Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-7:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"437c6082","parentId":"0a1e6873","timestamp":"2026-05-29T11:49:37.676Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-8:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"339e1f78","parentId":"437c6082","timestamp":"2026-05-29T11:49:37.678Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-8"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-8:request_answer","answer":"Answer for deterministic-grounding-text-8"}}} +{"type":"message","id":"be940616","parentId":"339e1f78","timestamp":"2026-05-29T11:49:37.679Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-9:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"1a058f03","parentId":"be940616","timestamp":"2026-05-29T11:49:37.681Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-9:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} +{"type":"message","id":"a4e04491","parentId":"1a058f03","timestamp":"2026-05-29T11:49:37.682Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-10:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"7104f30b","parentId":"a4e04491","timestamp":"2026-05-29T11:49:37.683Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-10:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md index bb9559ab..f8d1c298 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md @@ -2,16 +2,16 @@ ## Session -- session: 019e7373-6b3d-7bdb-bccb-fbf5d974efea -- cwd: /var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-E54Gbu +- session: 019e7391-86fb-78bd-a3b9-e54b48f316af +- cwd: /var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc ## Session binding ```json { "schemaVersion": 1, - "sessionId": "019e7373-6b3d-7bdb-bccb-fbf5d974efea", - "specId": "spec-3107835e-7dde-4a30-9f26-7f49d52d31b3", + "sessionId": "019e7391-86fb-78bd-a3b9-e54b48f316af", + "specId": "spec-9a7e0a75-0932-4caa-a848-285e476dcb85", "specTitle": "Public RPC parity spec" } ``` diff --git a/memory/CARDS.md b/memory/CARDS.md index fd96362b..78e09af7 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -62,7 +62,7 @@ Depends on: A23-L — already validated by the public RPC parity proof; this car ## Card 2 — Add a self-describing parity report envelope -**Status:** next +**Status:** done **Weight:** light scope card ### Objective @@ -109,7 +109,7 @@ Depends on: A5-L and A23-L — the artifact envelope improves fixture-driver/pro ## Card 3 — Make transcript rendering Brunch-semantic by default -**Status:** queued +**Status:** next **Weight:** light scope card ### Objective diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index 69716671..207c4861 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { basename, dirname, join } from "node:path" import { describe, expect, it } from "vitest" @@ -11,6 +11,10 @@ describe("public Brunch RPC structured-exchange parity proof", () => { const report = await runPublicRpcParityProof() expect(report).toMatchObject({ + schemaVersion: 1, + probeId: "public-rpc-parity", + runId: expect.any(String), + generatedAt: expect.any(String), mission: expect.stringContaining("public JSON-RPC only"), evaluationFocus: expect.stringContaining( "tuple transcript/projection parity", @@ -21,6 +25,7 @@ describe("public Brunch RPC structured-exchange parity proof", () => { specId: expect.any(String), sessionId: expect.any(String), }) + expect(Date.parse(report.generatedAt)).not.toBeNaN() expect(report.toolCoverage).toEqual([ "present_options", "present_question", @@ -44,31 +49,37 @@ describe("public Brunch RPC structured-exchange parity proof", () => { const artifacts = report.artifacts expect(artifacts).toEqual({ - runDir: join(fixtureRoot, "runs", "public-rpc-parity", "artifact-test"), + runDir: join(fixtureRoot, "runs", "public-rpc-parity", report.runId), sessionJsonl: join( fixtureRoot, "runs", "public-rpc-parity", - "artifact-test", + report.runId, "session.jsonl", ), transcriptMarkdown: join( fixtureRoot, "runs", "public-rpc-parity", - "artifact-test", + report.runId, "transcript.md", ), reportJson: join( fixtureRoot, "runs", "public-rpc-parity", - "artifact-test", + report.runId, "report.json", ), }) if (artifacts === undefined) throw new Error("Expected artifact paths") + expect( + artifacts.runDir.endsWith(join("runs", report.probeId, report.runId)), + ).toBe(true) + expect(basename(artifacts.runDir)).toBe(report.runId) + expect(basename(dirname(artifacts.runDir))).toBe(report.probeId) + const sessionJsonl = await readFile(artifacts.sessionJsonl, "utf8") const transcript = await readFile(artifacts.transcriptMarkdown, "utf8") const persistedReport = JSON.parse( @@ -81,6 +92,10 @@ describe("public Brunch RPC structured-exchange parity proof", () => { expect(transcript).toContain("— prompt (present_") expect(transcript).toContain("— response (request_") expect(persistedReport).toMatchObject({ + schemaVersion: 1, + probeId: "public-rpc-parity", + runId: report.runId, + generatedAt: report.generatedAt, mission: report.mission, completedTurns: 10, exchangeIds: report.exchangeIds, diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 51fb5a6e..c996cf11 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -67,6 +67,10 @@ export interface PublicRpcParityProofOptions { } export interface PublicRpcParityProofReport { + schemaVersion: 1 + probeId: "public-rpc-parity" + runId: string + generatedAt: string mission: string evaluationFocus: string maxTurnBudget: number @@ -175,6 +179,8 @@ function responseFor(exchange: PendingExchange): ProofResponse { export async function runPublicRpcParityProof( options: PublicRpcParityProofOptions = {}, ): Promise<PublicRpcParityProofReport> { + const runId = options.runId ?? defaultRunId() + const generatedAt = new Date().toISOString() const cwd = await mkdtemp(join(tmpdir(), "brunch-public-rpc-parity-")) const coordinator = createWorkspaceSessionCoordinator({ cwd }) const handlers = createRpcHandlers({ coordinator, cwd }) @@ -375,6 +381,10 @@ export async function runPublicRpcParityProof( } const report: PublicRpcParityProofReport = { + schemaVersion: 1, + probeId: "public-rpc-parity", + runId, + generatedAt, mission: "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", evaluationFocus: @@ -393,7 +403,7 @@ export async function runPublicRpcParityProof( if (options.fixtureRoot !== undefined) { report.artifacts = await writeProofArtifacts({ fixtureRoot: options.fixtureRoot, - runId: options.runId ?? defaultRunId(), + runId, sessionText, report, }) From 83795e149284ad1567caff2f6129a772c2792b43 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 13:51:21 +0200 Subject: [PATCH 141/164] Render semantic transcripts by default --- memory/CARDS.md | 155 --------------------------------- src/session-transcript.test.ts | 12 +++ src/session-transcript.ts | 10 +-- 3 files changed, 13 insertions(+), 164 deletions(-) delete mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 78e09af7..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,155 +0,0 @@ -# Scope Cards — FE-744 public RPC parity artifact hardening - -> Prepared by `ln-scope` for build in a separate thread. These cards are scoped slices inside the existing `pi-ui-extension-patterns` frontier / FE-744 branch. Do not create new Linear issues or Graphite branches for these cards by default. - -## Orientation - -- **Containing seam:** FE-744 `pi-ui-extension-patterns`, specifically the public Brunch RPC parity proof and probe-oracle artifact layer. -- **Relevant frontier item:** `pi-ui-extension-patterns` remains the branch/tracker boundary; these are follow-through hardening slices after committed `.fixtures` public-RPC parity artifacts landed in `baa08cbe`. -- **Canonicalized handoff state:** `HANDOFF.md` has been retired; durable decisions now live in `memory/SPEC.md` / `memory/PLAN.md`. Future `capture_*` ANALYSIS work is specified at the carrier/visibility level only and requires a separate `ln-design` pass before implementation. -- **Main open risk:** the new review bundle is human-legible, but its report/test witness is still too thin to prove every completed exchange is represented in both source JSONL and transcript Markdown. - -## Queue discipline - -- Build cards in order unless implementation reveals a reason to stop. -- Each card should be committed independently after `npm run verify` passes. -- Ordinary test runs must not mutate committed `.fixtures` outputs; committed seed bundles may be regenerated only by an explicit artifact-writing path. -- Canonical SPEC/PLAN reconciliation should be a no-op for these cards unless implementation changes the already-recorded `.fixtures` bundle shape, Brunch-semantic transcript default, or `capture_*` deferral boundary. - -## Card 1 — Strengthen parity artifact witness - -**Status:** done -**Weight:** light scope card - -### Objective - -The public RPC parity artifact test proves that every completed exchange id in the report is present in both the persisted session JSONL and rendered transcript Markdown. - -### Acceptance Criteria - -✓ `src/probes/public-rpc-parity-proof.test.ts` asserts that the persisted `report.json` exchange ids exactly match the in-memory report exchange ids and contain ten unique ids. -✓ The artifact-writing test asserts that every persisted exchange id appears in `session.jsonl`. -✓ The artifact-writing test asserts that every persisted exchange id appears in `transcript.md`. -✓ Ordinary `runPublicRpcParityProof()` calls without `fixtureRoot` still do not write `.fixtures` artifacts. - -### Verification Approach - -- Inner: `npm run test -- src/probes/public-rpc-parity-proof.test.ts` — proves the artifact bundle is witnessed through the public probe boundary. -- Inner: `npm run fix` after edits. -- Gate: `npm run verify` before committing. - -### Cross-cutting obligations - -- Preserve I32-L: public RPC elicitation driving must not require raw Pi RPC. -- Preserve I23-L: structured-exchange transcript evidence comes from durable `toolResult.content` / `toolResult.details`. -- Preserve explicit artifact persistence: tests should write only to a temp fixture root unless a builder intentionally regenerates committed `.fixtures` output. - -### Assumption dependency - -Depends on: A23-L — already validated by the public RPC parity proof; this card only strengthens the artifact oracle over that proof. - -### Promotion checklist - -- [ ] Changes a requirement -- [ ] Creates/retires/invalidates an assumption -- [ ] Depends on an unvalidated high-impact assumption -- [ ] Makes/reverses a non-trivial design decision -- [ ] Establishes a new seam-level invariant -- [ ] Changes a frontier-level obligation or verification architecture layer -- [ ] Crosses more than two major seams -- [ ] First touch in an unfamiliar seam -- [ ] Cannot name containing seam/current rationale - -## Card 2 — Add a self-describing parity report envelope - -**Status:** done -**Weight:** light scope card - -### Objective - -The public RPC parity `report.json` identifies its schema, probe id, run id, and generation timestamp without relying on directory layout. - -### Acceptance Criteria - -✓ `PublicRpcParityProofReport` includes an explicit `schemaVersion: 1`. -✓ `PublicRpcParityProofReport` includes `probeId: "public-rpc-parity"`. -✓ `PublicRpcParityProofReport` includes the `runId` used for artifact output. -✓ `PublicRpcParityProofReport` includes `generatedAt` as an ISO timestamp. -✓ The artifact-writing test proves `report.json.artifacts.runDir` ends in `/runs/public-rpc-parity/<report.runId>` and that the report's `probeId` matches the artifact path's probe segment. -✓ The committed seed bundle at `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` is regenerated or edited so `report.json` matches the new envelope. - -### Verification Approach - -- Inner: `npm run test -- src/probes/public-rpc-parity-proof.test.ts` — proves the report envelope and path coherence. -- Middle: inspect `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json` — confirms the committed review artifact is self-describing. -- Inner: `npm run fix` after edits. -- Gate: `npm run verify` before committing. - -### Cross-cutting obligations - -- Keep `.fixtures/runs/<probe-id>/<run-id>/` as the probe-oracle review-bundle shape documented in SPEC. -- Do not change the public RPC parity behavior while changing only the report envelope. -- Do not mutate committed `.fixtures` during ordinary tests; seed regeneration must be an explicit builder action. - -### Assumption dependency - -Depends on: A5-L and A23-L — the artifact envelope improves fixture-driver/probe quality over the already-validated parity path; it does not introduce a new substrate assumption. - -### Promotion checklist - -- [ ] Changes a requirement -- [ ] Creates/retires/invalidates an assumption -- [ ] Depends on an unvalidated high-impact assumption -- [ ] Makes/reverses a non-trivial design decision -- [ ] Establishes a new seam-level invariant -- [ ] Changes a frontier-level obligation or verification architecture layer -- [ ] Crosses more than two major seams -- [ ] First touch in an unfamiliar seam -- [ ] Cannot name containing seam/current rationale - -## Card 3 — Make transcript rendering Brunch-semantic by default - -**Status:** next -**Weight:** light scope card - -### Objective - -The session transcript renderer's default output omits generic non-Brunch tool results while retaining Brunch semantic transcript evidence for the currently implemented structured-exchange families. - -### Acceptance Criteria - -✓ `src/session-transcript.test.ts` covers a JSONL session containing both a generic tool result and structured-exchange `present_*` / `request_*` tool results. -✓ `renderSessionTranscript(...)` default output includes the structured-exchange prompt/response sections. -✓ `renderSessionTranscript(...)` default output omits the generic tool result heading/body. -✓ `runPublicRpcParityProof({ fixtureRoot, runId })` still writes a transcript containing all ten exchange ids from the report. -✓ This card does not implement `capture_analysis`; it leaves `capture_*` ANALYSIS rendering for the separate design pass unless a minimal classifier falls out naturally without choosing the details schema. - -### Verification Approach - -- Inner: `npm run test -- src/session-transcript.test.ts src/probes/public-rpc-parity-proof.test.ts` — proves the renderer default and parity artifact integration. -- Middle: inspect generated `transcript.md` from a temp artifact-writing parity run if tests fail to make the behavior obvious. -- Inner: `npm run fix` after edits. -- Gate: `npm run verify` before committing. - -### Cross-cutting obligations - -- Preserve I23-L: durable semantic display comes from `toolResult.content` / `toolResult.details`, not `renderCall` or live UI state. -- Preserve I33-L / D50-L at the boundary: `capture_*` is transcript evidence only and should be included in Brunch-semantic transcripts once designed, but this card must not invent the `capture_analysis` details schema or shared component API. -- Keep the transcript renderer aligned with the human-review oracle: Brunch semantic transcript evidence should be visible; unrelated generic tool noise should not obscure it. -- Defer raw/debug transcript mode unless implementation shows it is cheaper to preserve during this slice; if raw mode is added, it must be explicit and covered by tests. - -### Assumption dependency - -Depends on: A23-L — the implemented structured-exchange tool families and parity transcript are already validated; this card narrows the default human transcript view over that known substrate. It references I33-L only as a deferral guard, not as an implementation dependency. - -### Promotion checklist - -- [ ] Changes a requirement -- [ ] Creates/retires/invalidates an assumption -- [ ] Depends on an unvalidated high-impact assumption -- [ ] Makes/reverses a non-trivial design decision -- [ ] Establishes a new seam-level invariant -- [ ] Changes a frontier-level obligation or verification architecture layer -- [ ] Crosses more than two major seams -- [ ] First touch in an unfamiliar seam -- [ ] Cannot name containing seam/current rationale diff --git a/src/session-transcript.test.ts b/src/session-transcript.test.ts index a8b2a639..ce67a936 100644 --- a/src/session-transcript.test.ts +++ b/src/session-transcript.test.ts @@ -16,6 +16,16 @@ describe("session transcript renderer", () => { customType: "brunch.session_binding", data: { specId: "spec-1", specTitle: "Demo spec" }, }), + line({ + id: "generic-tool-1", + type: "message", + message: { + role: "toolResult", + toolName: "read", + content: [{ type: "text", text: "Generic file contents" }], + details: { path: "notes.txt" }, + }, + }), line({ id: "present-1", type: "message", @@ -86,5 +96,7 @@ describe("session transcript renderer", () => { "## Exchange turn-1 — response (request_choice, answered)", ) expect(transcript).toContain("Keep it deterministic.") + expect(transcript).not.toContain("## Tool result: read") + expect(transcript).not.toContain("Generic file contents") }) }) diff --git a/src/session-transcript.ts b/src/session-transcript.ts index 3d66590f..2ac2832d 100644 --- a/src/session-transcript.ts +++ b/src/session-transcript.ts @@ -162,15 +162,7 @@ function renderToolResult( ] } - const body = textContent(message.content) - return [ - `## Tool result: ${message.toolName}`, - "", - body.length > 0 ? body : "_(empty tool result)_", - ...(details === undefined - ? [] - : ["", "```json", JSON.stringify(details, null, 2), "```"]), - ] + return [] } function structuredPresent(value: unknown) { From 6c63158bcd000ba406485cc5848df93403c6c96c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 14:02:15 +0200 Subject: [PATCH 142/164] post semantic-transcripts sync --- HANDOFF.md | 93 -------------------------------------------------- memory/PLAN.md | 8 ++--- memory/SPEC.md | 19 +++++++---- 3 files changed, 17 insertions(+), 103 deletions(-) delete mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index ce8eccb5..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,93 +0,0 @@ -# Handoff - -> Refreshed by `ln-sync` at 2026-05-29. This file is volatile transfer state only. -> Delete or overwrite it once the next session scopes/builds the web real-time observation slice or creates a newer handoff. - -## Goal - -Finish FE-744 by closing the remaining Pi-wrapping proof seams after public RPC structured-exchange parity: web real-time structured-exchange observation, then branded/themed chrome recovery. - -## Session State - -- **Last completed implementation flow:** builder completed the FE-744 RPC parity hardening queue after the ten-turn parity proof. -- **Current skill:** `ln-sync` — reconciling canonical docs and refreshing this handoff. -- **Flow position:** `scope → build ×4 → review → scope hardening queue → build ×3 → sync/handoff`. -- **Branch:** `ln/fe-744-pi-ui-extension-patterns`. - -## Completed Since Previous Handoff - -### Public RPC tuple parity queue - -- `5fa4ab45` — Implement structured exchange request choices -- `7f4c6318` — Project structured exchange tuples -- `929ea746` — Move RPC elicitation onto tuple truth -- `5e323437` — Add public RPC parity proof - -### Review hardening queue - -- `faa4dbc2` — Harden public RPC parity exchange identity -- `f1216fbc` — Close pending exchange on terminal request status -- `a9b3abb9` — Preserve option artifacts in RPC parity - -The builder reported `npm run verify` passed after the final hardening slice, and `memory/CARDS.md` was deleted as exhausted. - -## Current Canonical State - -- Public RPC parity is now a landed FE-744 baseline, not open scope. -- `rpc.discover`, `workspace.selectionState`, `workspace.activate`, `session.startElicitation`, `session.pendingExchange`, `elicitation.respond`, `session.elicitationExchanges`, and `session.transcriptDisplay` form the public proof surface. -- `src/probes/public-rpc-parity-proof.ts` drives ten **distinct** assistant-first structured exchanges from a fresh cwd through Brunch JSON-RPC only. -- Tuple-shaped transcript truth is the active model: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered structured-exchange tools; review/candidate tools remain named stubs. -- Hardened projection behavior: matching terminal `answered`, `cancelled`, and `unavailable` request tuples close pending exchanges; option `content` and optional `rationale` survive public pending/proof projections. -- `memory/PLAN.md`, `memory/SPEC.md`, and `docs/architecture/pi-ui-extension-patterns.md` have been refreshed in this sync to reflect that parity/hardening has landed. - -## Next Scope Target - -The next actionable item is still inside the FE-744 `pi-ui-extension-patterns` frontier: - -> Scope the web real-time structured-exchange observation smoke: a browser/web client observes selected session/exchange state updating when TUI or public RPC interactions append tuple-shaped structured-exchange transcript truth. - -Suggested acceptance shape: - -- Web client subscribes or otherwise observes the currently selected spec/session state over the Brunch public surface. -- Starting/responding to a structured exchange through public RPC updates the browser view without a manual reload. -- The smoke covers pending exchange appearance, response/closure, transcript display/exchange projection change, and selected session identity. -- The proof stays read/observe-only from the web side unless an explicit product write path is already scoped. - -After that, recover branded/themed chrome before FE-744 closeout by inspecting the retired probe implementation named in `memory/PLAN.md`: - -```sh -git show 6c2e3823:.pi/extensions/brunch-chrome.ts -``` - -## Decisions and Assumptions - -| Item | Status | Source | -| --- | --- | --- | -| Structured exchanges are durable `present_*` / `request_*` `toolResult` tuples; `renderCall` is transient. | persisted | `memory/SPEC.md` D37-L / I23-L | -| Public Brunch RPC can drive ten assistant-first structured exchanges without raw Pi RPC or a parallel prompt/turn store. | validated | `memory/SPEC.md` A23-L / I32-L; `src/probes/public-rpc-parity-proof.ts` | -| `request_choices` is now implemented and registered; multi-choice uses JSON-editor fallback semantics where needed. | persisted | `memory/SPEC.md` I23-L; structured-exchange tests | -| Matching `cancelled` and `unavailable` request tuples are terminal for projection/pending state. | persisted | `memory/SPEC.md` I23-L; projection tests | -| RPC event consumers should not assume request `tool_execution_start` precedes request extension UI. | persisted | `memory/SPEC.md` D37-L; `docs/architecture/pi-ui-extension-patterns.md` | -| Questionnaire/multi-question surfaces and distinct `skipped` terminal state remain deferred. | persisted | `memory/SPEC.md` R17 / lexicon | - -## Artifact Status - -| Artifact | Status | -| --- | --- | -| `memory/SPEC.md` | refreshed; A23/I23/I32 updated for landed parity/hardening | -| `memory/PLAN.md` | refreshed; FE-744 pointer now names web observation then chrome recovery | -| `docs/architecture/pi-ui-extension-patterns.md` | refreshed; no longer says ten-turn public RPC parity is missing | -| `memory/CARDS.md` | absent; last hardening queue exhausted and deleted | -| `memory/REFACTOR.md` | absent | -| `HANDOFF.md` | this volatile handoff | - -## Repo State Notes - -- At the start of sync, git status was clean and the branch was ahead of origin by 7 commits. -- This sync intentionally edits canonical docs plus this handoff; commit or discard those doc edits according to the session plan. -- No code changes were made in this sync. - -## Resume Prompt - -> Read `memory/SPEC.md`, the FE-744 section of `memory/PLAN.md`, and `HANDOFF.md`. -> Public RPC structured-exchange parity and its review hardening have landed. The immediate next step is `/ln-scope` for web real-time structured-exchange observation smoke inside FE-744. Preserve tuple-shaped transcript truth, public Brunch RPC boundaries, and the read-only observer posture for web unless a write path is explicitly scoped. diff --git a/memory/PLAN.md b/memory/PLAN.md index e4667699..f2e12f46 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -117,9 +117,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-exchange preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. - **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first batch-proposal fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. -- **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L +- **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L, D50-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L, I33-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) -- **Current execution pointer:** before implementation, run oracle/scoping pressure on A14-L and A22-L: define the smallest replay/probe set that can reveal over-capture, missed obvious facts, dry-run-invalid review-set drafts, and whether plain-prose `preface` is sufficient for low-confidence implications. +- **Current execution pointer:** before implementation, run oracle/scoping pressure on A14-L and A22-L: define the smallest replay/probe set that can reveal over-capture, missed obvious facts, dry-run-invalid review-set drafts, whether plain-prose `preface` is sufficient for low-confidence implications, and how any `capture_*` ANALYSIS entries will be compared against committed graph mutations. ### subagents-for-proposal-diversity @@ -223,9 +223,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next scope whether to harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested) before returning to web real-time structured-exchange observation smoke. Keep the scroll-lock finding from the project-local `structured_exchange` extension in mind: active answer controls should stay compact and transcript-friendly. Then recover branded chrome before FE-744 closeout by inspecting the retired probe implementation (`git show 6c2e3823:.pi/extensions/brunch-chrome.ts` and nearby commits) and porting the actual theme-token/branded layout into `src/tui-client/.pi/extensions/chrome.ts` or a private submodule with an oracle that fails for the current diagnostic dump. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next build from `memory/CARDS.md`: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 11ec9137..3e5f2311 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -217,7 +217,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges are durable present/request toolResult tuples.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is a pair/tuple of registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, while `request_*` tools collect and persist the user response. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both user-facing TUI transcript content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives ten distinct tuple-shaped exchanges over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives ten distinct tuple-shaped exchanges over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -228,7 +228,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D31-L — A four-axis meta-rubric is a soft heuristic for fan-out comparison rubrics across all three flows; not architecturally enforced.** When generating comparison rubrics for fan-out alternatives across candidate-spec, technical-design, and verification-design flows, the elicitor attempts to express each axis in terms of (*legibility / cost-of-knowing*, *failure modes*, *coverage / range*, *commitment*). Project-specific axes are allowed alongside; the meta-frame is dropped when it doesn't fit. The hypothesis (uniform comparison UI across all three flows is more useful than per-flow improvisation) is testable via fixture comparison; promote to schema/UI only if it holds up. Depends on: D25-L, D26-L. Supersedes: a hardcoded per-flow rubric. - **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. - **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. -- **D47-L — Structured-exchange `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-exchange payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. +- **D47-L — Structured-exchange `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-exchange payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Future `capture_*` analysis entries provide a separate post-exchange/review evidence surface for candidate semantic changes; they do not replace preface as next-question orientation and do not become graph truth. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L, D50-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. +- **D50-L — `capture_*` tools persist transcript-native ANALYSIS, not graph mutations.** Brunch may add a third structured-exchange tool family such as `capture_analysis` alongside `present_*` and `request_*`. A `capture_*` tool returns a normal persisted Pi `toolResult` with Brunch details and markdown content describing likely graph/node/edge changes, grouped into high-confidence candidates that could be committed later and low-confidence candidates that should drive clarification. `capture_*` output is transcript-visible evidence for Markdown/ASCII review and later graph-mutation cross-checking, but it is not graph truth and never bypasses the `CommandExecutor`. Product UI should hide capture analysis entirely if Pi exposes a supported hide seam; otherwise `renderResult` should be maximally collapsed/minimal while preserving full persisted `toolResult.content`/`details` for transcript renderers. The exact TypeBox details schema and shared component subparts (`Preface`, prompt body, option list, answer summary, capture analysis) require a later `ln-design` pass before implementation. Depends on: D12-L, D17-L, D18-L, D37-L, D41-L, D47-L. Supersedes: using ad hoc hidden custom entries, probe-only side files, or graph writes as the first carrier for pre-graph analysis. - **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/tui-client/.pi/extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. @@ -272,6 +273,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | | I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, ten distinct public-RPC structured-exchange tuples, terminal non-answered status handling, option content/rationale parity, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | +| I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | planned (future capture-analysis schema/rendering tests plus transcript renderer fixture; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | ## Future Direction Register @@ -391,10 +393,12 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | -| **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` family. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | +| **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` / future `capture_*` families. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response; `capture_*` tools persist assistant analysis of likely semantic changes without mutating graph truth. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | | **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. Its details include `exchangeId`, `presentTool`, `kind`, `status: presented`, `expectedRequest`, and `createdAtToolCallId`. | | **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, future `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. It references the present half by `exchangeId` and present tool rather than repeating the presented markdown. | -| **Structured exchange result details** | The structured payload in a structured-exchange toolResult. Present details support tuple recovery; request details carry terminal status (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review/comment data. Brunch projection should not need render lifecycle state to rebuild the exchange. | +| **Capture tool** | A future `capture_*` structured-exchange tool (for example `capture_analysis`) whose normal persisted `toolResult` records ANALYSIS: high-confidence candidate graph mutations and low-confidence clarification candidates grounded in transcript evidence. It is transcript-visible but UI-hidden when possible, otherwise maximally collapsed; it is never a graph mutation. | +| **ANALYSIS transcript section** | Human-reviewable transcript rendering of `capture_*` tool results. ANALYSIS explains candidate node/edge changes and uncertainties before graph persistence or before comparing later graph mutations to the transcript; it is evidence, not authority. | +| **Structured exchange result details** | The structured payload in a structured-exchange toolResult. Present details support tuple recovery; request details carry terminal status (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review/comment data; capture details carry analysis candidates and grounding. Brunch projection should not need render lifecycle state to rebuild the exchange. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained `request_*` toolResult details. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | @@ -480,7 +484,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo - **Gate before commit:** `npm run verify`. All steps must pass; no override. - **Failure protocol:** stop on first failure; the failure becomes the must-fix task; re-run the stack from step 1; only proceed when all checks pass. - **Frontier completion:** manual smoke can prove presentation life, but any durable product claim must also have an artifact/query oracle, property/round-trip test, contract test, or fixture assertion tied to the canonical store or projection handler that owns the fact. -- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.brunch-fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. +- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.brunch-fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. Transcript renderers default to the Brunch-semantic view: structured-exchange and Brunch custom transcript evidence included, unrelated generic tool results skipped unless an explicit raw/debug mode is requested. ### Oracle Strategy by Loop Tier @@ -495,7 +499,8 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, rendered `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Capture-analysis transcript oracle | Future `capture_*` probes persist ANALYSIS as normal Brunch toolResults, assert no graph writes occur, render full analysis in Markdown/ASCII transcripts, and assert the TUI path hides or collapses the same result without losing persisted content/details. | D17-L, D18-L, D37-L, D47-L, D50-L; I23-L, I30-L, I33-L. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -544,11 +549,13 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | | I32-L | FE-744 public-RPC elicitation parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic ten-turn agent-as-user run over Brunch JSON-RPC only, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | +| I33-L | Future capture-analysis tests: `capture_*` result schema/rendering, no graph-write side effects, Brunch-semantic transcript inclusion, and hidden/collapsed TUI rendering fallback. | ### Design Notes - **Deterministic before generative.** M1 should prefer a deterministic or tightly scripted user-agent path for the first captured run before relying on LLM persona variance. Generative/adversarial probes come after the transcript and fixture substrate is trusted. M1 scripted captures prove the transport/projection/fixture substrate on its current terms; they do not settle the final elicitation interaction logic, knowledge flow, or prompt/response expectation model. - **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. +- **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The schema/component shape should be designed separately before implementation; the durable commitment now is only the toolResult-family carrier and visibility policy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. - **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. From eb723169482ad6fad427f8228103983e0ef86a14 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 14:07:52 +0200 Subject: [PATCH 143/164] Restore explicit Brunch extension registry --- memory/PLAN.md | 2 +- memory/SPEC.md | 4 +- .../auto-discovered-extensions.test.ts | 105 --------- .../.pi/__tests__/extension-registry.test.ts | 213 ++++++++++++++++++ src/tui-client/.pi/extensions/alternatives.ts | 7 - src/tui-client/.pi/extensions/chrome.ts | 11 +- .../.pi/extensions/command-policy.ts | 7 - .../.pi/extensions/mention-autocomplete.ts | 12 - .../.pi/extensions/operational-mode.ts | 8 - .../.pi/extensions/session-lifecycle.ts | 15 +- .../extensions/structured-exchange/index.ts | 13 +- .../.pi/extensions/workspace-dialog.ts | 12 - src/tui-client/pi-extension-shell.ts | 156 +++---------- 13 files changed, 256 insertions(+), 309 deletions(-) delete mode 100644 src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts create mode 100644 src/tui-client/.pi/__tests__/extension-registry.test.ts diff --git a/memory/PLAN.md b/memory/PLAN.md index f2e12f46..566186b6 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -225,7 +225,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. Next build from `memory/CARDS.md`: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 3e5f2311..6307bc2f 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -127,7 +127,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. -- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/tui-client/pi-extension-shell.ts`) rather than ambient discovery. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, or nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`. +- **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/tui-client/pi-extension-shell.ts`) rather than ambient discovery; *explicit* means the shell statically imports its product extensions and registers them from a fixed ordered list — it must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the shell wires those dependencies at the call site; the `default` exports under `src/tui-client/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`, or replacing the explicit static shell list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, or modeling read-only posture as a volatile allowlist of every safe tool. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. @@ -496,7 +496,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; pending-exchange start/read/respond handlers preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | -| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | +| Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | | Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | diff --git a/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts b/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts deleted file mode 100644 index 0d3ec9fc..00000000 --- a/src/tui-client/.pi/__tests__/auto-discovered-extensions.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import { describe, expect, it } from "vitest" - -import { - BRUNCH_PRODUCT_EXTENSION_READY, - discoverBrunchProductExtensionEntries, -} from "../../pi-extension-shell.js" -import alternatives from "../extensions/alternatives.js" -import chrome from "../extensions/chrome.js" -import commandPolicy from "../extensions/command-policy.js" -import mentionAutocomplete from "../extensions/mention-autocomplete.js" -import operationalMode from "../extensions/operational-mode.js" -import sessionLifecycle from "../extensions/session-lifecycle.js" -import structuredExchange from "../extensions/structured-exchange/index.js" -import workspaceDialog from "../extensions/workspace-dialog.js" - -const autoDiscoveredExtensions = { - "alternatives.ts": alternatives, - "chrome.ts": chrome, - "command-policy.ts": commandPolicy, - "mention-autocomplete.ts": mentionAutocomplete, - "operational-mode.ts": operationalMode, - "session-lifecycle.ts": sessionLifecycle, - "structured-exchange/index.ts": structuredExchange, - "workspace-dialog.ts": workspaceDialog, -} - -describe("Pi auto-discovered extensions", () => { - it("export default factory functions for src/tui-client /.pi iteration", () => { - for (const [path, factory] of Object.entries(autoDiscoveredExtensions)) { - expect(factory, path).toEqual(expect.any(Function)) - } - }) - - it("discovers prod-ready Brunch extension entrypoints from local metadata", async () => { - const entries = await discoverBrunchProductExtensionEntries() - - expect(entries.map((entry) => entry.path)).toEqual([ - "session-lifecycle.ts", - "chrome.ts", - "command-policy.ts", - "operational-mode.ts", - "mention-autocomplete.ts", - "alternatives.ts", - "structured-exchange/index.ts", - "workspace-dialog.ts", - ]) - for (const entry of entries) { - expect(entry.meta.productStatus, entry.path).toBe( - BRUNCH_PRODUCT_EXTENSION_READY, - ) - expect(entry.registerProductExtension, entry.path).toEqual( - expect.any(Function), - ) - } - }) - - it("does not treat support modules or WIP modules as product extension entrypoints", async () => { - const entries = await discoverBrunchProductExtensionEntries() - const paths = entries.map((entry) => entry.path) - - expect(paths).not.toContain("structured-exchange/request-choice.ts") - expect(paths).not.toContain("structured-exchange/shared/model.ts") - expect(paths).not.toContain("subagents/config.json") - expect(paths).not.toContain("auto-compaction-anchors.json") - }) - - it("requires local ready metadata before product loading an entrypoint", async () => { - const extensionsDir = await mkdtemp(join(tmpdir(), "brunch-extensions-")) - await writeFile( - join(extensionsDir, "ready.js"), - `export const brunchExtensionMeta = { productStatus: "ready" }; - export function registerBrunchProductExtension() {}`, - ) - await writeFile( - join(extensionsDir, "implicit.js"), - `export function registerBrunchProductExtension() {}`, - ) - await writeFile( - join(extensionsDir, "wip.js"), - `export const brunchExtensionMeta = { productStatus: "wip" }; - export function registerBrunchProductExtension() {}`, - ) - await mkdir(join(extensionsDir, "nested")) - await writeFile( - join(extensionsDir, "nested", "index.js"), - `export const brunchExtensionMeta = { productStatus: "ready", loadOrder: -1 }; - export function registerBrunchProductExtension() {}`, - ) - await writeFile( - join(extensionsDir, "nested", "helper.js"), - `throw new Error("support files must not be imported")`, - ) - - const entries = await discoverBrunchProductExtensionEntries(extensionsDir) - - expect(entries.map((entry) => entry.path)).toEqual([ - "nested/index.js", - "ready.js", - ]) - }) -}) diff --git a/src/tui-client/.pi/__tests__/extension-registry.test.ts b/src/tui-client/.pi/__tests__/extension-registry.test.ts new file mode 100644 index 00000000..dc3ea08b --- /dev/null +++ b/src/tui-client/.pi/__tests__/extension-registry.test.ts @@ -0,0 +1,213 @@ +import { access, readFile, readdir } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it } from "vitest" + +import { createBrunchPiExtensionShell } from "../../pi-extension-shell.js" +import alternatives from "../extensions/alternatives.js" +import chrome from "../extensions/chrome.js" +import commandPolicy from "../extensions/command-policy.js" +import mentionAutocomplete from "../extensions/mention-autocomplete.js" +import operationalMode from "../extensions/operational-mode.js" +import sessionLifecycle from "../extensions/session-lifecycle.js" +import structuredExchange, { + PRESENT_OPTIONS_TOOL, + PRESENT_QUESTION_TOOL, + REQUEST_ANSWER_TOOL, + REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, +} from "../extensions/structured-exchange/index.js" +import workspaceDialog, { + BRUNCH_WORKSPACE_COMMAND, +} from "../extensions/workspace-dialog.js" + +const extensionDefaults = { + "alternatives.ts": alternatives, + "chrome.ts": chrome, + "command-policy.ts": commandPolicy, + "mention-autocomplete.ts": mentionAutocomplete, + "operational-mode.ts": operationalMode, + "session-lifecycle.ts": sessionLifecycle, + "structured-exchange/index.ts": structuredExchange, + "workspace-dialog.ts": workspaceDialog, +} + +describe("Brunch explicit Pi extension registry", () => { + it("keeps default factory exports for src/tui-client /.pi iteration", () => { + for (const [path, factory] of Object.entries(extensionDefaults)) { + expect(factory, path).toEqual(expect.any(Function)) + } + }) + + it("registers product extensions from the shell in explicit order", async () => { + const recording = createRecordingExtensionApi() + + await createBrunchPiExtensionShell( + brunchChromeFixture, + recording.onSessionBoundary, + { + coordinator: {} as never, + graphMentionSource: { listMentionCandidates: () => [] }, + }, + )(recording.api) + + expect(recording.toolNames).toEqual([ + "read", + "grep", + "find", + "ls", + "present_alternatives", + PRESENT_QUESTION_TOOL, + PRESENT_OPTIONS_TOOL, + REQUEST_ANSWER_TOOL, + REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, + ]) + expect(recording.commandNames).toEqual([BRUNCH_WORKSPACE_COMMAND]) + expect(recording.messageRenderers).toEqual(["alternatives-card-set"]) + expect(recording.shortcuts).toEqual(["ctrl+shift+b"]) + expect(recording.eventNames).toEqual([ + "session_start", + "before_agent_start", + "message_start", + "session_start", + "session_before_tree", + "session_before_fork", + "session_start", + "before_agent_start", + "tool_call", + "user_bash", + "before_agent_start", + "session_start", + ]) + + const sessionStartIndexes = recording.eventNames.flatMap((event, index) => + event === "session_start" ? [index] : [], + ) + expect(sessionStartIndexes[0]).toBeLessThan(sessionStartIndexes[1] ?? -1) + }) + + it("does not retain the filesystem-discovery product-extension protocol", async () => { + const shell = await readFile( + join(projectRoot(), "src/tui-client/pi-extension-shell.ts"), + "utf8", + ) + const discoveryExport = ["discover", "BrunchProductExtensionEntries"].join( + "", + ) + expect(shell).not.toContain(`export async function ${discoveryExport}`) + expect(shell).not.toContain("node:fs/promises") + expect(shell).not.toContain("pathToFileURL") + + const forbiddenExportNames = [ + ["brunch", "ExtensionMeta"].join(""), + ["register", "BrunchProductExtension"].join(""), + ] + const files = await listExtensionEntrypoints() + for (const file of files) { + const source = await readFile(file, "utf8") + for (const exportName of forbiddenExportNames) { + expect(source, file).not.toContain(`export const ${exportName}`) + expect(source, file).not.toContain(`export function ${exportName}`) + } + } + }) +}) + +const brunchChromeFixture = { + cwd: "/tmp/brunch", + chatMode: "interactive" as const, + phase: "ready" as const, + spec: { + id: "spec-1", + title: "Fixture spec", + }, + session: { + id: "session-1", + label: "Fixture session", + }, +} + +function createRecordingExtensionApi() { + const eventNames: string[] = [] + const toolNames: string[] = [] + const commandNames: string[] = [] + const shortcuts: string[] = [] + const messageRenderers: string[] = [] + const onSessionBoundary = async () => {} + const api = { + on(eventName: string) { + eventNames.push(eventName) + }, + registerTool(tool: { name: string }) { + toolNames.push(tool.name) + }, + registerCommand(name: string) { + commandNames.push(name) + }, + registerShortcut(name: string) { + shortcuts.push(name) + }, + registerMessageRenderer(type: string) { + messageRenderers.push(type) + }, + sendMessage() {}, + getAllTools: () => + [ + "read", + "grep", + "find", + "ls", + "present_alternatives", + PRESENT_QUESTION_TOOL, + PRESENT_OPTIONS_TOOL, + REQUEST_ANSWER_TOOL, + REQUEST_CHOICE_TOOL, + REQUEST_CHOICES_TOOL, + "bash", + "edit", + "write", + ].map((name) => ({ name })), + setActiveTools() {}, + } + return { + api: api as never, + eventNames, + toolNames, + commandNames, + shortcuts, + messageRenderers, + onSessionBoundary, + } +} + +async function listExtensionEntrypoints(): Promise<string[]> { + const extensionsDir = join(projectRoot(), "src/tui-client/.pi/extensions") + const entries = await readdir(extensionsDir, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + const path = join(extensionsDir, entry.name) + if (entry.isFile() && entry.name.endsWith(".ts")) files.push(path) + if (entry.isDirectory()) { + const indexFile = join(path, "index.ts") + if (await fileExists(indexFile)) files.push(indexFile) + } + } + return files +} + +async function fileExists(file: string): Promise<boolean> { + try { + await access(file) + return true + } catch { + return false + } +} + +function projectRoot(): string { + return dirname( + dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), + ) +} diff --git a/src/tui-client/.pi/extensions/alternatives.ts b/src/tui-client/.pi/extensions/alternatives.ts index 99825d8a..406c33cf 100644 --- a/src/tui-client/.pi/extensions/alternatives.ts +++ b/src/tui-client/.pi/extensions/alternatives.ts @@ -206,11 +206,4 @@ export function registerBrunchAlternatives(pi: ExtensionAPI) { }) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 60, -} as const - -export const registerBrunchProductExtension = registerBrunchAlternatives - export default registerBrunchAlternatives diff --git a/src/tui-client/.pi/extensions/chrome.ts b/src/tui-client/.pi/extensions/chrome.ts index 52d608eb..7e451504 100644 --- a/src/tui-client/.pi/extensions/chrome.ts +++ b/src/tui-client/.pi/extensions/chrome.ts @@ -161,17 +161,12 @@ export function renderBrunchChrome( ui.setTitle(`brunch — ${chrome.spec?.title ?? chrome.cwd}`) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 20, -} as const - -export function registerBrunchProductExtension( +export function registerBrunchChrome( pi: ExtensionAPI, - context: { chrome: BrunchChromeState }, + chrome: BrunchChromeState, ): void { pi.on("session_start", async (_event, ctx) => { - renderBrunchChrome(ctx.ui, context.chrome) + renderBrunchChrome(ctx.ui, chrome) }) } diff --git a/src/tui-client/.pi/extensions/command-policy.ts b/src/tui-client/.pi/extensions/command-policy.ts index c74ebfc9..4b8649b6 100644 --- a/src/tui-client/.pi/extensions/command-policy.ts +++ b/src/tui-client/.pi/extensions/command-policy.ts @@ -14,11 +14,4 @@ export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { }) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 30, -} as const - -export const registerBrunchProductExtension = registerBrunchBranchPolicyHandlers - export default registerBrunchBranchPolicyHandlers diff --git a/src/tui-client/.pi/extensions/mention-autocomplete.ts b/src/tui-client/.pi/extensions/mention-autocomplete.ts index 3d7dde47..69176272 100644 --- a/src/tui-client/.pi/extensions/mention-autocomplete.ts +++ b/src/tui-client/.pi/extensions/mention-autocomplete.ts @@ -154,16 +154,4 @@ function candidateToAutocompleteItem( } } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 50, -} as const - -export function registerBrunchProductExtension( - pi: ExtensionAPI, - context: { graphMentionSource: GraphMentionSource }, -): void { - registerBrunchMentionAutocomplete(pi, context.graphMentionSource) -} - export default registerBrunchMentionAutocomplete diff --git a/src/tui-client/.pi/extensions/operational-mode.ts b/src/tui-client/.pi/extensions/operational-mode.ts index 290b427d..ef8c52ce 100644 --- a/src/tui-client/.pi/extensions/operational-mode.ts +++ b/src/tui-client/.pi/extensions/operational-mode.ts @@ -602,12 +602,4 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { })) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 40, -} as const - -export const registerBrunchProductExtension = - registerBrunchOperationalModePolicy - export default registerBrunchOperationalModePolicy diff --git a/src/tui-client/.pi/extensions/session-lifecycle.ts b/src/tui-client/.pi/extensions/session-lifecycle.ts index fd762b55..a70b46b3 100644 --- a/src/tui-client/.pi/extensions/session-lifecycle.ts +++ b/src/tui-client/.pi/extensions/session-lifecycle.ts @@ -34,22 +34,17 @@ export function registerBrunchSessionBoundaryRefreshHandlers( }) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 10, -} as const - -export function registerBrunchProductExtension( +export function registerBrunchSessionBoundary( pi: ExtensionAPI, - context: { onSessionBoundary?: BrunchSessionBoundaryHandler }, + onSessionBoundary?: BrunchSessionBoundaryHandler, ): void { pi.on("session_start", async (_event, ctx) => { await bindBrunchSessionBoundary( ctx.sessionManager as SessionManager, - context.onSessionBoundary, + onSessionBoundary, ) }) - registerBrunchSessionBoundaryRefreshHandlers(pi, context.onSessionBoundary) + registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary) } -export default registerBrunchSessionBoundaryRefreshHandlers +export default registerBrunchSessionBoundary diff --git a/src/tui-client/.pi/extensions/structured-exchange/index.ts b/src/tui-client/.pi/extensions/structured-exchange/index.ts index ea32fdb1..a9421c4f 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/index.ts +++ b/src/tui-client/.pi/extensions/structured-exchange/index.ts @@ -68,17 +68,10 @@ void presentReviewSetTool void presentCandidatesTool void requestReviewTool -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 70, -} as const - -export function registerBrunchProductExtension(pi: ExtensionAPI): void { - registerStructuredExchange(pi) -} - -export default function registerStructuredExchange(pi: ExtensionAPI) { +export function registerStructuredExchange(pi: ExtensionAPI) { for (const tool of STRUCTURED_EXCHANGE_IMPLEMENTED_TOOLS) { pi.registerTool(tool) } } + +export default registerStructuredExchange diff --git a/src/tui-client/.pi/extensions/workspace-dialog.ts b/src/tui-client/.pi/extensions/workspace-dialog.ts index c9e8f7eb..97f86d7f 100644 --- a/src/tui-client/.pi/extensions/workspace-dialog.ts +++ b/src/tui-client/.pi/extensions/workspace-dialog.ts @@ -42,18 +42,6 @@ export function registerBrunchWorkspaceDialog( }) } -export const brunchExtensionMeta = { - productStatus: "ready", - loadOrder: 80, -} as const - -export function registerBrunchProductExtension( - pi: ExtensionAPI, - context: { options: BrunchSpecSessionPickerOptions }, -): void { - registerBrunchWorkspaceDialog(pi, context.options) -} - export default function brunchWorkspaceDialog(pi: ExtensionAPI): void { pi.registerCommand(BRUNCH_WORKSPACE_COMMAND, { description: "Open the Brunch spec/session picker", diff --git a/src/tui-client/pi-extension-shell.ts b/src/tui-client/pi-extension-shell.ts index 89398ee3..10c5b8c1 100644 --- a/src/tui-client/pi-extension-shell.ts +++ b/src/tui-client/pi-extension-shell.ts @@ -1,17 +1,23 @@ -import { access, readdir } from "node:fs/promises" -import { dirname, extname, join, relative, sep } from "node:path" -import { fileURLToPath, pathToFileURL } from "node:url" - import { type ExtensionAPI, type ExtensionFactory, } from "@earendil-works/pi-coding-agent" +import { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" +import { registerBrunchChrome } from "./.pi/extensions/chrome.js" +import { registerBrunchBranchPolicyHandlers } from "./.pi/extensions/command-policy.js" import { type GraphMentionSource } from "./.pi/extensions/mention-autocomplete.js" -import { FIXTURE_GRAPH_MENTION_SOURCE } from "./.pi/extensions/mention-autocomplete.js" +import { + FIXTURE_GRAPH_MENTION_SOURCE, + registerBrunchMentionAutocomplete, +} from "./.pi/extensions/mention-autocomplete.js" +import { registerBrunchOperationalModePolicy } from "./.pi/extensions/operational-mode.js" +import { registerBrunchSessionBoundary } from "./.pi/extensions/session-lifecycle.js" +import { registerStructuredExchange } from "./.pi/extensions/structured-exchange/index.js" import { type BrunchChromeState } from "./.pi/extensions/chrome.js" import { type BrunchSessionBoundaryHandler } from "./.pi/extensions/session-lifecycle.js" import { type BrunchSpecSessionPickerOptions } from "./.pi/extensions/workspace-dialog.js" +import { registerBrunchWorkspaceDialog } from "./.pi/extensions/workspace-dialog.js" export { registerBrunchAlternatives } from "./.pi/extensions/alternatives.js" export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from "./.pi/extensions/command-policy.js" @@ -41,6 +47,7 @@ export { export { chromeStateForWorkspace, projectBrunchChromeFooterLines, + registerBrunchChrome, renderBrunchChrome, type BrunchChromeCoherenceVerdict, type BrunchChromeFooterTelemetry, @@ -51,6 +58,7 @@ export { } from "./.pi/extensions/chrome.js" export { bindBrunchSessionBoundary, + registerBrunchSessionBoundary, registerBrunchSessionBoundaryRefreshHandlers, type BrunchSessionBoundaryHandler, } from "./.pi/extensions/session-lifecycle.js" @@ -68,137 +76,31 @@ export interface BrunchPiExtensionShellOptions graphMentionSource?: GraphMentionSource } -export interface BrunchProductExtensionContext { - chrome: BrunchChromeState - onSessionBoundary?: BrunchSessionBoundaryHandler - options: BrunchPiExtensionShellOptions - graphMentionSource: GraphMentionSource -} - -export const BRUNCH_PRODUCT_EXTENSION_READY = "ready" as const - -export interface BrunchExtensionMeta { - productStatus: typeof BRUNCH_PRODUCT_EXTENSION_READY | "wip" | "dev-only" - loadOrder?: number -} - -export type BrunchProductExtensionRegistration = ( +type BrunchProductExtensionRegistrar = ( pi: ExtensionAPI, - context: BrunchProductExtensionContext, ) => void | Promise<void> -export interface BrunchProductExtensionEntry { - path: string - meta: BrunchExtensionMeta & { - productStatus: typeof BRUNCH_PRODUCT_EXTENSION_READY - } - registerProductExtension: BrunchProductExtensionRegistration -} - -interface BrunchExtensionModule { - brunchExtensionMeta?: BrunchExtensionMeta - registerBrunchProductExtension?: BrunchProductExtensionRegistration -} - -const EXTENSIONS_DIR = join( - dirname(fileURLToPath(import.meta.url)), - ".pi", - "extensions", -) - -export async function discoverBrunchProductExtensionEntries( - extensionsDir: string = EXTENSIONS_DIR, -): Promise<BrunchProductExtensionEntry[]> { - const entryFiles = await discoverExtensionEntryFiles(extensionsDir) - const entries = await Promise.all( - entryFiles.map(async (file) => { - const module = (await import( - pathToFileURL(file).href - )) as BrunchExtensionModule - const meta = module.brunchExtensionMeta - if (meta?.productStatus !== BRUNCH_PRODUCT_EXTENSION_READY) { - return undefined - } - if (module.registerBrunchProductExtension === undefined) { - throw new Error( - `Prod-ready Brunch extension ${file} must export registerBrunchProductExtension`, - ) - } - return { - path: normalizeExtensionPath(relative(extensionsDir, file)), - meta: { - ...meta, - productStatus: BRUNCH_PRODUCT_EXTENSION_READY, - }, - registerProductExtension: module.registerBrunchProductExtension, - } - }), - ) - return entries - .filter( - (entry): entry is BrunchProductExtensionEntry => entry !== undefined, - ) - .sort( - (left, right) => - (left.meta.loadOrder ?? 0) - (right.meta.loadOrder ?? 0) || - left.path.localeCompare(right.path), - ) -} - -async function discoverExtensionEntryFiles( - extensionsDir: string, -): Promise<string[]> { - const dirents = await readdir(extensionsDir, { withFileTypes: true }) - const files: string[] = [] - for (const dirent of dirents) { - const file = join(extensionsDir, dirent.name) - if (dirent.isFile() && isExtensionEntrypointFile(dirent.name)) { - files.push(file) - } - if (dirent.isDirectory()) { - for (const extension of [".ts", ".js"]) { - const indexFile = join(file, `index${extension}`) - if (await fileExists(indexFile)) files.push(indexFile) - } - } - } - return files -} - -function isExtensionEntrypointFile(file: string): boolean { - const extension = extname(file) - return (extension === ".ts" || extension === ".js") && !file.endsWith(".d.ts") -} - -async function fileExists(file: string): Promise<boolean> { - try { - await access(file) - return true - } catch { - return false - } -} - -function normalizeExtensionPath(path: string): string { - return path.split(sep).join("/") -} - export function createBrunchPiExtensionShell( chrome: BrunchChromeState, onSessionBoundary: BrunchSessionBoundaryHandler | undefined, options: BrunchPiExtensionShellOptions, ): ExtensionFactory { return async (pi) => { - const context: BrunchProductExtensionContext = { - chrome, - ...(onSessionBoundary === undefined ? {} : { onSessionBoundary }), - options, - graphMentionSource: - options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE, - } - const entries = await discoverBrunchProductExtensionEntries() - for (const entry of entries) { - await entry.registerProductExtension(pi, context) + const graphMentionSource = + options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE + const extensions: readonly BrunchProductExtensionRegistrar[] = [ + (api) => registerBrunchSessionBoundary(api, onSessionBoundary), + (api) => registerBrunchChrome(api, chrome), + registerBrunchBranchPolicyHandlers, + registerBrunchOperationalModePolicy, + (api) => registerBrunchMentionAutocomplete(api, graphMentionSource), + registerBrunchAlternatives, + registerStructuredExchange, + (api) => registerBrunchWorkspaceDialog(api, options), + ] + + for (const registerExtension of extensions) { + await registerExtension(pi) } } } From adcd881fe457eac4672944e718e0e8a5b72abade Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 14:19:10 +0200 Subject: [PATCH 144/164] Canonicalize fixture root --- .brunch-fixtures/README.md | 37 ----------------- .fixtures/README.md | 40 +++++++++++++++++++ .../brief-001/scripted-001/scripted-001.jsonl | 0 .../scripted-001/scripted-001.meta.json | 0 .../brief-002/scripted-001/scripted-001.jsonl | 0 .../scripted-001/scripted-001.meta.json | 0 .../brief-003/scripted-001/scripted-001.jsonl | 0 .../scripted-001/scripted-001.meta.json | 0 .../briefs/brief-001-identity-reference.json | 0 .../briefs/brief-002-state-lifecycle.json | 0 .../briefs/brief-003-derived-views.json | 0 .oxfmtrc.json | 2 +- .oxlintrc.json | 2 +- docs/README.md | 3 +- docs/architecture/fixture-strategy.md | 10 ++--- docs/archive/PLAN_HISTORY.md | 2 +- docs/design/ELICITATION_LENSES.md | 2 +- memory/PLAN.md | 4 +- memory/SPEC.md | 6 +-- src/jsonl-session-viability.test.ts | 4 +- src/probes/brief-library.test.ts | 2 +- src/probes/fixture-capture.test.ts | 8 ++-- src/probes/fixture-capture.ts | 9 +---- src/probes/scripts/verify-m1.sh | 4 +- 24 files changed, 65 insertions(+), 70 deletions(-) delete mode 100644 .brunch-fixtures/README.md create mode 100644 .fixtures/README.md rename {.brunch-fixtures => .fixtures}/brief-001/scripted-001/scripted-001.jsonl (100%) rename {.brunch-fixtures => .fixtures}/brief-001/scripted-001/scripted-001.meta.json (100%) rename {.brunch-fixtures => .fixtures}/brief-002/scripted-001/scripted-001.jsonl (100%) rename {.brunch-fixtures => .fixtures}/brief-002/scripted-001/scripted-001.meta.json (100%) rename {.brunch-fixtures => .fixtures}/brief-003/scripted-001/scripted-001.jsonl (100%) rename {.brunch-fixtures => .fixtures}/brief-003/scripted-001/scripted-001.meta.json (100%) rename {.brunch-fixtures => .fixtures}/briefs/brief-001-identity-reference.json (100%) rename {.brunch-fixtures => .fixtures}/briefs/brief-002-state-lifecycle.json (100%) rename {.brunch-fixtures => .fixtures}/briefs/brief-003-derived-views.json (100%) diff --git a/.brunch-fixtures/README.md b/.brunch-fixtures/README.md deleted file mode 100644 index bd8dfe73..00000000 --- a/.brunch-fixtures/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# `.brunch-fixtures/` - -Curated test inputs and captured golden runs for the Brunch POC. - -This directory is the on-disk home of the fixture strategy described in -[docs/architecture/fixture-strategy.md](../docs/architecture/fixture-strategy.md). - -## Layout - -``` -.brunch-fixtures/ -├── briefs/ # Curated product briefs (JSON) -│ ├── brief-001-identity-reference.json -│ ├── brief-002-state-lifecycle.json -│ ├── brief-003-derived-views.json -│ └── ... -└── <brief-id>/ - └── <run-id>/ - ├── <run-id>.jsonl # Captured transcript - ├── <run-id>.meta.json # Brief id, driver mode, session, projection summary - ├── <run-id>.graph.json # Deferred until the graph plane exists - └── <run-id>.coherence.json # Deferred until coherence is first-class -``` - -## Status - -The first M1 briefs live under `briefs/` as JSON files. Captured runs are added -under each brief id by the JSON-RPC stdio fixture driver. - -## Conventions - -- Briefs are short, human-readable JSON; the captured runs are the heavy data. -- Brief ids are kebab-case and stable; runs are timestamped, content-hashed, or - deterministic for reviewable scripted captures. -- Replay regression runs check transcript reproduction first. Property and - adversarial / generative checks come online as later milestones provide graph - and coherence artifacts. diff --git a/.fixtures/README.md b/.fixtures/README.md new file mode 100644 index 00000000..6efd0a4a --- /dev/null +++ b/.fixtures/README.md @@ -0,0 +1,40 @@ +# `.fixtures/` + +Curated test inputs, captured golden runs, and probe-oracle review bundles for the Brunch POC. + +This directory is the on-disk home of the fixture strategy described in +[docs/architecture/fixture-strategy.md](../docs/architecture/fixture-strategy.md). + +## Layout + +``` +.fixtures/ +├── briefs/ # Curated product briefs (JSON) +│ ├── brief-001-identity-reference.json +│ ├── brief-002-state-lifecycle.json +│ ├── brief-003-derived-views.json +│ └── ... +├── <brief-id>/ +│ └── <run-id>/ +│ ├── <run-id>.jsonl # Captured transcript +│ ├── <run-id>.meta.json # Brief id, driver mode, session, projection summary +│ ├── <run-id>.graph.json # Deferred until the graph plane exists +│ └── <run-id>.coherence.json # Deferred until coherence is first-class +└── runs/ + └── <probe-id>/ + └── <run-id>/ + ├── session.jsonl # Probe source transcript + ├── transcript.md # Human-readable semantic rendering + └── report.json # Probe report and artifact paths +``` + +## Status + +The first M1 briefs live under `briefs/` as JSON files. Captured brief runs are added under each brief id by the JSON-RPC stdio fixture driver. Probe-oracle review bundles that are not tied to a curated brief live under `runs/<probe-id>/<run-id>/`. + +## Conventions + +- Briefs are short, human-readable JSON; captured runs and probe bundles are the heavy data. +- Brief ids are kebab-case and stable; runs are timestamped, content-hashed, or deterministic for reviewable scripted captures. +- Replay regression runs check transcript reproduction first. Property and adversarial / generative checks come online as later milestones provide graph and coherence artifacts. +- Probe bundles keep executable reports and human transcript renderings colocated so a reviewer can compare the oracle output against transcript evidence. diff --git a/.brunch-fixtures/brief-001/scripted-001/scripted-001.jsonl b/.fixtures/brief-001/scripted-001/scripted-001.jsonl similarity index 100% rename from .brunch-fixtures/brief-001/scripted-001/scripted-001.jsonl rename to .fixtures/brief-001/scripted-001/scripted-001.jsonl diff --git a/.brunch-fixtures/brief-001/scripted-001/scripted-001.meta.json b/.fixtures/brief-001/scripted-001/scripted-001.meta.json similarity index 100% rename from .brunch-fixtures/brief-001/scripted-001/scripted-001.meta.json rename to .fixtures/brief-001/scripted-001/scripted-001.meta.json diff --git a/.brunch-fixtures/brief-002/scripted-001/scripted-001.jsonl b/.fixtures/brief-002/scripted-001/scripted-001.jsonl similarity index 100% rename from .brunch-fixtures/brief-002/scripted-001/scripted-001.jsonl rename to .fixtures/brief-002/scripted-001/scripted-001.jsonl diff --git a/.brunch-fixtures/brief-002/scripted-001/scripted-001.meta.json b/.fixtures/brief-002/scripted-001/scripted-001.meta.json similarity index 100% rename from .brunch-fixtures/brief-002/scripted-001/scripted-001.meta.json rename to .fixtures/brief-002/scripted-001/scripted-001.meta.json diff --git a/.brunch-fixtures/brief-003/scripted-001/scripted-001.jsonl b/.fixtures/brief-003/scripted-001/scripted-001.jsonl similarity index 100% rename from .brunch-fixtures/brief-003/scripted-001/scripted-001.jsonl rename to .fixtures/brief-003/scripted-001/scripted-001.jsonl diff --git a/.brunch-fixtures/brief-003/scripted-001/scripted-001.meta.json b/.fixtures/brief-003/scripted-001/scripted-001.meta.json similarity index 100% rename from .brunch-fixtures/brief-003/scripted-001/scripted-001.meta.json rename to .fixtures/brief-003/scripted-001/scripted-001.meta.json diff --git a/.brunch-fixtures/briefs/brief-001-identity-reference.json b/.fixtures/briefs/brief-001-identity-reference.json similarity index 100% rename from .brunch-fixtures/briefs/brief-001-identity-reference.json rename to .fixtures/briefs/brief-001-identity-reference.json diff --git a/.brunch-fixtures/briefs/brief-002-state-lifecycle.json b/.fixtures/briefs/brief-002-state-lifecycle.json similarity index 100% rename from .brunch-fixtures/briefs/brief-002-state-lifecycle.json rename to .fixtures/briefs/brief-002-state-lifecycle.json diff --git a/.brunch-fixtures/briefs/brief-003-derived-views.json b/.fixtures/briefs/brief-003-derived-views.json similarity index 100% rename from .brunch-fixtures/briefs/brief-003-derived-views.json rename to .fixtures/briefs/brief-003-derived-views.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2101b2ed..ac19e92d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,5 +3,5 @@ "printWidth": 110, "singleQuote": true, "sortImports": true, - "ignorePatterns": ["*.md", "docs/**", "memory/**", "archive/**", ".brunch-fixtures/**", "dist/**"] + "ignorePatterns": ["*.md", "docs/**", "memory/**", "archive/**", ".fixtures/**", "dist/**"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index aa1bf094..7c1b225e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,5 +7,5 @@ "rules": { "typescript/no-deprecated": "error" }, - "ignorePatterns": ["dist", "archive", "docs", "memory", ".brunch-fixtures", "scripts"] + "ignorePatterns": ["dist", "archive", "docs", "memory", ".fixtures", "scripts"] } diff --git a/docs/README.md b/docs/README.md index e40ceed8..8a9c9a63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,8 +23,7 @@ planning state: ## Fixtures -[`.brunch-fixtures/`](../../.brunch-fixtures/) holds curated briefs and -captured golden runs. See the directory README for layout and conventions. +[`.fixtures/`](../.fixtures/) holds curated briefs, captured golden runs, and probe-oracle review bundles. See the directory README for layout and conventions. ## Behavioral kernels diff --git a/docs/architecture/fixture-strategy.md b/docs/architecture/fixture-strategy.md index d6828cb9..b1c8c52a 100644 --- a/docs/architecture/fixture-strategy.md +++ b/docs/architecture/fixture-strategy.md @@ -38,7 +38,7 @@ This strategy exists because two things are being remodelled at once during the ▼ ╭────────────────────────────────╮ │ Run fixture bundle │ - │ .brunch-fixtures/<brief-id>/ │ + │ .fixtures/<brief-id>/ │ │ <run-id>.jsonl │ │ <run-id>.graph.json │ │ <run-id>.coherence.json │ @@ -92,7 +92,7 @@ Briefs are short, human-readable, and curated. The run artefacts are the heavy d ### Brief fixture format -`.brunch-fixtures/briefs/brief-002-state-lifecycle.json`: +`.fixtures/briefs/brief-002-state-lifecycle.json`: ```json { @@ -128,7 +128,7 @@ Briefs are short, human-readable, and curated. The run artefacts are the heavy d | 6 | **Verified sort algorithm** | Validation/normalization, formal properties only | Narrow but stretches `formal_property` requirements, `Obligation` nodes, `proof` / `model_check` validation methods, and assurance-level computation | | 7 | **"Notion meets Linear meets Slack"** | Forces scope-boundary clarification before any kernel can engage | Adversarial; stresses offer-first interaction and scope-card affordance | -Briefs #1–#3 are the first curated M1 seeds under `.brunch-fixtures/briefs/`. They are intentionally thin, human-reviewed product briefs for transcript/projection replay, not final evidence that Brunch's elicitation interaction logic or knowledge-flow model is correct. +Briefs #1–#3 are the first curated M1 seeds under `.fixtures/briefs/`. They are intentionally thin, human-reviewed product briefs for transcript/projection replay, not final evidence that Brunch's elicitation interaction logic or knowledge-flow model is correct. ### Brief #7 expectations — "Notion meets Linear meets Slack" @@ -196,7 +196,7 @@ If you change your mind, say so explicitly. ## Run fixture bundle -A captured run produces four artefacts under `.brunch-fixtures/<brief-id>/<run-id>/`: +A captured run produces four artefacts under `.fixtures/<brief-id>/<run-id>/`: | File | Contents | | --- | --- | @@ -232,7 +232,7 @@ The fixture harness threads through the existing milestone ladder; it does not n ## Open questions 1. Whether the agent-as-user should be a pi-coding-agent session or a thinner harness (just a model client). The pi-coding-agent path gets transcript capture for free; the thinner path is cheaper to run in CI. -2. Whether briefs should be stored under `.brunch-fixtures/` in the brunch-next repo, in a sibling repository, or in `docs/architecture/artifacts/` alongside other captured exhibits. +2. Whether to keep fixture artifacts in this repository long-term or move large post-POC corpora to a sibling repository. For the POC, `.fixtures/` is the canonical in-repo fixture root for both curated briefs and probe-oracle review bundles. 3. Whether replay regression should attempt full transcript reproduction or only assert that the *graph* matches after a free-running replay. Full reproduction is brittle to model upgrades; graph-only is more durable but loses transcript-level signal. 4. Whether the agent-as-user should run with the same model as the Brunch session under test, or a deliberately different one (to surface model-dependent kernel-card behaviour). 5. Whether to surface a `brunch fixtures capture <brief-id>` and `brunch fixtures replay <brief-id> <run-id>` CLI sub-command set, or keep fixture tooling external to the Brunch binary. diff --git a/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md index 1cd21e1d..5309f86b 100644 --- a/docs/archive/PLAN_HISTORY.md +++ b/docs/archive/PLAN_HISTORY.md @@ -32,7 +32,7 @@ Archived from `memory/PLAN.md` so the live plan only carries active, next, horiz - **Status:** done - **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression fixtures for at least briefs #1–#3. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. - **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. -- **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.brunch-fixtures/`; the first three curated briefs are captured. +- **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.fixtures/`; the first three curated briefs are captured. - **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./src/probes/scripts/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the probe. - **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. - **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L diff --git a/docs/design/ELICITATION_LENSES.md b/docs/design/ELICITATION_LENSES.md index ec279442..8c2a1fff 100644 --- a/docs/design/ELICITATION_LENSES.md +++ b/docs/design/ELICITATION_LENSES.md @@ -110,7 +110,7 @@ Scenarios are a recurring rendering primitive across lenses with three distingui All three share a shape: a particular vignette, deliberately under-specified at the boundaries (fat-marker), illustrative not prescriptive, carrying an implicit "vs not-this". A scenario-entry primitive may eventually be worth extracting as a typed custom entry; for now scenarios live as transcript content with role distinguished by context. -**Terminology guard.** Scenarios (user-facing, runtime) are distinct from **briefs** (`.brunch-fixtures/briefs/`, dev-only inputs for the agent-as-user fixture driver). Briefs are testing infrastructure; scenarios are product surface. Do not conflate. +**Terminology guard.** Scenarios (user-facing, runtime) are distinct from **briefs** (`.fixtures/briefs/`, dev-only inputs for the agent-as-user fixture driver). Briefs are testing infrastructure; scenarios are product surface. Do not conflate. ## Establishment offers and intent hints diff --git a/memory/PLAN.md b/memory/PLAN.md index 566186b6..b083c4fd 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -192,7 +192,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** bounded feature - **Status:** not-started - **Objective:** Author and review briefs #4–#7 plus the adversarial second tier per fixture-strategy. Outputs are JSON briefs and one or two reviewer notes. -- **Acceptance:** Briefs #1–#7 present in `.brunch-fixtures/briefs/`; adversarial briefs present with documented targets; expectations for brief #7 satisfied per fixture-strategy. +- **Acceptance:** Briefs #1–#7 present in `.fixtures/briefs/`; adversarial briefs present with documented targets; expectations for brief #7 satisfied per fixture-strategy. - **Verification:** Doc review against fixture-strategy expectations; schema/checker validation for brief JSON once available; spot-replay if the relevant harness milestone has landed. - **Cross-cutting obligations:** Keep the brief corpus aligned with the canonical replay/property/adversarial fixture model rather than letting it drift into a loose examples folder. - **Traceability:** R20 / A5-L @@ -278,7 +278,7 @@ The POC should maximize assumption falsification rather than merely implement mi - 2026-05-22 `web-shell` — Done: M3 now serves the native React web shell over one persistent WebSocket RPC client, blocks/adjudicates branchy transcript shapes for session-consuming reads, serves only static HTTP assets (no REST product reads), projects explicit durable sessions through a canonical Brunch session-envelope reader, renders assistant/user/prompt transcript rows, and keeps browser state as a read-only client attachment rather than a durable session. Verified: `npm run verify` after each slice plus direct host/WebSocket smoke for static HTML, missing REST product reads, explicit `{ sessionId, specId }` projections, transcript display, and exchange projection. Accepted deferral: qualitative browser-open smoke remains environment-blocked by the current macOS sandbox. - 2026-05-21 `jsonl-session-viability` — Done: Pi JSONL reload preserves coordinator-created binding-only sessions, first assistant/user flushes without duplicate prefixes, `/new` same-spec bindings, raw user/assistant payloads, representative Brunch custom entries, context-participating custom messages, continuity/compaction metadata, structured elicitation entries, defensive active-branch projection behavior, and M1 bundle-local replay parity for briefs #1–#3. Verified: `npm run verify` after each slice. Watch: M2 validates JSONL as sufficient for Brunch-supported linear sessions on current POC terms; branch-aware Brunch sessions are intentionally unsupported per D24-L, and later side-task, mention, and continuity frontiers still own their final payload semantics. -- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.brunch-fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./src/probes/scripts/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. +- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./src/probes/scripts/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. Older history: `docs/archive/PLAN_HISTORY.md` diff --git a/memory/SPEC.md b/memory/SPEC.md index 6307bc2f..c59aa227 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -418,8 +418,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Epistemic status** | Confidence basis: `observed | asserted | assumed | inferred`. Like `authority`, this is a context-shaping label for attention, grouping, and compression rather than a complete theory of truth. | | **Framing-as** | Orthogonal modality classifying a node's product role (e.g. `problem`, `persona`, `non_goal`) within an allowed matrix. | | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | -| **Brief** | A short curated product brief in `.brunch-fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | -| **Capture / Run / Fixture** | A captured agent-as-user run produces durable review artifacts. Brief-library captures use `.brunch-fixtures/<brief-id>/<run-id>/` with `.jsonl`, deferred `.graph.json` / `.coherence.json`, and `.meta.json` artifacts; product probe-oracle captures may use `.fixtures/runs/<probe-id>/<run-id>/` with `session.jsonl`, `transcript.md`, and `report.json` when the review object is a probe run rather than a curated brief. | +| **Brief** | A short curated product brief in `.fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | +| **Capture / Run / Fixture** | A captured agent-as-user run produces durable review artifacts. Brief-library captures use `.fixtures/<brief-id>/<run-id>/` with `.jsonl`, deferred `.graph.json` / `.coherence.json`, and `.meta.json` artifacts; product probe-oracle captures may use `.fixtures/runs/<probe-id>/<run-id>/` with `session.jsonl`, `transcript.md`, and `report.json` when the review object is a probe run rather than a curated brief. | | **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent roles (`elicitor` / `reviewer` / `reconciler` / deferred observer-auditor roles) remain orthogonal. | | **Single-exchange elicitation flow** | A prompt/answer exchange such as step-by-step questioning or contrastive disambiguation. The elicitor captures high-confidence extractive content synchronously post-exchange; low-confidence implications stay in preface/question material. | | **Batch-proposal flow** | A proposal/review flow with structured entity-draft payloads in `brunch.review_set_proposal` entries. Durable graph changes land only through review-set approval. | @@ -484,7 +484,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo - **Gate before commit:** `npm run verify`. All steps must pass; no override. - **Failure protocol:** stop on first failure; the failure becomes the must-fix task; re-run the stack from step 1; only proceed when all checks pass. - **Frontier completion:** manual smoke can prove presentation life, but any durable product claim must also have an artifact/query oracle, property/round-trip test, contract test, or fixture assertion tied to the canonical store or projection handler that owns the fact. -- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.brunch-fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. Transcript renderers default to the Brunch-semantic view: structured-exchange and Brunch custom transcript evidence included, unrelated generic tool results skipped unless an explicit raw/debug mode is requested. +- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. Transcript renderers default to the Brunch-semantic view: structured-exchange and Brunch custom transcript evidence included, unrelated generic tool results skipped unless an explicit raw/debug mode is requested. ### Oracle Strategy by Loop Tier diff --git a/src/jsonl-session-viability.test.ts b/src/jsonl-session-viability.test.ts index b7a04c76..3928cfce 100644 --- a/src/jsonl-session-viability.test.ts +++ b/src/jsonl-session-viability.test.ts @@ -375,12 +375,12 @@ describe("M1 fixture JSONL replay parity", () => { async function loadM1FixtureBundle( briefId: typeof M1_FIXTURE_IDS[number], ): Promise<M1FixtureBundle> { - const bundleDir = join(".brunch-fixtures", briefId, M1_RUN_ID) + const bundleDir = join(".fixtures", briefId, M1_RUN_ID) const metaPath = join(bundleDir, `${M1_RUN_ID}.meta.json`) const meta = JSON.parse(await readFile(metaPath, "utf8")) as M1FixtureMeta const jsonlPath = join(dirname(metaPath), meta.artifacts.jsonl) const briefPath = join( - ".brunch-fixtures", + ".fixtures", "briefs", `${briefId}-${briefSlug(briefId)}.json`, ) diff --git a/src/probes/brief-library.test.ts b/src/probes/brief-library.test.ts index eaa7e7f9..b6147fea 100644 --- a/src/probes/brief-library.test.ts +++ b/src/probes/brief-library.test.ts @@ -4,7 +4,7 @@ import { loadBriefLibrary } from "./brief-library.js" describe("fixture brief library", () => { it("loads the first three deterministic product briefs", async () => { - const briefs = await loadBriefLibrary(".brunch-fixtures/briefs") + const briefs = await loadBriefLibrary(".fixtures/briefs") expect(briefs.map((brief) => brief.id)).toEqual([ "brief-001", diff --git a/src/probes/fixture-capture.test.ts b/src/probes/fixture-capture.test.ts index 6d6faafa..5d9da24b 100644 --- a/src/probes/fixture-capture.test.ts +++ b/src/probes/fixture-capture.test.ts @@ -112,9 +112,7 @@ describe("fixture capture", () => { coordinator, }) - expect(result.runDir).toBe( - join(cwd, ".brunch-fixtures", "brief-001", "run-001"), - ) + expect(result.runDir).toBe(join(cwd, ".fixtures", "brief-001", "run-001")) expect(JSON.parse(await readFile(result.metaFile, "utf8"))).toMatchObject({ schemaVersion: 1, briefId: "brief-001", @@ -147,7 +145,7 @@ describe("fixture capture", () => { it("replays captured brief bundles through exchange projection", async () => { for (const briefId of ["brief-001", "brief-002", "brief-003"]) { const runId = "scripted-001" - const runDir = join(".brunch-fixtures", briefId, runId) + const runDir = join(".fixtures", briefId, runId) const metadata = JSON.parse( await readFile(join(runDir, `${runId}.meta.json`), "utf8"), ) as { @@ -178,7 +176,7 @@ describe("fixture capture", () => { const results = await captureDeterministicBriefRuns({ cwd, - briefsDir: ".brunch-fixtures/briefs", + briefsDir: ".fixtures/briefs", runId: "scripted-001", timestamp: "2026-05-21T00:00:00.000Z", }) diff --git a/src/probes/fixture-capture.ts b/src/probes/fixture-capture.ts index d962a170..d3fc80b9 100644 --- a/src/probes/fixture-capture.ts +++ b/src/probes/fixture-capture.ts @@ -51,12 +51,7 @@ export async function captureFixtureRun( options, "session.elicitationExchanges", ) - const runDir = join( - options.cwd, - ".brunch-fixtures", - options.briefId, - options.runId, - ) + const runDir = join(options.cwd, ".fixtures", options.briefId, options.runId) const jsonlFile = join(runDir, `${options.runId}.jsonl`) const metaFile = join(runDir, `${options.runId}.meta.json`) @@ -102,7 +97,7 @@ export async function captureDeterministicBriefRuns( options: DeterministicBriefRunOptions, ): Promise<FixtureCaptureResult[]> { const briefs = await loadBriefLibrary( - options.briefsDir ?? join(options.cwd, ".brunch-fixtures", "briefs"), + options.briefsDir ?? join(options.cwd, ".fixtures", "briefs"), ) const coordinator = createWorkspaceSessionCoordinator({ cwd: options.cwd }) const results: FixtureCaptureResult[] = [] diff --git a/src/probes/scripts/verify-m1.sh b/src/probes/scripts/verify-m1.sh index c68e1af3..6dcb3aaf 100755 --- a/src/probes/scripts/verify-m1.sh +++ b/src/probes/scripts/verify-m1.sh @@ -51,14 +51,14 @@ import { join } from "node:path" import { loadBriefLibrary } from "./src/probes/brief-library.ts" import { loadJsonlTranscriptEntries, projectElicitationExchanges } from "./src/elicitation-exchange.ts" -const briefs = await loadBriefLibrary(".brunch-fixtures/briefs") +const briefs = await loadBriefLibrary(".fixtures/briefs") const expected = new Map(briefs.map((brief) => [brief.id, brief.title])) const briefIds = ["brief-001", "brief-002", "brief-003"] const seenSpecIds = new Set() for (const briefId of briefIds) { const runId = "scripted-001" - const runDir = join(".brunch-fixtures", briefId, runId) + const runDir = join(".fixtures", briefId, runId) const jsonlFile = join(runDir, `${runId}.jsonl`) const metaFile = join(runDir, `${runId}.meta.json`) const entries = await loadJsonlTranscriptEntries(jsonlFile) From efd4b5ffea79b7c6b46cc4df8ad9abda73c90881 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 14:29:07 +0200 Subject: [PATCH 145/164] Retire stale brief fixture machinery --- .fixtures/README.md | 37 +-- .../brief-001/scripted-001/scripted-001.jsonl | 4 - .../scripted-001/scripted-001.meta.json | 28 -- .../brief-002/scripted-001/scripted-001.jsonl | 4 - .../scripted-001/scripted-001.meta.json | 28 -- .../brief-003/scripted-001/scripted-001.jsonl | 4 - .../scripted-001/scripted-001.meta.json | 28 -- .../briefs/brief-001-identity-reference.json | 19 -- .../briefs/brief-002-state-lifecycle.json | 19 -- .fixtures/briefs/brief-003-derived-views.json | 19 -- docs/README.md | 8 +- docs/architecture/fixture-strategy.md | 239 ---------------- docs/architecture/pi-seam-extensions.md | 2 +- docs/architecture/prd.md | 2 +- docs/architecture/probes-and-transcripts.md | 60 ++++ docs/archive/PLAN_HISTORY.md | 8 +- docs/design/ELICITATION_LENSES.md | 2 +- docs/design/REVIEW_SETS.md | 2 +- memory/PLAN.md | 64 ++--- memory/SPEC.md | 47 ++-- src/jsonl-session-viability.test.ts | 137 +-------- src/probes/brief-library.test.ts | 30 -- src/probes/brief-library.ts | 64 ----- src/probes/fixture-capture.test.ts | 265 ------------------ src/probes/fixture-capture.ts | 196 ------------- src/probes/probe-scripts.test.ts | 28 -- src/probes/scripts/verify-m1.sh | 135 --------- 27 files changed, 135 insertions(+), 1344 deletions(-) delete mode 100644 .fixtures/brief-001/scripted-001/scripted-001.jsonl delete mode 100644 .fixtures/brief-001/scripted-001/scripted-001.meta.json delete mode 100644 .fixtures/brief-002/scripted-001/scripted-001.jsonl delete mode 100644 .fixtures/brief-002/scripted-001/scripted-001.meta.json delete mode 100644 .fixtures/brief-003/scripted-001/scripted-001.jsonl delete mode 100644 .fixtures/brief-003/scripted-001/scripted-001.meta.json delete mode 100644 .fixtures/briefs/brief-001-identity-reference.json delete mode 100644 .fixtures/briefs/brief-002-state-lifecycle.json delete mode 100644 .fixtures/briefs/brief-003-derived-views.json delete mode 100644 docs/architecture/fixture-strategy.md create mode 100644 docs/architecture/probes-and-transcripts.md delete mode 100644 src/probes/brief-library.test.ts delete mode 100644 src/probes/brief-library.ts delete mode 100644 src/probes/fixture-capture.test.ts delete mode 100644 src/probes/fixture-capture.ts delete mode 100644 src/probes/probe-scripts.test.ts delete mode 100755 src/probes/scripts/verify-m1.sh diff --git a/.fixtures/README.md b/.fixtures/README.md index 6efd0a4a..6bb26280 100644 --- a/.fixtures/README.md +++ b/.fixtures/README.md @@ -1,40 +1,29 @@ # `.fixtures/` -Curated test inputs, captured golden runs, and probe-oracle review bundles for the Brunch POC. +Current probe artifacts and transcript evidence for the Brunch POC. -This directory is the on-disk home of the fixture strategy described in -[docs/architecture/fixture-strategy.md](../docs/architecture/fixture-strategy.md). +The active convention is **probe first, transcript-backed**: each committed run +must have a probe id, a run id, executable/reportable oracle output, and the +transcript artifact needed for human review. Brief-based golden fixtures may +return later, but they should be generated through this probe/transcript path +rather than a separate brief-library subsystem. + +See [`docs/architecture/probes-and-transcripts.md`](../docs/architecture/probes-and-transcripts.md) +for the current architecture. ## Layout ``` .fixtures/ -├── briefs/ # Curated product briefs (JSON) -│ ├── brief-001-identity-reference.json -│ ├── brief-002-state-lifecycle.json -│ ├── brief-003-derived-views.json -│ └── ... -├── <brief-id>/ -│ └── <run-id>/ -│ ├── <run-id>.jsonl # Captured transcript -│ ├── <run-id>.meta.json # Brief id, driver mode, session, projection summary -│ ├── <run-id>.graph.json # Deferred until the graph plane exists -│ └── <run-id>.coherence.json # Deferred until coherence is first-class └── runs/ └── <probe-id>/ └── <run-id>/ - ├── session.jsonl # Probe source transcript + ├── session.jsonl # Source transcript / canonical run evidence ├── transcript.md # Human-readable semantic rendering └── report.json # Probe report and artifact paths ``` -## Status - -The first M1 briefs live under `briefs/` as JSON files. Captured brief runs are added under each brief id by the JSON-RPC stdio fixture driver. Probe-oracle review bundles that are not tied to a curated brief live under `runs/<probe-id>/<run-id>/`. - -## Conventions +## Current runs -- Briefs are short, human-readable JSON; captured runs and probe bundles are the heavy data. -- Brief ids are kebab-case and stable; runs are timestamped, content-hashed, or deterministic for reviewable scripted captures. -- Replay regression runs check transcript reproduction first. Property and adversarial / generative checks come online as later milestones provide graph and coherence artifacts. -- Probe bundles keep executable reports and human transcript renderings colocated so a reviewer can compare the oracle output against transcript evidence. +- `runs/public-rpc-parity/2026-05-29-public-rpc-parity/` — FE-744 public Brunch + JSON-RPC structured-exchange parity proof. diff --git a/.fixtures/brief-001/scripted-001/scripted-001.jsonl b/.fixtures/brief-001/scripted-001/scripted-001.jsonl deleted file mode 100644 index f12ac2fb..00000000 --- a/.fixtures/brief-001/scripted-001/scripted-001.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"type":"session","version":3,"id":"019e4a13-1a50-7eb3-a4f7-644d4bff42bd","timestamp":"2026-05-21T10:27:06.448Z","cwd":"/Users/lunelson/Code/hashintel/brunch-next"} -{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e4a13-1a50-7eb3-a4f7-644d4bff42bd","specId":"spec-a3e8371c-af78-45ee-8b1a-7752317caa35","specTitle":"Team knowledge cards"},"id":"fc95cd0b","parentId":null,"timestamp":"2026-05-21T10:27:06.449Z"} -{"type":"custom_message","customType":"brunch.elicitation_prompt","content":"Elicitation prompt for brief-001 — Team knowledge cards: A small team wants a shared workspace for knowledge cards. Each card has a stable identity, a human-readable title, and may link to other cards even when titles change.","display":true,"id":"101e1d31","parentId":"fc95cd0b","timestamp":"2026-05-21T10:27:06.451Z"} -{"type":"message","id":"71549d11","parentId":"101e1d31","timestamp":"2026-05-21T10:27:06.451Z","message":{"role":"user","content":"I care about renaming cards without breaking links.\nTwo cards can have similar titles, so titles cannot be the only reference.","timestamp":1779321600000}} diff --git a/.fixtures/brief-001/scripted-001/scripted-001.meta.json b/.fixtures/brief-001/scripted-001/scripted-001.meta.json deleted file mode 100644 index f32de479..00000000 --- a/.fixtures/brief-001/scripted-001/scripted-001.meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": 1, - "briefId": "brief-001", - "runId": "scripted-001", - "timestamp": "2026-05-21T00:00:00.000Z", - "brunchVersion": "0.0.0", - "session": { - "id": "019e4a13-1a50-7eb3-a4f7-644d4bff42bd", - "sourceFile": "/Users/lunelson/Code/hashintel/brunch-next/.brunch/sessions/2026-05-21T10-27-06-448Z_019e4a13-1a50-7eb3-a4f7-644d4bff42bd.jsonl" - }, - "driver": { - "mode": "scripted-deterministic" - }, - "projectionSummary": { - "status": "ready", - "exchangeCount": 1, - "openPrompt": false - }, - "artifacts": { - "jsonl": "scripted-001.jsonl", - "graph": { - "status": "deferred" - }, - "coherence": { - "status": "deferred" - } - } -} diff --git a/.fixtures/brief-002/scripted-001/scripted-001.jsonl b/.fixtures/brief-002/scripted-001/scripted-001.jsonl deleted file mode 100644 index a8831a6e..00000000 --- a/.fixtures/brief-002/scripted-001/scripted-001.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"type":"session","version":3,"id":"019e4a13-1a57-77cd-8c77-43922404adca","timestamp":"2026-05-21T10:27:06.455Z","cwd":"/Users/lunelson/Code/hashintel/brunch-next"} -{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e4a13-1a57-77cd-8c77-43922404adca","specId":"spec-b37cabd4-12f5-4cea-bcbf-90a170e6f058","specTitle":"Approval workflow for vendor invoices"},"id":"99670cde","parentId":null,"timestamp":"2026-05-21T10:27:06.455Z"} -{"type":"custom_message","customType":"brunch.elicitation_prompt","content":"Elicitation prompt for brief-002 — Approval workflow for vendor invoices: A finance team needs invoices to move from draft to submitted to approved or rejected. Only budget owners can approve, and rejected invoices can be revised and resubmitted.","display":true,"id":"99dc94b3","parentId":"99670cde","timestamp":"2026-05-21T10:27:06.455Z"} -{"type":"message","id":"e0baa29d","parentId":"99dc94b3","timestamp":"2026-05-21T10:27:06.455Z","message":{"role":"user","content":"Rejected invoices are not terminal; they can go back to draft.\nApproved invoices should not be edited without reopening the workflow.","timestamp":1779321600000}} diff --git a/.fixtures/brief-002/scripted-001/scripted-001.meta.json b/.fixtures/brief-002/scripted-001/scripted-001.meta.json deleted file mode 100644 index dc67b14a..00000000 --- a/.fixtures/brief-002/scripted-001/scripted-001.meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": 1, - "briefId": "brief-002", - "runId": "scripted-001", - "timestamp": "2026-05-21T00:00:00.000Z", - "brunchVersion": "0.0.0", - "session": { - "id": "019e4a13-1a57-77cd-8c77-43922404adca", - "sourceFile": "/Users/lunelson/Code/hashintel/brunch-next/.brunch/sessions/2026-05-21T10-27-06-455Z_019e4a13-1a57-77cd-8c77-43922404adca.jsonl" - }, - "driver": { - "mode": "scripted-deterministic" - }, - "projectionSummary": { - "status": "ready", - "exchangeCount": 1, - "openPrompt": false - }, - "artifacts": { - "jsonl": "scripted-001.jsonl", - "graph": { - "status": "deferred" - }, - "coherence": { - "status": "deferred" - } - } -} diff --git a/.fixtures/brief-003/scripted-001/scripted-001.jsonl b/.fixtures/brief-003/scripted-001/scripted-001.jsonl deleted file mode 100644 index 76e4a2d5..00000000 --- a/.fixtures/brief-003/scripted-001/scripted-001.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"type":"session","version":3,"id":"019e4a13-1a59-712e-82ff-78900f655901","timestamp":"2026-05-21T10:27:06.457Z","cwd":"/Users/lunelson/Code/hashintel/brunch-next"} -{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e4a13-1a59-712e-82ff-78900f655901","specId":"spec-b804de5f-762e-4d27-a538-8b8d38139bec","specTitle":"Project dashboard rollups"},"id":"74df4b55","parentId":null,"timestamp":"2026-05-21T10:27:06.457Z"} -{"type":"custom_message","customType":"brunch.elicitation_prompt","content":"Elicitation prompt for brief-003 — Project dashboard rollups: A product lead wants a dashboard that rolls task status, blockers, and recent decisions up from individual project notes into one current view.","display":true,"id":"72fa4dfe","parentId":"74df4b55","timestamp":"2026-05-21T10:27:06.457Z"} -{"type":"message","id":"d47729ba","parentId":"72fa4dfe","timestamp":"2026-05-21T10:27:06.457Z","message":{"role":"user","content":"If the source note changes, the dashboard should not silently stay stale.\nRecent decisions should show where they came from.","timestamp":1779321600000}} diff --git a/.fixtures/brief-003/scripted-001/scripted-001.meta.json b/.fixtures/brief-003/scripted-001/scripted-001.meta.json deleted file mode 100644 index df02c92b..00000000 --- a/.fixtures/brief-003/scripted-001/scripted-001.meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": 1, - "briefId": "brief-003", - "runId": "scripted-001", - "timestamp": "2026-05-21T00:00:00.000Z", - "brunchVersion": "0.0.0", - "session": { - "id": "019e4a13-1a59-712e-82ff-78900f655901", - "sourceFile": "/Users/lunelson/Code/hashintel/brunch-next/.brunch/sessions/2026-05-21T10-27-06-457Z_019e4a13-1a59-712e-82ff-78900f655901.jsonl" - }, - "driver": { - "mode": "scripted-deterministic" - }, - "projectionSummary": { - "status": "ready", - "exchangeCount": 1, - "openPrompt": false - }, - "artifacts": { - "jsonl": "scripted-001.jsonl", - "graph": { - "status": "deferred" - }, - "coherence": { - "status": "deferred" - } - } -} diff --git a/.fixtures/briefs/brief-001-identity-reference.json b/.fixtures/briefs/brief-001-identity-reference.json deleted file mode 100644 index a7383271..00000000 --- a/.fixtures/briefs/brief-001-identity-reference.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "schemaVersion": 1, - "id": "brief-001", - "title": "Team knowledge cards", - "kernelTags": ["identity-reference", "containment-topology"], - "productBrief": "A small team wants a shared workspace for knowledge cards. Each card has a stable identity, a human-readable title, and may link to other cards even when titles change.", - "expectedStructuralObservations": [ - "Cards need stable IDs separate from mutable titles.", - "Links should target card identity rather than display text." - ], - "scriptedUserNotes": [ - "I care about renaming cards without breaking links.", - "Two cards can have similar titles, so titles cannot be the only reference." - ], - "deferredExpectations": { - "graph": "Later graph fixtures should produce identity/reference nodes and link invariants.", - "coherence": "Later coherence checks should flag title-anchored references as weak evidence." - } -} diff --git a/.fixtures/briefs/brief-002-state-lifecycle.json b/.fixtures/briefs/brief-002-state-lifecycle.json deleted file mode 100644 index 9370e358..00000000 --- a/.fixtures/briefs/brief-002-state-lifecycle.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "schemaVersion": 1, - "id": "brief-002", - "title": "Approval workflow for vendor invoices", - "kernelTags": ["state-lifecycle", "authority-capability"], - "productBrief": "A finance team needs invoices to move from draft to submitted to approved or rejected. Only budget owners can approve, and rejected invoices can be revised and resubmitted.", - "expectedStructuralObservations": [ - "Invoice states and legal transitions must be explicit.", - "Approval authority depends on the budget owner role." - ], - "scriptedUserNotes": [ - "Rejected invoices are not terminal; they can go back to draft.", - "Approved invoices should not be edited without reopening the workflow." - ], - "deferredExpectations": { - "graph": "Later graph fixtures should capture lifecycle states, transitions, and authority predicates.", - "coherence": "Later coherence checks should flag contradictory terminality claims." - } -} diff --git a/.fixtures/briefs/brief-003-derived-views.json b/.fixtures/briefs/brief-003-derived-views.json deleted file mode 100644 index 1c6abe26..00000000 --- a/.fixtures/briefs/brief-003-derived-views.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "schemaVersion": 1, - "id": "brief-003", - "title": "Project dashboard rollups", - "kernelTags": ["derived-data-views", "temporal-history"], - "productBrief": "A product lead wants a dashboard that rolls task status, blockers, and recent decisions up from individual project notes into one current view.", - "expectedStructuralObservations": [ - "Dashboard values are derived from underlying notes and decisions.", - "The system needs evidence for when a rollup was last refreshed." - ], - "scriptedUserNotes": [ - "If the source note changes, the dashboard should not silently stay stale.", - "Recent decisions should show where they came from." - ], - "deferredExpectations": { - "graph": "Later graph fixtures should capture source-to-view derivation edges and evidence anchors.", - "coherence": "Later coherence checks should flag stale projections when source facts change." - } -} diff --git a/docs/README.md b/docs/README.md index 8a9c9a63..9282a68b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ downstream: 1. [prd.md](./architecture/prd.md) — product thesis, delivery posture, requirements, milestone ladder M0–M9, success criteria. 2. [pi-seam-extensions.md](./architecture/pi-seam-extensions.md) — the five Brunch-owned subsystems on top of pi (side-tasks, lenses, spec selector, offer-first interaction, mentions + staleness), the graph clock + change log, the reconciliation-need substrate, the oracle plane stub, the Flue evaluation, and framework-alignment / deferred subsystems. -3. [fixture-strategy.md](./architecture/fixture-strategy.md) — brief library, captured-run fixture format, three-layer assertion model (replay / property / adversarial), agent-as-user driver over JSON-RPC stdio, milestone mapping. +3. [probes-and-transcripts.md](./architecture/probes-and-transcripts.md) — current probe-run artifact convention, transcript evidence, report shape, and the future path for any brief-based agent-as-user probes. ## Planning memory @@ -21,15 +21,15 @@ planning state: - [memory/SPEC.md](../../memory/SPEC.md) — product contract, capability requirements, live architecture register (assumptions, decisions, invariants), future direction register, lexicon, verification stance. - [memory/PLAN.md](../../memory/PLAN.md) — active frontier, near-horizon ordering, dependencies, and the stable-id frontier definitions sequenced against the milestone ladder. -## Fixtures +## Probe artifacts -[`.fixtures/`](../.fixtures/) holds curated briefs, captured golden runs, and probe-oracle review bundles. See the directory README for layout and conventions. +[`.fixtures/`](../.fixtures/) holds current probe-run artifacts and transcript evidence. See the directory README for layout and conventions. ## Behavioral kernels [`docs/design/BEHAVIORAL_KERNELS.md`](../design/BEHAVIORAL_KERNELS.md) is the canonical input to the oracle-plane stub and the kernel-activation gate. -Briefs #1–#3 in the fixture library are worked out in that document. +Older brief-library examples were retired; future behavioral-kernel evidence should land as probe runs with transcript artifacts. ## Horizon design notes diff --git a/docs/architecture/fixture-strategy.md b/docs/architecture/fixture-strategy.md deleted file mode 100644 index b1c8c52a..00000000 --- a/docs/architecture/fixture-strategy.md +++ /dev/null @@ -1,239 +0,0 @@ -# Brunch POC — Fixture Strategy - -This is a sibling document to [prd.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) and [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md). It captures the test-fixture and evaluation-harness strategy for the POC: a brief library, a captured-run fixture format, a three-layer assertion model, and an agent-as-user driver that exercises the JSON-RPC stdio surface end to end. - -This strategy exists because two things are being remodelled at once during the POC: the data layer (intent / oracle / design / plan planes, the change log, the coherence verdict, the typed oracle entities introduced in [pi-seam-extensions §Oracle plane](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#oracle-plane-typed-stub-for-the-poc)) and the elicitation product (offer envelopes, lenses, behavioral kernels per [`BEHAVIORAL_KERNELS.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/BEHAVIORAL_KERNELS.md)). Without a reproducible end-to-end test loop, regressions in either layer will be invisible until the other has compounded onto them. A captured-fixture pipeline gives the POC the feedback loop it needs to iterate the schema and the kernels in parallel. - -## The shape - -```diagram - ╭──────────────────────╮ - │ Brief fixture │ - │ - product idea │ - │ - persona dials │ - │ - expected kernels │ - ╰──────────┬───────────╯ - │ - ┌────────────┴────────────┐ - ▼ ▼ - ╭──────────────────╮ ╭──────────────────╮ - │ Agent-as-user │ │ Human driver │ - │ over JSON-RPC │ │ (manual capture) │ - ╰────────┬─────────╯ ╰────────┬─────────╯ - │ │ - └────────────┬────────────┘ - ▼ - ╭────────────────────────────────╮ - │ Brunch host (TUI / RPC / Web) │ - ╰────────────────┬───────────────╯ - │ - ┌──────────────────┼──────────────────┐ - ▼ ▼ ▼ - ╭──────────────╮ ╭───────────────╮ ╭──────────────╮ - │ Transcript │ │ Graph export │ │ Coherence │ - │ (JSONL) │ │ (nodes,edges) │ │ verdict │ - ╰──────┬───────╯ ╰───────┬───────╯ ╰──────┬───────╯ - │ │ │ - └──────────────────┼─────────────────┘ - ▼ - ╭────────────────────────────────╮ - │ Run fixture bundle │ - │ .fixtures/<brief-id>/ │ - │ <run-id>.jsonl │ - │ <run-id>.graph.json │ - │ <run-id>.coherence.json │ - │ <run-id>.meta.json │ - ╰────────────────────────────────╯ -``` - -## Three assertion layers - -The POC needs all three; they serve different jobs. - -| Layer | What it asserts | Tolerates model drift | Best for | -| --- | --- | --- | --- | -| **Replay regression** | Exact transcript replay reproduces the golden graph | No | Pinning specific kernel-card outputs; catching regressions when schema changes | -| **Property regression** | Whatever the agent-as-user produces, structural invariants hold | Yes | Day-to-day CI; catching coherence/cascade regressions; validating the command layer doesn't admit illegal states | -| **Adversarial / generative** | Mutate brief, persona, or user answers; classify failure modes | Yes | Stress-testing kernel coverage, assumption-invalidation cascade, lens switching, cross-session continuity | - -The adversarial layer should reuse the existing [`flow-generative-testing`](file:///Users/lunelson/Code/hashintel/brunch-next/.agents/skills/flow-generative-testing/SKILL.md) skill workflow rather than reinventing a probe runner. - -### Property invariants — starter set - -These are checkable on any run regardless of model variance. They depend on the M4 graph plane and the oracle-plane stub from pi-seam-extensions §Oracle plane. - -- Every `active` requirement has at least one `validates` edge from a `Check`. -- No `Obligation` exists without a `derived_from` edge to an `Invariant` or a `formal_property` requirement. -- Every `invalidated` assumption has dependent requirements in `blocked` state, or the coherence verdict is `incoherent` with that violation explicitly surfaced. -- Every `decision` has at least one `example` of kind `positive` (the chosen option) and zero or more `negative` with `counterexample_for` edges (the rejected options). -- No orphan oracle-plane nodes (`Check` without `validates`, `Evidence` without `produces`, `Obligation` without `derived_from`). -- For every `worldUpdate` custom entry in the transcript, the named graph items have LSNs strictly greater than the session's pre-update `lastSeenLsn`. -- For every `brunch.lens_switch` and `brunch.spec_switch` entry, the session interest set is recomputed before the next agent turn. - -Reconciliation-substrate invariants (depend on the reconciliation-need substrate from pi-seam-extensions): - -- Every reconciliation need has a `created_at_lsn` strictly less than or equal to the current global LSN; no need carries an LSN ahead of the change log. -- Every reconciliation need of `kind = 'impasse'` references at least two graph nodes via `concerns` edges (you cannot be impassed about nothing, and the minimal contradiction has two sides). -- Every reconciliation need either has `status ∈ {open, deferred}` or carries a non-null `resolved_at_lsn` strictly greater than `created_at_lsn`. -- For every coherence verdict of `incoherent`, at least one open reconciliation need exists whose `concerns` set intersects the nodes cited by the verdict. -- No graph node remains in a derived `blocked` state across more than one turn without a corresponding open reconciliation need referencing it. - -Intent-modality invariants (depend on the `framing_as` extension from pi-seam-extensions §Oracle / intent-plane subtype area): - -- Every node carrying a `framing_as` value lists framings that are all members of the allowed matrix for that node's base kind. No node carries a framing outside its kind's allowed set. -- Every node carrying `authority = 'derived'` has at least one inbound edge of a relation kind whose policy is declared as authority-propagating (e.g. `refines`, `decomposes_into`). Derived authority is never free-standing. -- Every node carrying `epistemic_status = 'observed'` has at least one supporting `Evidence` node or, where the oracle plane is not yet engaged, a transcript reference recorded on the node. - -The starter set should grow as the POC encounters real regressions worth pinning. - -## Brief library - -Briefs are short, human-readable, and curated. The run artefacts are the heavy data. - -### Brief fixture format - -`.fixtures/briefs/brief-002-state-lifecycle.json`: - -```json -{ - "schemaVersion": 1, - "id": "brief-002", - "title": "Approval workflow for vendor invoices", - "kernelTags": ["state-lifecycle", "authority-capability"], - "productBrief": "A finance team needs invoices to move from draft to submitted to approved or rejected. Only budget owners can approve, and rejected invoices can be revised and resubmitted.", - "expectedStructuralObservations": [ - "Invoice states and legal transitions must be explicit.", - "Approval authority depends on the budget owner role." - ], - "scriptedUserNotes": [ - "Rejected invoices are not terminal; they can go back to draft.", - "Approved invoices should not be edited without reopening the workflow." - ], - "deferredExpectations": { - "graph": "Later graph fixtures should capture lifecycle states, transitions, and authority predicates.", - "coherence": "Later coherence checks should flag contradictory terminality claims." - } -} -``` - -### Starter set (seven briefs) - -| # | Brief | Active kernels (expected) | Stretches | -| --- | --- | --- | --- | -| 1 | **Team knowledge cards** | Identity/reference, containment topology | Stable identity versus mutable titles; links that must survive renames | -| 2 | **Approval workflow for vendor invoices** | State/lifecycle, authority/capability | Non-terminal rejection, reopening approved work, role-gated transitions | -| 3 | **Project dashboard rollups** | Derived-data views, temporal history | Stale projections, source evidence for rollups and decisions | -| 4 | **Calendar scheduling with notifications** | Concurrency (overlap), authority, external effects, error/recovery | External-effects + recovery semantics | -| 5 | **Knowledge-graph editor (meta)** | Identity, containment, change/migration, observability, validation | Brunch describing itself; sanity check on the modelling | -| 6 | **Verified sort algorithm** | Validation/normalization, formal properties only | Narrow but stretches `formal_property` requirements, `Obligation` nodes, `proof` / `model_check` validation methods, and assurance-level computation | -| 7 | **"Notion meets Linear meets Slack"** | Forces scope-boundary clarification before any kernel can engage | Adversarial; stresses offer-first interaction and scope-card affordance | - -Briefs #1–#3 are the first curated M1 seeds under `.fixtures/briefs/`. They are intentionally thin, human-reviewed product briefs for transcript/projection replay, not final evidence that Brunch's elicitation interaction logic or knowledge-flow model is correct. - -### Brief #7 expectations — "Notion meets Linear meets Slack" - -This brief is intentionally vague and over-scoped so that the interviewer is forced to refuse forward motion until product framing crystallises. Its captured run is therefore evaluated on a different axis from briefs 1–6: its golden outcome is *scope clarification*, not kernel coverage. - -Expected by termination of the run (whether the run terminates in success or in an explicit "cannot proceed" state): - -- At least one `product_concept` node exists with `authority = 'stakeholder'`. -- At least one `problem` node exists with a `motivates` edge to a `product_concept`. -- At least one `persona` node exists with a `realizes` or `targeted_by` edge to a `product_concept` or `problem`. -- At least one `non_goal` exists (rendered as a constraint subtype with `framing_as = 'non_goal'`), explicitly carving out something the system will not do. -- The kernel-activation gate (`product_concept` ∧ `problem` ∧ `persona` ∧ `non_goal`) is recorded as either *met* or *not met* on the run's terminal transcript entry; if met, the interviewer must have transitioned at least one kernel from latent to active before termination. -- The change log contains at least one `brunch.lens_switch` or equivalent scope-card affordance entry, evidencing that the interviewer actively pushed back on scope rather than silently absorbing it. - -A run for brief #7 that terminates with kernels active but with none of `product_concept`, `problem`, `persona`, `non_goal` present is a property-regression failure: the interviewer admitted kernel activation without satisfying the activation gate. - -### Adversarial briefs (second tier) - -| Brief | Targets | -| --- | --- | -| **"Changing my mind"** — persona starts with one assumption, retracts it mid-spec | Assumption-invalidation cascade end-to-end | -| **"Contradictory requirements"** — persona supplies two requirements that become mutually exclusive after a third question | Coherence detection | -| **"Long horizon"** — persona iterates over 50+ turns | Compaction-aware continuity (M9); lens switches across compaction boundaries | -| **"Cross-session paired"** — two briefs that operate on the same spec workspace and produce relevant changes for each other | `worldUpdate` interest-set filtering (M7) | - -## Agent-as-user harness - -The agent-as-user is a thin driver that exercises the JSON-RPC stdio surface end to end. It does three things: - -1. Opens a JSON-RPC stdio connection to `brunch --mode rpc`. -2. Subscribes to Brunch's pending structured-interaction stream (structured-exchange tool calls/results and product-native offer/proposal entries per [pi-seam-extensions §4](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#4-assistant--and-system-offer-first-structured-interaction)). -3. For each pending interaction, calls an LLM with the brief, the persona dials, and the interaction payload; collects a terminal structured response; posts it back over Brunch RPC (or through the private Pi-RPC extension UI relay when the driver is proving that seam). - -### Termination conditions - -- The session emits a `spec.approved` or equivalent terminal signal. -- A configurable max-turns ceiling. -- A token-cost ceiling per run. -- A `brunch.needs_human` outcome the persona is configured not to fulfil. - -### Minimal user-agent system prompt - -``` -You are simulating a user with this product brief: - -{brief} - -Personality: -- Style: {persona.style} -- Domain literacy: {persona.domain_literacy} -- Patience: {persona.patience} -- You may change your mind on prior answers with probability {persona.change_mind_probability}. - -You are talking to a guided spec-elicitation tool. It will offer you -choices and questions. Respond as the user described above. Be -consistent with your prior answers unless you decide to change your mind. -If you change your mind, say so explicitly. -``` - -### Posture - -- The user-agent has **no** access to Brunch's graph plane or transcript substrate. Its only channel into the session is the offer/response stream over JSON-RPC. This keeps the test path identical to the production interaction path. -- The user-agent is itself a single pi-coding-agent (or thinner) session in a separate process, configured to *not* discover any project context. It is not a Brunch session and does not run lens machinery. -- Runs are deterministic given the same model, temperature, brief, persona, and offers — but the entire pipeline is designed to tolerate non-determinism via the property-regression layer. - -## Run fixture bundle - -A captured run produces four artefacts under `.fixtures/<brief-id>/<run-id>/`: - -| File | Contents | -| --- | --- | -| `<run-id>.jsonl` | The full pi JSONL session transcript including structured-exchange tool results and Brunch custom entries (`brunch.establishment_offer`, `brunch.review_set_proposal`, `brunch.elicitor_intent_hint`, `brunch.lens_switch`, `brunch.spec_switch`, `brunch.kernel_activation`, `brunch.side_task_result`, `worldUpdate`) | -| `<run-id>.graph.json` | A snapshot of all spec-workspace graph planes at run termination: nodes, edges, per-entity versions, current graph LSN | -| `<run-id>.coherence.json` | Coherence verdict at termination, including per-plane status and any open violations | -| `<run-id>.meta.json` | Run metadata: brief id, persona dials, model, timestamps, total turns, total tokens, terminal reason, agent-as-user prompt hash | - -The transcript is the load-bearing artefact for **replay regression**; the graph + coherence files are load-bearing for **property regression**. - -## Milestone mapping - -The fixture harness threads through the existing milestone ladder; it does not need its own milestone. - -| Milestone | Fixture work | -| --- | --- | -| **M0** (walking skeleton + TUI) | Begin curating briefs as JSON. Manually-driven runs at the TUI produce first JSONL captures. Briefs cost nothing to write; the longer the library, the more leverage later. | -| **M1** (mode shell: print + rpc) | Stand up the first fixture-capture path against `brunch --mode rpc`. First **replay regression** fixtures land here, asserting transcript reproduction/projection only. Graph plane does not yet exist; assertions are transcript-shaped, and scripted exchange shape should not be treated as final elicitation behavior. | -| **M2** (JSONL session viability) | The captured transcripts *are* the JSONL session files. The fixture library's reproducibility is part of M2's evidence. | -| **M3** (web shell) | The same offer-response fixtures drive the web client through its WebSocket; free coverage of the web shell against known-good runs. | -| **M4** (graph data plane) | Graph snapshots become part of the run-fixture bundle. The first **property regression** assertions land here. | -| **M5** (agent ↔ graph) | Kernel-card outputs become observable in the graph. Briefs grow per-kernel-card coverage assertions. | -| **M6** (authority model + gated tools) | Adversarial briefs that request human-gated actions in print/RPC mode become regression fixtures for the structured-`needs_human` outcome. | -| **M7** (detection, relevance, turn-boundary reconciliation) | Cross-session paired-brief fixtures land here. The "changing my mind" adversarial brief becomes a property-regression fixture for assumption cascade. | -| **M8** (coherence as first-class) | "Contradictory requirements" adversarial brief becomes a property-regression fixture for coherence verdict emission. | -| **M9** (compaction-aware continuity) | "Long horizon" adversarial briefs land here as regression for compaction. Property assertions include "session-scoped `lastSeenLsn` survives compaction." | - -## Two secondary benefits - -1. **The fixture library is its own form of design pressure.** Curating briefs forces explicit articulation of what kinds of products Brunch should be able to elicit specs for. That articulation is itself a useful design artefact and feeds back into kernel-card prioritisation. -2. **The agent-as-user harness *is* a Brunch demo.** Running `brunch demo offline-kanban` produces a fully populated spec workspace and a transcript that can be replayed for stakeholders. The same harness becomes the basis for screencast and integration-test demos with zero additional work. - -## Open questions - -1. Whether the agent-as-user should be a pi-coding-agent session or a thinner harness (just a model client). The pi-coding-agent path gets transcript capture for free; the thinner path is cheaper to run in CI. -2. Whether to keep fixture artifacts in this repository long-term or move large post-POC corpora to a sibling repository. For the POC, `.fixtures/` is the canonical in-repo fixture root for both curated briefs and probe-oracle review bundles. -3. Whether replay regression should attempt full transcript reproduction or only assert that the *graph* matches after a free-running replay. Full reproduction is brittle to model upgrades; graph-only is more durable but loses transcript-level signal. -4. Whether the agent-as-user should run with the same model as the Brunch session under test, or a deliberately different one (to surface model-dependent kernel-card behaviour). -5. Whether to surface a `brunch fixtures capture <brief-id>` and `brunch fixtures replay <brief-id> <run-id>` CLI sub-command set, or keep fixture tooling external to the Brunch binary. -6. How to handle PII or sensitive content in adversarial briefs that involve realistic-looking data. The POC's briefs are synthetic; this is a deferred concern. diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 8a04b174..1ae516c3 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -650,7 +650,7 @@ The intent ontology covers engineering-spec shapes well but is thin on product-f Framing primarily drives **elicitation, rendering, and context packing** in the POC — not new relation-policy rules. Base kind still drives edge legality and most traversal. Context packs, scope-card UI, and compaction summaries must render a dedicated **Product framing** block so the data does not silently disappear from the agent's view. -Kernel-activation gate: behavioral kernels should not engage in earnest before at least one `product_concept`, one `problem`, one `persona`, and one `non_goal` (or scope boundary) have been captured for the spec. This is the minimum framing bundle required for brief #7 ("Notion meets Linear meets Slack") in the [fixture strategy](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) to succeed. +Kernel-activation gate: behavioral kernels should not engage in earnest before at least one `product_concept`, one `problem`, one `persona`, and one `non_goal` (or scope boundary) have been captured for the spec. Future probe scenarios can exercise this bundle through transcript artifacts, but there is no current standalone brief-library gate. #### Oracle-plane entities (new node types) diff --git a/docs/architecture/prd.md b/docs/architecture/prd.md index af204000..cafed4bc 100644 --- a/docs/architecture/prd.md +++ b/docs/architecture/prd.md @@ -523,7 +523,7 @@ Prove the mode dispatcher. - `--mode print` and `--mode rpc` run from the same Brunch-owned host setup. - all three pi-backed modes share one coherent local authority model. -- the JSON-RPC stdio surface is exercised end-to-end by an agent-as-user driver against a curated brief library; see [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md). First replay-regression fixtures land here. +- the JSON-RPC stdio surface is exercised end-to-end by probe drivers that leave transcript artifacts; see [probes-and-transcripts.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/probes-and-transcripts.md). Older curated-brief fixture captures have been retired in favor of current probe runs. ### M2 — JSONL session viability diff --git a/docs/architecture/probes-and-transcripts.md b/docs/architecture/probes-and-transcripts.md new file mode 100644 index 00000000..e0d47e88 --- /dev/null +++ b/docs/architecture/probes-and-transcripts.md @@ -0,0 +1,60 @@ +# Probes and transcript artifacts + +Brunch's current verification substrate is **probe runs with transcript evidence**. +A probe is an executable or scripted check that drives a Brunch seam, writes or +points at the canonical transcript artifact, and emits a compact report a human +can review. The transcript is the durable evidence; the report explains what was +proven and where to inspect it. + +This replaces the older over-planned "brief library / fixture strategy" shape. +Curated briefs may become useful again, but only as inputs to probes that produce +normal probe-run artifacts under `.fixtures/runs/<probe-id>/<run-id>/`. + +## Current contract + +A committed probe run lives at: + +``` +.fixtures/runs/<probe-id>/<run-id>/ +├── session.jsonl # source transcript / canonical run evidence +├── transcript.md # Brunch-semantic human rendering +└── report.json # probe metadata, oracle summary, artifact paths +``` + +Probe reports should include, at minimum: + +- `schemaVersion` +- `probeId` +- `runId` +- enough mission / evaluation-focus text to explain the run +- the turn or operation budget when relevant +- blockers / friction / failure notes when relevant +- paths to colocated artifacts + +## What belongs here now + +- Transport and projection probes that prove Brunch public RPC / web / TUI seams. +- Transcript-shape probes that prove durable Pi JSONL contains enough semantic + evidence to reconstruct Brunch exchanges. +- Human-readable transcript renderings paired with machine-checkable reports. + +## What does not belong here now + +- Milestone trophy scripts whose acceptance moment has passed. +- Golden captures for retired transcript shapes. +- Tests that only assert a fixed set of example briefs exists. +- A parallel "brief library" subsystem with its own lifecycle. + +## Future pathway for brief-based probes + +If Brunch later needs agent-as-user brief-based golden fixtures, the path is: + +1. define the probe and its current behavioral question; +2. optionally use a curated brief as probe input; +3. drive Brunch through the public product surface; +4. persist the resulting transcript and report under `.fixtures/runs/...`; +5. make assertions against current Brunch semantics, not historical milestone + shapes. + +Briefs are inputs, not the canonical artifact. The transcript-backed probe run is +canonical. diff --git a/docs/archive/PLAN_HISTORY.md b/docs/archive/PLAN_HISTORY.md index 5309f86b..9c2bfad1 100644 --- a/docs/archive/PLAN_HISTORY.md +++ b/docs/archive/PLAN_HISTORY.md @@ -30,13 +30,13 @@ Archived from `memory/PLAN.md` so the live plan only carries active, next, horiz - **Branch:** `ln/fe-735-mode-shell-fixture-driver` (stacked on `ln/fe-729-walking-skeleton`) - **Kind:** structural - **Status:** done -- **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression fixtures for at least briefs #1–#3. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. -- **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the fixture-driven feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. +- **Objective:** Add `--mode print` and `--mode rpc` transport dispatchers over the same Brunch host and named RPC method-family handlers; land the agent-as-user JSON-RPC stdio driver; prove transcript projection of elicitation exchanges; and capture the first replay-regression transcript fixtures for then-current scripted brief inputs. For M1, print mode is a snapshot renderer/proof-of-life, not a single-turn agent run. +- **Why now / unlocks:** Proves D5-L (JSON-RPC primary) and unlocks the transcript-backed probe feedback loop. Without this milestone, every downstream milestone has only manual TUI evidence. - **Acceptance:** `brunch --mode print` and `brunch --mode rpc` boot from the same host setup; the first `session.*` / `workspace.*` RPC handlers are named product methods rather than a generic read gateway; an agent-as-user driver completes at least one brief end-to-end over stdio by responding to elicitation prompts; captured JSONL can be projected into prompt/response elicitation exchanges; a `.jsonl` + `.meta.json` bundle is written under `.fixtures/`; the first three curated briefs are captured. -- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, replay-regression fixture(s) asserting transcript reproduction/projection parity, and `./src/probes/scripts/verify-m1.sh` for store/projection/manual-smoke evidence (SPEC §Oracle Strategy by Loop Tier). Outer — the three-layer fixture model is established in skeleton form here; property and adversarial layers come online as later milestones supply graph/coherence substrates; brief quality and golden-capture representativeness remain explicit human review prompts in the probe. +- **Verification:** Inner — verify gate plus projection-handler unit tests for elicitation exchange ranges. Middle — deterministic first captured run, stdio RPC handler contract tests, and replay-regression fixture(s) asserting transcript reproduction/projection parity. The old M1 milestone script and scripted brief captures were later retired when tuple-shaped public-RPC probe artifacts became the current evidence path. Outer — property/adversarial probe layers come online as later milestones supply graph/coherence substrates. - **Cross-cutting obligations:** Keep transport mode distinct from agent roles/lenses; do not make print mode select or imply an agent strategy in M1. Keep the captured-run format forward-compatible with later `.graph.json` and `.coherence.json` artefacts; establish exchange projection over Pi JSONL without creating canonical chat/turn tables; keep read/subscription architecture thin — named RPC method families and projection handlers over canonical stores, not a generic read-model platform; this frontier establishes the first layer of the canonical replay/property/adversarial fixture architecture rather than a one-off harness. - **Traceability:** R4, R5, R11, R16, R17, R20 / D5-L, D12-L, D13-L, D18-L, D19-L / I3-L, I10-L, I13-L / A1-L, A5-L -- **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) +- **Design docs:** [probes-and-transcripts.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/probes-and-transcripts.md) - **Current execution pointer:** complete after M1 review fixes; proceed to `jsonl-session-viability`. ### jsonl-session-viability diff --git a/docs/design/ELICITATION_LENSES.md b/docs/design/ELICITATION_LENSES.md index 8c2a1fff..1d122d60 100644 --- a/docs/design/ELICITATION_LENSES.md +++ b/docs/design/ELICITATION_LENSES.md @@ -110,7 +110,7 @@ Scenarios are a recurring rendering primitive across lenses with three distingui All three share a shape: a particular vignette, deliberately under-specified at the boundaries (fat-marker), illustrative not prescriptive, carrying an implicit "vs not-this". A scenario-entry primitive may eventually be worth extracting as a typed custom entry; for now scenarios live as transcript content with role distinguished by context. -**Terminology guard.** Scenarios (user-facing, runtime) are distinct from **briefs** (`.fixtures/briefs/`, dev-only inputs for the agent-as-user fixture driver). Briefs are testing infrastructure; scenarios are product surface. Do not conflate. +**Terminology guard.** Scenarios are user-facing/runtime examples. Probe inputs are testing infrastructure that only matter when they produce transcript-backed probe runs under `.fixtures/runs/`. Do not turn probe inputs into product scenarios, and do not revive a standalone brief-library subsystem. ## Establishment offers and intent hints diff --git a/docs/design/REVIEW_SETS.md b/docs/design/REVIEW_SETS.md index 90cc52f6..e32abeb5 100644 --- a/docs/design/REVIEW_SETS.md +++ b/docs/design/REVIEW_SETS.md @@ -67,7 +67,7 @@ Approximate shape (refined during M5 implementation): A14-L tracks the open assumption: **LLM elicitors must reliably produce graph-structurally-legal payloads** (entity drafts and edges that pass `CommandExecutor` structural validation). This is validated via: -- Fixture replay across briefs exercising generative lenses +- Probe replay across transcript-backed scenarios exercising generative lenses - Dry-run validation reports at proposal time If LLM reliability proves insufficient, fallbacks are possible without changing the user-facing review-cycle: diff --git a/memory/PLAN.md b/memory/PLAN.md index b083c4fd..b17e3e55 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, fixture/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first ten-turn tuple parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first ten-turn tuple parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure @@ -25,23 +25,23 @@ The POC should maximize assumption falsification rather than merely implement mi | A1-L Pi substrate seams | A needed host/session/RPC/extension seam cannot be expressed without forking Pi. | Mostly exercised by M0-M3; FE-744 and `sealed-pi-profile-runtime-state` close the remaining UI/profile seams before graph-agent work depends on them. | | A3-L command layer sufficiency | Agent, UI, reviewer, or capture writes need shortcuts around one `CommandExecutor`. | `graph-data-plane`, `agent-graph-integration`, and `authority-model` must prove one command boundary for every write path. | | A4-L global LSN adequacy | Replay, staleness, or reconciliation ordering needs per-entity/vector clocks. | `graph-data-plane` establishes one-LSN-per-transaction; `turn-boundary-reconciliation` tries to break it with cross-session traces. | -| A5-L fixture driver quality | Agent-as-user captures fail to catch regressions or cannot represent realistic briefs. | FE-744 must first prove a deterministic public-RPC ten-turn elicitation driver; `brief-library-curation` and `fixture-strategy-evolution` then keep the broader assumption-proof matrix honest. | +| A5-L probe/transcript driver quality | Agent-as-user probes fail to catch regressions or cannot produce reviewable transcript evidence for realistic Brunch seams. | FE-744 has proved a deterministic public-RPC ten-turn elicitation driver; future brief-based or generative golden runs must pass through the `.fixtures/runs/<probe-id>/<run-id>/` probe/transcript artifact path. | | A6-L unified `graph.*` namespace | Intent/oracle/design/plan semantics become confusing or unsafe under one umbrella. | `graph-data-plane` and `agent-graph-integration` should start unified but watch for namespace pressure. | -| A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus briefs #1-#7 exercise framing; promote only if fixture pressure demands it. | +| A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus targeted probe scenarios exercise framing; promote only if probe evidence demands it. | | A8-L reconciliation substrate | Gaps, contradictions, process debt, and conflicts need separate substrates immediately. | `graph-data-plane` builds the shared substrate; `coherence-first-class` and known-bad briefs test subtype pressure. | | A9-L mention ledger granularity | Session-scoped snapshots miss necessary staleness or create noisy hints. | Defer until `turn-boundary-reconciliation`, after graph ids/LSNs exist. | | A10-L TUI chrome seam | Branded persistent chrome cannot be recovered through Pi UI primitives. | FE-744 must re-prove chrome visually/thematically, not just semantically, before closeout. | | A11-L next-turn delivery | Side-task/reviewer results require mid-turn delivery or another event plane. | Keep deferred until M5/M7 side-task/reviewer paths exist; test at turn-boundary rendezvous. | | A13-L deferred observer/auditor queue | Async audit/backfill needs canonical chat/turn tables or privileged writes. | Not load-bearing after D18-L; defer until a backstop queue is actually introduced. | | A14-L review-set structural legality | LLMs cannot produce dry-run-valid entity/edge drafts reliably enough. | M5 must measure structural-legality rate and retry/fallback behavior before depending on proposal-heavy UX. | -| A15-L establishment hints | Offers are not reconstructable or useful from transcript entries alone. | M5 establishment-offer fixtures and FE-744 chrome affordances exercise this. | -| A16-L reviewer trigger/scope | Reviewer findings are too slow, noisy, or incomplete under deferred policy. | Do not overbuild early; first accepted review-set fixtures should make reviewer policy empirical. | +| A15-L establishment hints | Offers are not reconstructable or useful from transcript entries alone. | M5 establishment-offer probe runs and FE-744 chrome affordances exercise this. | +| A16-L reviewer trigger/scope | Reviewer findings are too slow, noisy, or incomplete under deferred policy. | Do not overbuild early; first accepted review-set probe runs should make reviewer policy empirical. | | A17-L elicitation temperament preference | Users do not need persistent interrogative/proposal preference. | Outer-loop adoption signal only; do not block POC. | | A18-L command containment | Hiding suggestions + lifecycle blocking leaves unsafe Pi built-ins reachable. | FE-744 product-shell evidence must name any Pi upstream seam before M5/M6 authority work relies on it. | | A19-L sealed Pi profile | Ambient `.pi` settings/resources still shape Brunch product behavior. | `sealed-pi-profile-runtime-state` is a gate before graph tools and authority-sensitive agent work. | | A20-L Drizzle 1.0 beta | Beta blocks migrations, SQLite fidelity, or TypeBox derivation. | `graph-data-plane` starts with a version/schema spike before broad imports. | -| A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad fixtures earlier so the rubric is falsifiable. | -| A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture fixtures before async observer backstops are reconsidered. | +| A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad probe scenarios earlier so the rubric is falsifiable. | +| A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture probe runs before async observer backstops are reconsidered. | | A23-L public RPC elicitation parity | A public Brunch RPC client cannot discover methods, activate workspace/spec/session, drive assistant-first pending exchanges, or produce TUI-comparable JSONL without speaking raw Pi RPC or adding a parallel turn store. | Validated by FE-744 public-RPC tuple parity and hardening commits; remaining FE-744 work observes the same session/exchange state from web and recovers branded chrome. | @@ -59,8 +59,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Parallel / Low-conflict -- `brief-library-curation` — Author and review briefs #4–#7 plus the adversarial second tier. Briefs are text and can proceed independently of current Pi-wrapping work. -- `fixture-strategy-evolution` — Keep the assumption-proof matrix honest as captures land: property invariants, brief expectations, harness notes, and known-bad probes. Doc-only, but assumption-critical. +- `probes-and-transcripts-evolution` — Harden the probe/transcript artifact path as new seams need evidence: report schemas, transcript renderers, targeted probe scenarios, and optional brief inputs that feed normal `.fixtures/runs/<probe-id>/<run-id>/` runs. Doc/test-heavy, but assumption-critical. - `subagents-for-proposal-diversity` — Optional enhancement to candidate-proposal generation (D44-L). Lands when `agent-and-graph-integration` (M5) is far enough along that batch-proposal flow exists and would benefit from parallel data-gathering; never a blocker. ### Horizon @@ -115,7 +114,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** not-started - **Objective:** Brunch installs graph tools through pi's extension seams; agent graph operations, elicitor post-exchange capture writes, reviewer-attributed advisory writes, review-set batch acceptances, spec readiness grade/posture updates, and the transcript-native establishment/intent-hint surfaces all route exclusively through the Brunch-owned command layer and shared event substrate; web, TUI, and agent all observe the same changes. - **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-exchange preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. -- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing per brief; first batch-proposal fixture (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in fixture metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem briefs exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. +- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing through targeted probe runs; first batch-proposal probe (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in probe metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem probe scenarios exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L, D50-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L, I33-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) @@ -128,8 +127,8 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** optional enhancement - **Status:** deferred (lands when `agent-and-graph-integration` is far enough along to benefit; never a blocker for M0–M9) - **Objective:** Register a single `subagent` Pi tool per D44-L so the main agent can (a) fan out blocking data-gathering calls (scout / researcher / graph-reader) in parallel to ground proposals, then (b) fan out parallel `proposer` invocations to generate diverse candidate variants — the subagent realization of `ln-design`'s "design it twice" pattern and `ln-oracles`'s parallel-fan-out — and finally compose `brunch.review_set_proposal` entries from those variants via the D31-L meta-rubric. Subagent results return as tool content; no `CommandExecutor` access; no Brunch RPC access; isolated `pi --no-session --no-skills --no-extensions` subprocesses inheriting Brunch Pi Profile sealing. -- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/tui-client/.pi/extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one batch-proposal fixture exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. -- **Verification:** Inner — `subagent` tool argv-shape tests; TypeBox schema validation of agent frontmatter and `config.json`; per-starter-agent tool-allowlist conformance (proposer must have an empty tool set). Middle — isolation audit (no ambient `.pi/` resources reachable; parent `CommandExecutor` / Brunch RPC handlers absent from subprocess environment); subprocess streaming / abort propagation tests; parallel-fan-out independence test (two `proposer` invocations with distinct framings produce structurally distinct outputs). Outer — proposal-generation fixture invokes scout/researcher/graph-reader to ground, then parallel `proposer` variants, and surfaces the composed review-set proposal with grounding-bundle coverage and `epistemic_status` consistent with the gathered evidence; meta-rubric application visible in the comparison rendering. +- **Acceptance:** `subagent` tool registered with `{ agent, task }` and `{ tasks: [] }` parameters; starter agents scout/researcher/graph-reader/proposer land as markdown files with TypeBox-validated frontmatter under `src/tui-client/.pi/extensions/subagents/agents/`; proposer is system-prompt-only (no tools) and produces exactly one variant per invocation; argv shape per spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent tool allowlist / model / system-prompt path; concurrency cap honored from [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json); subagents have no inherited conversation context so the task string must carry everything; result text returns as tool result content with no transcript side-effects; at least one batch-proposal probe exercises a `tasks: []` parallel `proposer` fan-out (≥ 2 variants) feeding a single `brunch.review_set_proposal` composed by the main agent via the D31-L meta-rubric. +- **Verification:** Inner — `subagent` tool argv-shape tests; TypeBox schema validation of agent frontmatter and `config.json`; per-starter-agent tool-allowlist conformance (proposer must have an empty tool set). Middle — isolation audit (no ambient `.pi/` resources reachable; parent `CommandExecutor` / Brunch RPC handlers absent from subprocess environment); subprocess streaming / abort propagation tests; parallel-fan-out independence test (two `proposer` invocations with distinct framings produce structurally distinct outputs). Outer — proposal-generation probe invokes scout/researcher/graph-reader to ground, then parallel `proposer` variants, and surfaces the composed review-set proposal with grounding-bundle coverage and `epistemic_status` consistent with the gathered evidence; meta-rubric application visible in the comparison rendering. - **Cross-cutting obligations:** Preserve the single-authority mutation rule (`CommandExecutor` only — subagents never bypass it) and the sealed Pi Profile (no ambient `.pi/` leakage through the subprocess boundary). Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. Worker-style write-capable subagents are deferred until an execute operational mode exists. - **Traceability:** R20 / D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L, D44-L / I2-L, I11-L, I24-L, I29-L - **Design docs:** [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md) @@ -142,7 +141,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** not-started - **Objective:** Fill in the policy matrix behind the existing `CommandExecutor` result seam: three-tier policy (autonomous / requires-confirmation / human-only) implemented end-to-end; headless modes fail or delegate cleanly with structured `needs_human`; attribution + optimistic concurrency shared across all callers. - **Acceptance:** Adversarial briefs requesting human-gated actions in print/RPC produce structured `needs_human` through the command result contract; an authority test matrix passes across all four modes; M6 does not introduce a second policy service or caller-side authority gate. -- **Verification:** Inner gate plus policy classifier/result-shape unit tests. Middle — authority matrix contract tests across TUI/web/print/RPC through the existing `CommandExecutor` result seam. Outer — adversarial fixture for structured `needs_human` regression. +- **Verification:** Inner gate plus policy classifier/result-shape unit tests. Middle — authority matrix contract tests across TUI/web/print/RPC through the existing `CommandExecutor` result seam. Outer — adversarial probe for structured `needs_human` regression. - **Traceability:** R5, R6, R12 / D4-L, D20-L - **Design docs:** [prd.md §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) @@ -153,7 +152,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** structural - **Status:** not-started - **Objective:** Graph-revision tracking; session interest sets; `worldUpdate` synthesised by `prepareNextTurn`; mention-ledger staleness hints; side-task-result and reviewer-finding drain at the same boundary; session/spec binding transitions — and any lens switches present by then — recompute interest set before next agent turn. -- **Acceptance:** Cross-session paired-brief fixture exercises `worldUpdate` filtering; mention-staleness hints synthesise when an entity changed since last snapshot; succeeded side-task results are delivered only at the next turn boundary; reviewer findings from earlier batch acceptances arrive as advisory `reconciliation_need` items at the same boundary, never mid-turn; session/spec binding transitions and any emitted `brunch.lens_switch` entries recompute interest sets. +- **Acceptance:** Cross-session paired probe exercises `worldUpdate` filtering; mention-staleness hints synthesise when an entity changed since last snapshot; succeeded side-task results are delivered only at the next turn boundary; reviewer findings from earlier batch acceptances arrive as advisory `reconciliation_need` items at the same boundary, never mid-turn; session/spec binding transitions and any emitted `brunch.lens_switch` entries recompute interest sets. - **Verification:** Inner gate plus mention-ledger/session-interest unit tests. Middle — generated LSN/change traces and property tests for I4-L, I5-L, I9-L, I12-L, I16-L; subscription/update ordering checks for turn-boundary messages including reviewer findings. Outer — paired-brief adversarial capture passes, including side-task delivery and reviewer-finding delivery when those subsystems are active. - **Cross-cutting obligations:** This frontier is the rendezvous point for Brunch's shared next-turn event semantics: `worldUpdate`, side-task results, reviewer findings, lens changes, session/spec binding state, and mention staleness must coexist without inventing a second event plane. - **Traceability:** R11, R13, R14, R18, R21 / D6-L, D11-L, D14-L, D15-L, D17-L, D29-L / I1-L, I4-L, I5-L, I9-L, I12-L, I16-L / A4-L, A9-L, A11-L, A16-L @@ -167,7 +166,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** not-started - **Objective:** Structural legality enforced synchronously; semantic coherence stored as explicit product state; UI and agent read the same coherence verdict; before-images available where needed. - **Acceptance:** "Contradictory requirements" adversarial brief produces an `incoherent` verdict with a backing open reconciliation need; coherence verdict surfaces in the TUI chrome and in `graph.*` reads. -- **Verification:** Inner gate plus structural validator tests. Middle — coherence-emission property tests proving backing reconciliation needs and projection/query visibility. Outer — adversarial fixture for contradictory requirements plus manual UI checklist for visible coherence verdict. +- **Verification:** Inner gate plus structural validator tests. Middle — coherence-emission property tests proving backing reconciliation needs and projection/query visibility. Outer — adversarial probe for contradictory requirements plus manual UI checklist for visible coherence verdict. - **Cross-cutting obligations:** Coherence verdicts must remain visible through the same transcript/graph authority model that side tasks, elicitation exchanges, deferred audit/reviewer jobs, and reconciliation needs already use; this frontier must not hide coherence behind a private subsystem. - **Traceability:** R12, R14 / D8-L / I6-L - **Design docs:** [pi-seam-extensions.md §Reconciliation-need substrate](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md) @@ -180,36 +179,23 @@ The POC should maximize assumption falsification rather than merely implement mi - **Status:** not-started - **Objective:** Compaction preserves graph, coherence, and continuity anchors per D43-L; interest sets can widen beyond direct reads when needed; conflict signaling remains intelligible at long horizons. - **Acceptance:** Long-horizon adversarial brief (50+ turns) replays through compaction with `lastSeenLsn`, interest set, and session binding preserved; spec/session changes across compaction boundaries do not desync; the auto-compaction extension renders the configured preserved-anchor set byte-stable so active spec, in-flight side-task / deferred-auditor-job / reviewer-job bookkeeping, latest `brunch.agent_runtime_state`, latest `brunch.establishment_offer`, latest `brunch.lens_switch`, unresolved staleness hints, and active review-set leaves remain intelligible after compaction; ambient-affordance chrome continues to render the current offer; auto-compaction failure falls through to Pi default compaction rather than dropping anchors silently. -- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/deferred-auditor/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon fixture passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. +- **Verification:** Inner gate plus continuity-metadata unit tests and TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json). Middle — compaction round-trip/property tests for `lastSeenLsn`, interest set, session binding, graph/coherence anchors, active side-task/deferred-auditor/reviewer bookkeeping, latest-establishment-offer/lens/runtime-state reconstruction; deterministic anchor-rendering tests (same branch + same config → same header bytes); fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer — long-horizon probe passes, including continuity checks for side-task, interest-set, runtime-state, and establishment-offer state when present. - **Cross-cutting obligations:** Preserve the coherence anchors, session binding, session continuity metadata, and side-task/deferred-auditor/spec state that earlier milestones attached to the shared transcript/event substrate; preserve lens state only if a lens subsystem has landed by then. The auto-compaction extension is the canonical owner of `session_before_compact`; product code paths that touch compaction must compose with it rather than register a parallel hook. - **Traceability:** R15 / D6-L, D15-L, D43-L / I12-L, I28-L - **Design docs:** [prd.md §Continuity, Divergence, and Coherence](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md) -### brief-library-curation +### probes-and-transcripts-evolution -- **Name:** Curate the fixture brief library -- **Linear:** unassigned -- **Kind:** bounded feature -- **Status:** not-started -- **Objective:** Author and review briefs #4–#7 plus the adversarial second tier per fixture-strategy. Outputs are JSON briefs and one or two reviewer notes. -- **Acceptance:** Briefs #1–#7 present in `.fixtures/briefs/`; adversarial briefs present with documented targets; expectations for brief #7 satisfied per fixture-strategy. -- **Verification:** Doc review against fixture-strategy expectations; schema/checker validation for brief JSON once available; spot-replay if the relevant harness milestone has landed. -- **Cross-cutting obligations:** Keep the brief corpus aligned with the canonical replay/property/adversarial fixture model rather than letting it drift into a loose examples folder. -- **Traceability:** R20 / A5-L -- **Design docs:** [fixture-strategy.md §Brief library](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) - -### fixture-strategy-evolution - -- **Name:** Evolve fixture strategy as captures land +- **Name:** Evolve probe/transcript strategy as captures land - **Linear:** unassigned - **Kind:** hardening - **Status:** not-started -- **Objective:** Iterate `fixture-strategy.md` as the POC assumption-proof plan: property invariants, brief expectations, harness CLI shape, known-bad probes, agent-as-user evaluator probe shape (mission/intention, evaluation focus, max-turn budget, blocker/friction report), and per-assumption fitness notes as real captures expose gaps. -- **Acceptance:** Each assumption-heavy milestone landing adds at least one new fixture-strategy entry (invariant, brief expectation, harness note, known-bad probe, or fitness metric) or explicitly records "no change needed" for the assumptions it touched. -- **Verification:** PR review on the doc plus cross-check that new/changed fixture assertions map to SPEC assumptions/invariants or acknowledged blind spots; downstream fixture runs catch regressions and surface assumption fitness rather than only pass/fail. -- **Cross-cutting obligations:** Treat fixture strategy as canonical verification architecture that must stay in sync with SPEC/PLAN, not as optional commentary. If an assumption is not being tested by its assigned frontier, PLAN should say whether it is deferred, accepted as risk, or needs a spike/oracle pass. +- **Objective:** Keep the current probe/transcript substrate honest as new seams need evidence: report envelopes, Brunch-semantic transcript rendering, artifact layout, targeted probe scenarios, optional brief inputs, agent-as-user evaluator shape (mission/intention, evaluation focus, max-turn budget, blocker/friction report), and per-assumption fitness notes as real probe runs expose gaps. +- **Acceptance:** Each assumption-heavy frontier either lands a transcript-backed probe run under `.fixtures/runs/<probe-id>/<run-id>/`, extends the probe/report/transcript contract, or explicitly records "no probe change needed" for the assumptions it touched. Optional brief-shaped inputs may be added only as inputs to concrete probe runs, not as a standalone library obligation. +- **Verification:** PR review on the doc plus cross-check that new/changed probe assertions map to SPEC assumptions/invariants or acknowledged blind spots; downstream probe runs catch regressions and surface assumption fitness rather than only pass/fail. +- **Cross-cutting obligations:** Treat probe/transcript strategy as canonical verification architecture that must stay in sync with SPEC/PLAN, not as optional commentary. If an assumption is not being tested by its assigned frontier, PLAN should say whether it is deferred, accepted as risk, or needs a spike/oracle pass. - **Traceability:** A5-L -- **Design docs:** [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md) +- **Design docs:** [probes-and-transcripts.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/probes-and-transcripts.md) ### pi-ui-extension-patterns @@ -277,8 +263,8 @@ The POC should maximize assumption falsification rather than merely implement mi ## Recently Completed - 2026-05-22 `web-shell` — Done: M3 now serves the native React web shell over one persistent WebSocket RPC client, blocks/adjudicates branchy transcript shapes for session-consuming reads, serves only static HTTP assets (no REST product reads), projects explicit durable sessions through a canonical Brunch session-envelope reader, renders assistant/user/prompt transcript rows, and keeps browser state as a read-only client attachment rather than a durable session. Verified: `npm run verify` after each slice plus direct host/WebSocket smoke for static HTML, missing REST product reads, explicit `{ sessionId, specId }` projections, transcript display, and exchange projection. Accepted deferral: qualitative browser-open smoke remains environment-blocked by the current macOS sandbox. -- 2026-05-21 `jsonl-session-viability` — Done: Pi JSONL reload preserves coordinator-created binding-only sessions, first assistant/user flushes without duplicate prefixes, `/new` same-spec bindings, raw user/assistant payloads, representative Brunch custom entries, context-participating custom messages, continuity/compaction metadata, structured elicitation entries, defensive active-branch projection behavior, and M1 bundle-local replay parity for briefs #1–#3. Verified: `npm run verify` after each slice. Watch: M2 validates JSONL as sufficient for Brunch-supported linear sessions on current POC terms; branch-aware Brunch sessions are intentionally unsupported per D24-L, and later side-task, mention, and continuity frontiers still own their final payload semantics. -- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; fixture capture copies the same selected Pi JSONL session projected by RPC; brief metadata is Brunch-owned and marks graph/coherence artifacts deferred; briefs #1–#3 have scripted deterministic replay bundles under `.fixtures/<brief-id>/scripted-001/`. Verified: `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, `./src/probes/scripts/verify-m1.sh`, and human inspection that briefs/captures/product-shaped outputs are good on their current terms. Watch: M2 used these captured transcripts as JSONL reload evidence without turning them into a parallel chat/turn store; later elicitation work must revisit the encoded interaction logic, expectations, and knowledge-flow assumptions rather than treating the scripted M1 exchange shape as final product behavior. +- 2026-05-21 `jsonl-session-viability` — Done: Pi JSONL reload preserves coordinator-created binding-only sessions, first assistant/user flushes without duplicate prefixes, `/new` same-spec bindings, raw user/assistant payloads, representative Brunch custom entries, context-participating custom messages, continuity/compaction metadata, structured elicitation entries, defensive active-branch projection behavior, and M1 bundle-local replay parity for the now-retired scripted brief captures. Verified at the time with `npm run verify` after each slice; scripted captures were later deleted when tuple-shaped structured-exchange probes superseded the lightweight prompt/response fixture shape. Watch: M2 validates JSONL as sufficient for Brunch-supported linear sessions on current POC terms; branch-aware Brunch sessions are intentionally unsupported per D24-L, and later side-task, mention, and continuity frontiers still own their final payload semantics. +- 2026-05-21 `mode-shell-and-fixture-driver` — Done: print and RPC transport modes boot through the Brunch host; named `workspace.snapshot` and `session.elicitationExchanges` handlers project coordinator-selected session state; the first capture path copied the same selected Pi JSONL session projected by RPC and produced scripted brief captures. Those M1 artifacts and their milestone probe script are now retired: FE-744 tuple-shaped public-RPC probe artifacts are the current probe/transcript evidence. Historical verification included `npm run verify`, RPC/print parity smoke, exchange projection tests, fixture replay/projection parity tests, and human inspection. Older history: `docs/archive/PLAN_HISTORY.md` @@ -305,6 +291,6 @@ pi-ui-extension-patterns (active FE-744) │ └── subagents-for-proposal-diversity (optional after M5 pressure) -brief-library-curation and fixture-strategy-evolution remain parallel/continuous. +probes-and-transcripts-evolution remains parallel/continuous. flue-pattern-adoption, framework-direction-stubs, and geolog-and-petri-execution are horizon items, not on the active dependency spine. ``` diff --git a/memory/SPEC.md b/memory/SPEC.md index c59aa227..f7818d72 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -6,7 +6,7 @@ Anchored on the three canonical POC docs: - docs/architecture/prd.md - docs/architecture/pi-seam-extensions.md - - docs/architecture/fixture-strategy.md + - docs/architecture/probes-and-transcripts.md When re-running ln-spec: read this file first, preserve existing authority, and evolve only the touched area. SPEC is not an implementation diary. @@ -84,7 +84,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Verification & fixtures -24. Brunch must ship a brief library and an agent-as-user driver over the JSON-RPC stdio surface to capture replayable golden runs and property-checkable fixtures. The first product-level driver proof is a deterministic public-RPC elicitation session parity run: at least ten assistant-first exchanges through activated workspace/spec/session state, with Pi JSONL and Brunch projections comparable in kind and quality to an equivalent TUI-driven session. +24. Brunch must ship probe drivers over the public JSON-RPC surface that produce replayable transcript artifacts and property-checkable reports. The first product-level driver proof is a deterministic public-RPC elicitation session parity run: at least ten assistant-first exchanges through activated workspace/spec/session state, with Pi JSONL and Brunch projections comparable in kind and quality to an equivalent TUI-driven session. Brief-based golden fixtures are a future input style, not a separate required subsystem. #### Runtime profile & prompting @@ -102,15 +102,15 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A1-L | `pi-coding-agent` exposes enough seams (services, custom message roles, `prepareNextTurn`, `transformContext`, RPC mode, JSONL sessions, extension UI surface) to host all M0–M9 capabilities without forking pi. | high | open | D1-L | M0–M2: walking skeleton + mode shell + JSONL viability prove the substrate. | | A3-L | A single Brunch-owned command layer (with optimistic concurrency, validation, audit, and coherence triggers) is sufficient for both agent and human writers across all four modes for the POC's graph scale. | medium | open | D4-L | M4 + M5 + M6: graph plane, agent-↔-graph wiring, and authority tiers all routed through the same surface. | | A4-L | A monotonic global LSN per commit (one-LSN-per-transaction) is adequate for change-log replay, reconciliation-need ordering, and mention staleness without per-row vector clocks. | high | open | I1-L, I4-L | M4 + M7: replay fidelity and `worldUpdate` ordering tests. | -| A5-L | An agent-as-user driver running over JSON-RPC stdio can produce regression-quality fixtures across a curated brief library. | medium | open | D5-L | M1 — first replay-regression fixtures land. | +| A5-L | Agent-as-user probes over the public Brunch RPC surface can produce regression-quality transcript artifacts without depending on a parallel brief-library subsystem. | medium | partially validated | D5-L, D48-L, D49-L | FE-744 public-RPC parity proves the transport/projection substrate for ten deterministic structured exchanges; future brief-based golden-fixture work must enter through the probe/transcript artifact path. | | A6-L | The graph-native vocabulary can be deferred from explicit per-plane namespacing (`intent.*`, `oracle.*`, etc.) and start unified under `graph.*` without painful rework later. | medium | open | D3-L | M4–M5: if intent-plane plus oracle-plane stubs both fit under one namespace cleanly, the assumption holds. | -| A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Fixture runs across briefs #1–#7: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | +| A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Targeted probe runs that exercise framing pressure: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | | A10-L | A persistent TUI chrome region showing cwd / spec / phase / runtime bundle can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and elicitation-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | -| A14-L | LLM elicitor agents can reliably produce graph-structurally-legal review-set proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation). | medium | open | D27-L | Fixture replay across briefs that exercise batch-proposal and commitment review-set flows; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | +| A14-L | LLM elicitor agents can reliably produce graph-structurally-legal review-set proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation). | medium | open | D27-L | Probe runs that exercise batch-proposal and commitment review-set flows; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | | A15-L | Establishment hints as transcript-native custom entries (`brunch.establishment_offer`) provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms lens offers are reconstructable from transcript; chrome region renders ambient affordances from the latest such entry. | | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | | A17-L | A user-level temperamental preference for interrogative vs proposal-based elicitation meaningfully affects adoption and eventually warrants expression as a user-level setting. | low | open | D25-L, D26-L | Deferred; surfaces from outer-loop walkthroughs and adversarial fixtures once both single-exchange and batch-proposal flows exist in product. | @@ -147,7 +147,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Transport & client -- **D5-L — Brunch JSON-RPC is the single public product protocol.** Brunch exposes one public product RPC surface over stdio, WebSocket, and in-process handlers. Product clients — web UI, CLI probes, TUI adapters, and future relays — call Brunch method families and should not coordinate raw Pi RPC plus Brunch product RPC themselves. Pi RPC may be used behind a Brunch adapter for agent-loop mechanics and Pi extension UI, but it is not a second public product API. HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The Brunch stdio surface is also the agent-as-user fixture-capture interface, even when that driver internally relays Pi RPC events. Depends on: A5-L. Supersedes: treating raw Pi RPC as the product API for Brunch data. +- **D5-L — Brunch JSON-RPC is the single public product protocol.** Brunch exposes one public product RPC surface over stdio, WebSocket, and in-process handlers. Product clients — web UI, CLI probes, TUI adapters, and future relays — call Brunch method families and should not coordinate raw Pi RPC plus Brunch product RPC themselves. Pi RPC may be used behind a Brunch adapter for agent-loop mechanics and Pi extension UI, but it is not a second public product API. HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The Brunch stdio surface is also the agent-as-user probe driver interface, even when that driver internally relays Pi RPC events. Depends on: A5-L. Supersedes: treating raw Pi RPC as the product API for Brunch data. - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. - **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. - **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes named method families such as `rpc.*`, `workspace.*`, `session.*`, `elicitation.*`, `graph.*`, `coherence.*`, and `command.*`; each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/state.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch command layer. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model and any two-public-RPC-surface split. @@ -253,7 +253,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported elicitation exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | | I11-L | No durable graph mutation path — including migrations, maintenance scripts, elicitor-capture writes, deferred observer/auditor writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | | I12-L | Side-task results are delivered only at turn boundaries; no side-task result may steer or mutate the active turn outside the next-turn delivery path. | planned (M7 side-task delivery invariant) | D15-L | -| I13-L | At any idle linear session leaf, the latest unresolved interaction state is system/assistant-originated: user input is a response to an elicitation prompt, not ambient chat. | planned (M1 fixture + transcript projection tests) | D12-L, D24-L | +| I13-L | At any idle linear session leaf, the latest unresolved interaction state is system/assistant-originated: user input is a response to an elicitation prompt, not ambient chat. | partially covered (structured-exchange pending/respond projection tests and FE-744 public-RPC parity probe; richer idle-state probes still planned) | D12-L, D24-L | | I14-L | If Brunch introduces deferred observer/auditor jobs, they are keyed by session id plus elicitation-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate jobs for the same exchange, and job writes never become the primary freshness path for the next elicitor turn. | deferred/planned only if observer-audit queue lands (M5+ restart/idempotence tests) | D18-L, D4-L | | I15-L | Every review-set acceptance routes through `CommandExecutor` as one atomic `acceptReviewSet` command producing one LSN, one change-log entry, and one transaction over the entire batch. Partial acceptance is not representable through any product API. | planned (M5+ batch-acceptance command tests; review-set fixture parity) | D20-L, D27-L; I1-L, I11-L | | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | @@ -390,7 +390,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Structured elicitation entry** | Optional Brunch custom transcript entry used when an elicitation prompt/offer or response carries actions, choices, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, cancelled, marked unavailable, or explicitly declared display-only. A distinct `skipped` terminal state is deferred until product pressure distinguishes “declined but continue” from cancellation or an explicit `none`/`other` answer. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi `present_*`/`request_*` tool tuple whose result details carry the structured display and response. | | **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `elicitation.respond`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | -| **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for fixture capture. | +| **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for probe reports. | | **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` / future `capture_*` families. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response; `capture_*` tools persist assistant analysis of likely semantic changes without mutating graph truth. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | @@ -418,8 +418,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Epistemic status** | Confidence basis: `observed | asserted | assumed | inferred`. Like `authority`, this is a context-shaping label for attention, grouping, and compression rather than a complete theory of truth. | | **Framing-as** | Orthogonal modality classifying a node's product role (e.g. `problem`, `persona`, `non_goal`) within an allowed matrix. | | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | -| **Brief** | A short curated product brief in `.fixtures/briefs/`, run by the agent-as-user driver to produce golden captures. Dev-only fixture input; distinct from runtime user-facing **scenarios**. | -| **Capture / Run / Fixture** | A captured agent-as-user run produces durable review artifacts. Brief-library captures use `.fixtures/<brief-id>/<run-id>/` with `.jsonl`, deferred `.graph.json` / `.coherence.json`, and `.meta.json` artifacts; product probe-oracle captures may use `.fixtures/runs/<probe-id>/<run-id>/` with `session.jsonl`, `transcript.md`, and `report.json` when the review object is a probe run rather than a curated brief. | +| **Probe run** | A scripted or executable check of a Brunch seam that drives the public product surface and persists reviewable artifacts under `.fixtures/runs/<probe-id>/<run-id>/`. | +| **Transcript artifact** | The durable transcript evidence for a probe run, usually `session.jsonl` plus a Brunch-semantic `transcript.md`; reports explain the oracle, but transcript artifacts remain the evidence. | +| **Probe brief** | Optional future input text for an agent-as-user probe. A brief is not a canonical artifact family by itself; if brief-based golden fixtures return, they produce normal probe runs and transcript artifacts. | | **Elicitation lens** | A narrower interpretive strategy applied within the `elicitor` agent role — e.g. `step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`. Lens is metadata on elicitor-emitted custom transcript entries. Agent roles (`elicitor` / `reviewer` / `reconciler` / deferred observer-auditor roles) remain orthogonal. | | **Single-exchange elicitation flow** | A prompt/answer exchange such as step-by-step questioning or contrastive disambiguation. The elicitor captures high-confidence extractive content synchronously post-exchange; low-confidence implications stay in preface/question material. | | **Batch-proposal flow** | A proposal/review flow with structured entity-draft payloads in `brunch.review_set_proposal` entries. Durable graph changes land only through review-set approval. | @@ -440,7 +441,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Verification Stance -Verification is first-class product work for Brunch because the POC's claims are mostly seam claims: pi harness reuse, JSONL transcript truth, one mutation authority, thin RPC/projection handlers, graph continuity, and fixture-driven elicitation. A frontier is not complete merely because the UI appears alive; durable architectural claims must be proven against canonical stores or projection handlers. +Verification is first-class product work for Brunch because the POC's claims are mostly seam claims: pi harness reuse, JSONL transcript truth, one mutation authority, thin RPC/projection handlers, graph continuity, and probe-driven elicitation. A frontier is not complete merely because the UI appears alive; durable architectural claims must be proven against canonical stores or projection handlers. Brunch uses a three-layer stance: @@ -451,7 +452,7 @@ Brunch uses a three-layer stance: **POC-phase posture (M0–M9): viable-and-reasonable, not hardened.** Across the POC milestone ladder, the goal is "the system is viable and works at least reasonably well" — proof-of-life for each architectural claim, not statistical robustness. The implications for oracle design: - **Structural invariants stay hard gates** (atomicity, no-bypass, write-target restrictions, schema conformance, supersedes acyclicity). These don't get cheaper to defer; getting them wrong corrupts the substrate. -- **LLM-behavioral metrics — proposal structural-legality rate, lens-recommendation appropriateness, reviewer-finding precision — are *tracked as fitness*, not gated.** Captured per-run in fixture metadata; surfaced for human review; thresholds noted as targets (e.g. ≥95% legality on first attempt) but failure to hit them does not block merges during POC. +- **LLM-behavioral metrics — proposal structural-legality rate, lens-recommendation appropriateness, reviewer-finding precision — are *tracked as fitness*, not gated.** Captured per-run in probe report metadata; surfaced for human review; thresholds noted as targets (e.g. ≥95% legality on first attempt) but failure to hit them does not block merges during POC. - **Multi-run variance probes use conservative replication** (3 runs middle-loop, 5 outer-loop) — enough to detect catastrophic instability, not enough to characterize tail distributions. Higher replication is post-POC. - **Adversarial/generative fixture campaigns stay small and targeted** during POC: one or two known-bad scenarios per relevant invariant, not exhaustive coverage. Coverage breadth is post-POC. - **Deferred to post-POC hardening:** mutation testing, large-seed campaigns, performance budgets, accessibility audits, formal pass-rate thresholds as merge gates, exhaustive adversarial coverage. @@ -484,25 +485,25 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo - **Gate before commit:** `npm run verify`. All steps must pass; no override. - **Failure protocol:** stop on first failure; the failure becomes the must-fix task; re-run the stack from step 1; only proceed when all checks pass. - **Frontier completion:** manual smoke can prove presentation life, but any durable product claim must also have an artifact/query oracle, property/round-trip test, contract test, or fixture assertion tied to the canonical store or projection handler that owns the fact. -- **Fixture architecture:** the POC adopts the three-layer model from [fixture-strategy.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/fixture-strategy.md): replay regression, property regression, and adversarial / generative probes. Brief captured-run bundles converge on `.jsonl`, `.graph.json`, `.coherence.json`, and `.meta.json` artifacts under `.fixtures/`. Probe-oracle review bundles that are not brief-library runs may be committed under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. Transcript renderers default to the Brunch-semantic view: structured-exchange and Brunch custom transcript evidence included, unrelated generic tool results skipped unless an explicit raw/debug mode is requested. +- **Probe/transcript architecture:** the POC uses transcript-backed probe runs as the current verification artifact model. Committed probe evidence lives under `.fixtures/runs/<probe-id>/<run-id>/` with colocated `session.jsonl`, rendered `transcript.md`, and `report.json` so the executable oracle and human transcript oracle stay paired. Brief-based golden fixtures are deferred; if they return, briefs are probe inputs and the resulting transcript-backed probe run is canonical. Transcript renderers default to the Brunch-semantic view: structured-exchange and Brunch custom transcript evidence included, unrelated generic tool results skipped unless an explicit raw/debug mode is requested. ### Oracle Strategy by Loop Tier | Loop | Oracle family | Proves | Primary claims | | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | -| Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, fixture metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | +| Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, probe report metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | | Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; pending-exchange start/read/respond handlers preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | -| Middle | Fixture replay and property assertions | Brief-driven sessions still produce structurally valid transcript/graph/coherence artifacts despite model drift. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in fixture metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | +| Middle | Probe transcript replay and property assertions | Probe runs preserve transcript evidence that can be replayed, rendered, and compared against current Brunch projections. Future brief-driven sessions, if revived, must produce the same probe-run artifact shape. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in probe metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | | Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Middle | Capture-analysis transcript oracle | Future `capture_*` probes persist ANALYSIS as normal Brunch toolResults, assert no graph writes occur, render full analysis in Markdown/ASCII transcripts, and assert the TUI path hides or collapses the same result without losing persisted content/details. | D17-L, D18-L, D37-L, D47-L, D50-L; I23-L, I30-L, I33-L. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | -| Outer | Adversarial / generative fixture probes | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, **reviewer-finding precision via small targeted set of briefs designed to produce *known* coherence problems** (POC-scope: 1–2 known-bad scenarios per relevant invariant, not exhaustive coverage). | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | +| Outer | Adversarial / generative probe runs | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, and reviewer-finding precision through small targeted probe scenarios (brief-shaped inputs are allowed, but the probe run and transcript artifacts are canonical). POC scope remains one or two known-bad scenarios per relevant invariant, not exhaustive coverage. | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Probe Oracle Design @@ -531,7 +532,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I10-L | M1/M2 exchange projection tests, linear transcript validation, and no chat/turn architectural test. | | I11-L | M4/M5 no-bypass architectural test plus command transaction integration tests. | | I12-L | M7 side-task delivery invariant tests and adversarial fixture when side tasks are active. | -| I13-L | M1 fixture/projection checks for idle linear-session leaf state. | +| I13-L | Structured-exchange pending/respond projection tests plus FE-744 public-RPC parity probe for idle linear-session leaf state; richer probe runs still planned. | | I14-L | Deferred unless observer/auditor queue lands: restart/idempotence tests over exchange-keyed jobs, plus proof that next-turn freshness does not depend on the async job completing. | | I15-L | M5+ middle-loop property tests for batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible under mid-batch validation failure) paired with `acceptReviewSet` contract tests; review-set fixture parity in replay. | | I16-L | M5+ middle-loop architectural boundary test on reviewer-attributed `CommandExecutor` writers (rejects any non-`reconciliation_need` target); paired with reviewer-attributed command-result audit fixture. | @@ -545,7 +546,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I28-L | Inner — TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | -| I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/tui-client/.pi/extensions/subagents/agents/*.md` frontmatter and `src/tui-client/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — fixture-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | +| I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/tui-client/.pi/extensions/subagents/agents/*.md` frontmatter and `src/tui-client/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | | I32-L | FE-744 public-RPC elicitation parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic ten-turn agent-as-user run over Brunch JSON-RPC only, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | @@ -553,7 +554,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` ### Design Notes -- **Deterministic before generative.** M1 should prefer a deterministic or tightly scripted user-agent path for the first captured run before relying on LLM persona variance. Generative/adversarial probes come after the transcript and fixture substrate is trusted. M1 scripted captures prove the transport/projection/fixture substrate on its current terms; they do not settle the final elicitation interaction logic, knowledge flow, or prompt/response expectation model. +- **Deterministic before generative.** Probe runs should prefer deterministic or tightly scripted paths before relying on LLM persona variance. Generative/adversarial probes come after the transcript substrate is trusted. Retired M1 scripted captures proved the early transport/projection substrate on then-current terms, but tuple-shaped FE-744 public-RPC probes are the current evidence path. - **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. - **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The schema/component shape should be designed separately before implementation; the durable commitment now is only the toolResult-family carrier and visibility policy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. @@ -565,15 +566,15 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | Blind spot | Reason | Mitigation | Revisit trigger | | --- | --- | --- | --- | | Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query probe oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | -| LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and M1 scripted exchanges intentionally encode only a thin current exchange model. | Brief library, human-reviewed golden captures, adversarial probes, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated fixture failures where structure passes but elicitation is judged poor, or M2/M3 reveals that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | +| LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and retired M1 scripted exchanges encoded only a thin obsolete exchange model. | Transcript-backed probe runs, human-reviewed probe reports, adversarial probe scenarios, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated probe failures where structure passes but elicitation is judged poor, or later runs reveal that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | | Subscription reconnect/resume | POC can prove snapshot + live update without hardening network recovery yet. | Contract tests for initial snapshot and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | | Performance and scale | Local POC graph/session sizes are small; premature budgets may distort design. | Keep exports/checkers text-native and simple; add budgets when slow tests appear. | `npm run verify` or fixture runs exceed acceptable local iteration time. | | Cross-platform terminal rendering | TUI chrome visuals may differ by terminal. | Test state derivation and keep manual smoke on primary dev environment. | Distribution target broadens or terminal rendering bugs recur. | -| Lens-recommendation appropriateness | No deterministic ground truth for "did the agent offer the right strategy at the right time" given temperament + grounding density inputs. | Brief-driven outer-loop walkthrough; small targeted scenarios where recommended lens is judged by reviewer; tracked as fitness, not gated. | Repeated user complaints that the offered strategies feel wrong, or fixture review reveals systematic mis-offers. | +| Lens-recommendation appropriateness | No deterministic ground truth for "did the agent offer the right strategy at the right time" given temperament + grounding density inputs. | Probe-driven outer-loop walkthrough; small targeted scenarios where recommended lens is judged by reviewer; tracked as fitness, not gated. | Repeated user complaints that the offered strategies feel wrong, or fixture review reveals systematic mis-offers. | | Framing/proposal quality at thin grounding | Generative-lens proposals may be syntactically legal but semantically weak when grounding is thin; `epistemic_status` honesty may not be enforceable without human judgment. | A14-L proposal-legality rate tracked as fitness; outer-loop walkthrough of proposals under thin vs rich grounding; `epistemic_status` distribution surfaced per run. | Acceptance-without-rework rates drop, or reviewers consistently mark proposals as `inferred`/`asserted` despite asserted grounding. | | Reviewer finding precision (false positives/negatives) | Advisory-only reviewer can spam reconciliation needs (false positives) or miss real coherence gaps (false negatives); both erode trust. | Targeted adversarial briefs with known-bad coherence problems; precision/recall surfaced per run as fitness; user can dismiss reviewer findings without consequence. | Users systematically ignore reviewer findings, or coherence gaps slip past reviewer in known-bad fixtures. | | In-flight reviewer-signal UX | Chrome rendering of "reviewer running / has findings" before next-turn delivery is not yet designed; cost may exceed value in POC. | Probe oracle on chrome state after batch-accept; defer in-flight progress affordances unless a frontier explicitly demands them. | Users report confusion about whether reviewer ran or completed; or async job latency makes silence feel like failure. | -| Meta-rubric usefulness (D31-L) | Universal evaluative dimensions (complexity, lock-in, etc.) may or may not be productive across lens types; this is an unproven hypothesis. | Comparative outer-loop walkthrough: same proposal scenario with and without meta-rubric framing; user judgment captured in fixture metadata. | Meta-rubric framings are consistently ignored by users, or consistently produce better decisions — either signal warrants spec revision. | +| Meta-rubric usefulness (D31-L) | Universal evaluative dimensions (complexity, lock-in, etc.) may or may not be productive across lens types; this is an unproven hypothesis. | Comparative outer-loop walkthrough: same proposal scenario with and without meta-rubric framing; user judgment captured in probe metadata. | Meta-rubric framings are consistently ignored by users, or consistently produce better decisions — either signal warrants spec revision. | ### Acceptance Criteria @@ -582,6 +583,6 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` 3. Coherence is explicit product state, queryable through `graph.*` reads and visible in the TUI chrome. 4. The browser does not require a second primary data plane. 5. The transcript strategy is validated: pi JSONL sessions either suffice for the POC, or their insufficiency is sharply bounded with a justified fallback. -6. A fixture library of at least the seven starter briefs is captured and replayable; property invariants from the fixture-strategy doc pass against captured runs. +6. Probe runs with transcript artifacts can exercise current Brunch seams, and future brief-based golden fixtures, if revived, pass through the same probe/transcript artifact path rather than a parallel brief-library subsystem. 7. Brunch can be built as a local product over pi without forking pi. 8. A public Brunch RPC agent-as-user can discover methods, activate workspace/spec/session, complete at least ten assistant-first structured elicitation turns, and leave JSONL/projection evidence comparable to a TUI session without speaking raw Pi RPC. diff --git a/src/jsonl-session-viability.test.ts b/src/jsonl-session-viability.test.ts index 3928cfce..e438f501 100644 --- a/src/jsonl-session-viability.test.ts +++ b/src/jsonl-session-viability.test.ts @@ -1,7 +1,6 @@ import { mkdtempSync } from "node:fs" -import { readFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { dirname, join } from "node:path" +import { join } from "node:path" import { describe, expect, it } from "vitest" @@ -13,52 +12,13 @@ import { type SessionMessageEntry, } from "@earendil-works/pi-coding-agent" -import { - loadLinearElicitationExchangeProjection, - type ElicitationExchangeProjection, -} from "./elicitation-exchange.js" -import { isSessionBindingEntry } from "./session-binding.js" import { assistantMessage, userMessage } from "./test-helpers.js" -const M1_FIXTURE_IDS = ["brief-001", "brief-002", "brief-003"] as const -const M1_RUN_ID = "scripted-001" - interface PersistedSessionFixture { file: string manager: SessionManager } -interface M1FixtureMeta { - briefId: string - runId: string - session: { - id: string - sourceFile: string - } - projectionSummary: { - status: ElicitationExchangeProjection["status"] - exchangeCount: number - openPrompt: boolean - } - artifacts: { - jsonl: string - graph: { status: "deferred" } - coherence: { status: "deferred" } - } -} - -interface M1Brief { - id: string - title: string -} - -interface M1FixtureBundle { - bundleDir: string - jsonlPath: string - meta: M1FixtureMeta - brief: M1Brief -} - describe("Pi JSONL transcript viability", () => { it("jsonl raw user assistant payload survival", async () => { const { file, manager } = createPersistedSession() @@ -311,101 +271,6 @@ describe("Pi JSONL transcript viability", () => { }) }) -describe("M1 fixture JSONL replay parity", () => { - it("m1 fixture bundles reload for transcript parity", async () => { - for (const briefId of M1_FIXTURE_IDS) { - const bundle = await loadM1FixtureBundle(briefId) - const reloaded = SessionManager.open( - bundle.jsonlPath, - undefined, - process.cwd(), - ) - - expect(reloaded.getHeader()).toMatchObject({ id: bundle.meta.session.id }) - expect(reloaded.getEntries()).not.toHaveLength(0) - expect(bundle.meta.artifacts.jsonl).toBe(`${M1_RUN_ID}.jsonl`) - } - }) - - it("m1 fixture bundle metadata matches reprojected exchanges", async () => { - for (const briefId of M1_FIXTURE_IDS) { - const bundle = await loadM1FixtureBundle(briefId) - const projection = await loadLinearElicitationExchangeProjection( - bundle.jsonlPath, - ) - - expect(summaryForProjection(projection)).toEqual( - bundle.meta.projectionSummary, - ) - } - }) - - it("m1 fixture bundle bindings match briefs", async () => { - for (const briefId of M1_FIXTURE_IDS) { - const bundle = await loadM1FixtureBundle(briefId) - const bindings = SessionManager.open( - bundle.jsonlPath, - undefined, - process.cwd(), - ) - .getEntries() - .filter(isSessionBindingEntry) - - expect(bindings).toHaveLength(1) - expect(bindings[0]?.data).toMatchObject({ - sessionId: bundle.meta.session.id, - specTitle: bundle.brief.title, - }) - } - }) - - it("m1 fixture metadata treats source file as provenance only", async () => { - for (const briefId of M1_FIXTURE_IDS) { - const bundle = await loadM1FixtureBundle(briefId) - - expect(bundle.meta.session.sourceFile).toMatch(/^\//u) - expect(bundle.jsonlPath).toBe( - join(bundle.bundleDir, bundle.meta.artifacts.jsonl), - ) - expect(bundle.jsonlPath).not.toBe(bundle.meta.session.sourceFile) - } - }) -}) - -async function loadM1FixtureBundle( - briefId: typeof M1_FIXTURE_IDS[number], -): Promise<M1FixtureBundle> { - const bundleDir = join(".fixtures", briefId, M1_RUN_ID) - const metaPath = join(bundleDir, `${M1_RUN_ID}.meta.json`) - const meta = JSON.parse(await readFile(metaPath, "utf8")) as M1FixtureMeta - const jsonlPath = join(dirname(metaPath), meta.artifacts.jsonl) - const briefPath = join( - ".fixtures", - "briefs", - `${briefId}-${briefSlug(briefId)}.json`, - ) - const brief = JSON.parse(await readFile(briefPath, "utf8")) as M1Brief - return { bundleDir, jsonlPath, meta, brief } -} - -function briefSlug(briefId: typeof M1_FIXTURE_IDS[number]): string { - return { - "brief-001": "identity-reference", - "brief-002": "state-lifecycle", - "brief-003": "derived-views", - }[briefId] -} - -function summaryForProjection( - projection: ElicitationExchangeProjection, -): M1FixtureMeta["projectionSummary"] { - return { - status: projection.status, - exchangeCount: projection.exchanges.length, - openPrompt: projection.openPrompt !== null, - } -} - function createPersistedSession(): PersistedSessionFixture { const cwd = mkdtempSync(join(tmpdir(), "brunch-jsonl-")) const manager = SessionManager.create(cwd, join(cwd, ".brunch/sessions")) diff --git a/src/probes/brief-library.test.ts b/src/probes/brief-library.test.ts deleted file mode 100644 index b6147fea..00000000 --- a/src/probes/brief-library.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { loadBriefLibrary } from "./brief-library.js" - -describe("fixture brief library", () => { - it("loads the first three deterministic product briefs", async () => { - const briefs = await loadBriefLibrary(".fixtures/briefs") - - expect(briefs.map((brief) => brief.id)).toEqual([ - "brief-001", - "brief-002", - "brief-003", - ]) - expect(briefs).toEqual( - Array.from({ length: 3 }, () => - expect.objectContaining({ - schemaVersion: 1, - title: expect.any(String), - kernelTags: expect.arrayContaining([expect.any(String)]), - productBrief: expect.stringMatching(/\w/u), - expectedStructuralObservations: expect.arrayContaining([ - expect.any(String), - ]), - scriptedUserNotes: expect.arrayContaining([expect.any(String)]), - }), - ), - ) - expect(briefs[0]?.productBrief).not.toContain("assert") - }) -}) diff --git a/src/probes/brief-library.ts b/src/probes/brief-library.ts deleted file mode 100644 index ae6fba14..00000000 --- a/src/probes/brief-library.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { readdir, readFile } from "node:fs/promises" -import { join } from "node:path" - -export interface FixtureBrief { - schemaVersion: 1 - id: string - title: string - kernelTags: string[] - productBrief: string - expectedStructuralObservations: string[] - scriptedUserNotes: string[] - deferredExpectations?: { - graph?: string - coherence?: string - } -} - -export async function loadBriefLibrary(dir: string): Promise<FixtureBrief[]> { - const files = (await readdir(dir)) - .filter((file) => file.endsWith(".json")) - .sort() - const briefs = await Promise.all( - files.map(async (file) => - parseBrief(await readFile(join(dir, file), "utf8"), file), - ), - ) - return briefs.sort((left, right) => left.id.localeCompare(right.id)) -} - -function parseBrief(content: string, source: string): FixtureBrief { - const parsed = JSON.parse(content) as unknown - if (!isFixtureBrief(parsed)) { - throw new Error(`${source} is not a valid fixture brief`) - } - return parsed -} - -function isFixtureBrief(value: unknown): value is FixtureBrief { - if (typeof value !== "object" || value === null) { - return false - } - - const brief = value as Partial<FixtureBrief> - return ( - brief.schemaVersion === 1 && - typeof brief.id === "string" && - /^brief-\d{3}$/u.test(brief.id) && - typeof brief.title === "string" && - brief.title.length > 0 && - isNonEmptyStringArray(brief.kernelTags) && - typeof brief.productBrief === "string" && - brief.productBrief.length > 0 && - isNonEmptyStringArray(brief.expectedStructuralObservations) && - isNonEmptyStringArray(brief.scriptedUserNotes) - ) -} - -function isNonEmptyStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.length > 0 && - value.every((item) => typeof item === "string" && item.length > 0) - ) -} diff --git a/src/probes/fixture-capture.test.ts b/src/probes/fixture-capture.test.ts deleted file mode 100644 index 5d9da24b..00000000 --- a/src/probes/fixture-capture.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { describe, expect, it } from "vitest" - -import { - createWorkspaceSessionCoordinator, - type WorkspaceSessionCoordinator, -} from "../workspace-session-coordinator.js" -import { loadLinearElicitationExchangeProjection } from "../elicitation-exchange.js" -import { assistantMessage, userMessage } from "../test-helpers.js" -import { - captureDeterministicBriefRuns, - captureFixtureRun, -} from "./fixture-capture.js" - -describe("fixture capture", () => { - it("captures the coordinator-selected session without injecting a test coordinator", async () => { - const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-real-")) - const workspace = await createWorkspaceSessionCoordinator({ - cwd, - }).createSetupSession({ - specTitle: "Fixture spec", - }) - workspace.session.manager.appendMessage( - assistantMessage("Real selected question"), - ) - workspace.session.manager.appendMessage(userMessage("Real selected answer")) - - const result = await captureFixtureRun({ - cwd, - briefId: "brief-001", - runId: "run-001", - timestamp: "2026-05-21T00:00:00.000Z", - }) - - const copiedJsonl = await readFile(result.jsonlFile, "utf8") - const metadata = JSON.parse(await readFile(result.metaFile, "utf8")) as { - session: { - id: string - sourceFile: string - } - projectionSummary: { - status: string - exchangeCount: number - } - } - - expect(copiedJsonl).toContain("Real selected question") - expect(copiedJsonl).toContain("Real selected answer") - expect(metadata.session.id).toBe(workspace.session.id) - expect(metadata.session.sourceFile).toBe(workspace.session.file) - expect(metadata.projectionSummary).toMatchObject({ - status: "ready", - exchangeCount: 1, - }) - }) - - it("reports Brunch's package version, not the caller project's version", async () => { - const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-package-")) - await writeFile( - join(cwd, "package.json"), - `${JSON.stringify({ name: "caller-project", version: "9.9.9" })}\n`, - ) - const workspace = await createWorkspaceSessionCoordinator({ - cwd, - }).createSetupSession({ - specTitle: "Fixture spec", - }) - workspace.session.manager.appendMessage(assistantMessage("Question")) - workspace.session.manager.appendMessage(userMessage("Answer")) - - const result = await captureFixtureRun({ - cwd, - briefId: "brief-001", - runId: "run-001", - timestamp: "2026-05-21T00:00:00.000Z", - }) - - const metadata = JSON.parse(await readFile(result.metaFile, "utf8")) as { - brunchVersion: string - timestamp: string - } - - expect(metadata.brunchVersion).toBe("0.0.0") - expect(metadata.brunchVersion).not.toBe("9.9.9") - expect(metadata.timestamp).toBe("2026-05-21T00:00:00.000Z") - }) - - it("captures a deterministic JSONL and metadata bundle through RPC", async () => { - const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-")) - const workspace = await createWorkspaceSessionCoordinator({ - cwd, - }).createSetupSession({ - specTitle: "Fixture spec", - }) - workspace.session.manager.appendMessage(assistantMessage("Question")) - workspace.session.manager.appendMessage(userMessage("Answer")) - - const coordinator: WorkspaceSessionCoordinator = { - ...createWorkspaceSessionCoordinator({ cwd }), - async openDefaultWorkspace() { - return workspace - }, - } - - const result = await captureFixtureRun({ - cwd, - briefId: "brief-001", - runId: "run-001", - timestamp: "2026-05-21T00:00:00.000Z", - coordinator, - }) - - expect(result.runDir).toBe(join(cwd, ".fixtures", "brief-001", "run-001")) - expect(JSON.parse(await readFile(result.metaFile, "utf8"))).toMatchObject({ - schemaVersion: 1, - briefId: "brief-001", - runId: "run-001", - timestamp: "2026-05-21T00:00:00.000Z", - brunchVersion: "0.0.0", - session: { - id: expect.any(String), - sourceFile: expect.stringContaining(".brunch/sessions"), - }, - driver: { - mode: "scripted-deterministic", - }, - projectionSummary: { - status: "ready", - exchangeCount: 1, - openPrompt: false, - }, - artifacts: { - jsonl: "run-001.jsonl", - graph: { status: "deferred" }, - coherence: { status: "deferred" }, - }, - }) - expect(await readFile(result.jsonlFile, "utf8")).toContain( - '"role":"assistant"', - ) - }) - - it("replays captured brief bundles through exchange projection", async () => { - for (const briefId of ["brief-001", "brief-002", "brief-003"]) { - const runId = "scripted-001" - const runDir = join(".fixtures", briefId, runId) - const metadata = JSON.parse( - await readFile(join(runDir, `${runId}.meta.json`), "utf8"), - ) as { - briefId: string - runId: string - projectionSummary: { - status: string - exchangeCount: number - openPrompt: boolean - } - } - const projection = await loadLinearElicitationExchangeProjection( - join(runDir, `${runId}.jsonl`), - ) - - expect(metadata.briefId).toBe(briefId) - expect(metadata.runId).toBe(runId) - expect({ - status: projection.status, - exchangeCount: projection.exchanges.length, - openPrompt: projection.openPrompt !== null, - }).toEqual(metadata.projectionSummary) - } - }) - - it("captures deterministic runs for the first three briefs", async () => { - const cwd = await mkdtemp(join(tmpdir(), "brunch-fixture-driver-")) - - const results = await captureDeterministicBriefRuns({ - cwd, - briefsDir: ".fixtures/briefs", - runId: "scripted-001", - timestamp: "2026-05-21T00:00:00.000Z", - }) - - expect(results).toHaveLength(3) - const seenSpecIds = new Set<string>() - const expectedTitlesByBriefId = new Map([ - ["brief-001", "Team knowledge cards"], - ["brief-002", "Approval workflow for vendor invoices"], - ["brief-003", "Project dashboard rollups"], - ]) - - for (const result of results) { - const metadata = JSON.parse(await readFile(result.metaFile, "utf8")) as { - briefId: string - runId: string - driver: { mode: string } - session: { id: string } - projectionSummary: { - status: string - exchangeCount: number - openPrompt: boolean - } - artifacts: { - jsonl: string - graph: { status: string } - coherence: { status: string } - } - } - const jsonl = await readJsonl(result.jsonlFile) - const binding = singleSessionBinding(jsonl) - const expectedTitle = expectedTitlesByBriefId.get(metadata.briefId) - - expect(metadata.runId).toBe("scripted-001") - expect(metadata.driver.mode).toBe("scripted-deterministic") - expect(metadata.session.id).toEqual(expect.any(String)) - expect(metadata.projectionSummary).toEqual({ - status: "ready", - exchangeCount: 1, - openPrompt: false, - }) - expect(metadata.artifacts).toEqual({ - jsonl: "scripted-001.jsonl", - graph: { status: "deferred" }, - coherence: { status: "deferred" }, - }) - expect(expectedTitle).toBeDefined() - expect(binding.data.specTitle).toBe(expectedTitle) - expect(jsonl.map((entry) => JSON.stringify(entry)).join("\n")).toContain( - metadata.briefId, - ) - expect(seenSpecIds.has(binding.data.specId)).toBe(false) - seenSpecIds.add(binding.data.specId) - } - }) -}) - -async function readJsonl(file: string): Promise<unknown[]> { - return (await readFile(file, "utf8")) - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line) as unknown) -} - -interface SessionBindingProjection { - data: { - specId: string - specTitle: string - } -} - -function singleSessionBinding(entries: unknown[]): SessionBindingProjection { - const bindings = entries.filter( - (entry): entry is SessionBindingProjection => - typeof entry === "object" && - entry !== null && - (entry as { customType?: unknown }).customType === - "brunch.session_binding" && - typeof (entry as { data?: { specId?: unknown } }).data?.specId === - "string" && - typeof (entry as { data?: { specTitle?: unknown } }).data?.specTitle === - "string", - ) - expect(bindings).toHaveLength(1) - return bindings[0]! -} diff --git a/src/probes/fixture-capture.ts b/src/probes/fixture-capture.ts deleted file mode 100644 index d3fc80b9..00000000 --- a/src/probes/fixture-capture.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises" -import { dirname, join } from "node:path" -import { PassThrough } from "node:stream" -import { fileURLToPath } from "node:url" - -import { loadBriefLibrary, type FixtureBrief } from "./brief-library.js" -import { createRpcHandlers, runJsonRpcLineServer } from "../rpc/handlers.js" -import type { ElicitationExchangeProjection } from "../elicitation-exchange.js" -import type { WorkspaceSnapshot } from "../print-snapshot.js" -import type { JsonRpcResponse } from "../rpc/protocol.js" -import { - createWorkspaceSessionCoordinator, - type WorkspaceSessionBoundaryCoordinator, - type WorkspaceSessionCoordinator, - type WorkspaceSetupCoordinator, -} from "../workspace-session-coordinator.js" - -export interface FixtureCaptureOptions { - cwd: string - briefId: string - runId: string - timestamp?: string - coordinator?: WorkspaceSessionCoordinator -} - -export interface FixtureCaptureResult { - runDir: string - jsonlFile: string - metaFile: string -} - -export interface DeterministicBriefRunOptions { - cwd: string - briefsDir?: string - runId?: string - timestamp?: string -} - -export async function captureFixtureRun( - options: FixtureCaptureOptions, -): Promise<FixtureCaptureResult> { - const workspace = await callRpc<WorkspaceSnapshot>( - options, - "workspace.snapshot", - ) - if (!workspace.session) { - throw new Error("Cannot capture fixture without a selected Brunch session") - } - - const projection = await callRpc<ElicitationExchangeProjection>( - options, - "session.elicitationExchanges", - ) - const runDir = join(options.cwd, ".fixtures", options.briefId, options.runId) - const jsonlFile = join(runDir, `${options.runId}.jsonl`) - const metaFile = join(runDir, `${options.runId}.meta.json`) - - await mkdir(runDir, { recursive: true }) - await copyFile(workspace.session.file, jsonlFile) - await writeFile( - metaFile, - `${JSON.stringify( - { - schemaVersion: 1, - briefId: options.briefId, - runId: options.runId, - timestamp: options.timestamp ?? new Date().toISOString(), - brunchVersion: await readPackageVersion(), - session: { - id: workspace.session.id, - sourceFile: workspace.session.file, - }, - driver: { - mode: "scripted-deterministic", - }, - projectionSummary: { - status: projection.status, - exchangeCount: projection.exchanges.length, - openPrompt: projection.openPrompt !== null, - }, - artifacts: { - jsonl: `${options.runId}.jsonl`, - graph: { status: "deferred" }, - coherence: { status: "deferred" }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ) - - return { runDir, jsonlFile, metaFile } -} - -export async function captureDeterministicBriefRuns( - options: DeterministicBriefRunOptions, -): Promise<FixtureCaptureResult[]> { - const briefs = await loadBriefLibrary( - options.briefsDir ?? join(options.cwd, ".fixtures", "briefs"), - ) - const coordinator = createWorkspaceSessionCoordinator({ cwd: options.cwd }) - const results: FixtureCaptureResult[] = [] - - for (const brief of briefs) { - const workspace = await openScriptedBriefSession(coordinator, brief) - workspace.session.manager.appendCustomMessageEntry( - "brunch.elicitation_prompt", - `Elicitation prompt for ${brief.id} — ${brief.title}: ${brief.productBrief}`, - true, - ) - workspace.session.manager.appendMessage({ - role: "user", - content: brief.scriptedUserNotes.join("\n"), - timestamp: Date.parse(options.timestamp ?? new Date().toISOString()), - }) - await coordinator.bindCurrentSpecToReplacementSession( - workspace.session.manager, - ) - - results.push( - await captureFixtureRun({ - cwd: options.cwd, - briefId: brief.id, - runId: options.runId ?? "scripted-001", - ...(options.timestamp ? { timestamp: options.timestamp } : {}), - }), - ) - } - - return results -} - -async function openScriptedBriefSession( - coordinator: WorkspaceSetupCoordinator & WorkspaceSessionBoundaryCoordinator, - brief: FixtureBrief, -) { - return coordinator.createSetupSession({ - specTitle: brief.title, - createNewSpec: true, - }) -} - -async function callRpc<T>( - options: FixtureCaptureOptions, - method: string, -): Promise<T> { - const stdin = new PassThrough() - const stdout = new PassThrough() - const chunks: string[] = [] - stdout.on("data", (chunk) => chunks.push(String(chunk))) - stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method })}\n`) - - await runJsonRpcLineServer({ - input: stdin, - output: stdout, - handlers: createRpcHandlers({ - coordinator: - options.coordinator ?? - createWorkspaceSessionCoordinator({ cwd: options.cwd }), - cwd: options.cwd, - }), - }) - - const response = JSON.parse(chunks.join("")) as JsonRpcResponse<T> - if ("error" in response) { - throw new Error(response.error.message) - } - return response.result -} - -async function readPackageVersion(): Promise<string> { - try { - const packageJson = JSON.parse( - await readFile( - join( - dirname(fileURLToPath(import.meta.url)), - "..", - "..", - "package.json", - ), - "utf8", - ), - ) as { - version?: unknown - } - return typeof packageJson.version === "string" - ? packageJson.version - : "unknown" - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return "unknown" - } - throw error - } -} diff --git a/src/probes/probe-scripts.test.ts b/src/probes/probe-scripts.test.ts deleted file mode 100644 index e3ea8c4b..00000000 --- a/src/probes/probe-scripts.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { access } from "node:fs/promises" -import { constants } from "node:fs" -import { execFile } from "node:child_process" -import { promisify } from "node:util" -import { describe, expect, it } from "vitest" - -const execFileAsync = promisify(execFile) - -describe("M1 probe script", () => { - it("runs and prints expected plus actual outputs", async () => { - await access("src/probes/scripts/verify-m1.sh", constants.X_OK) - - const { stdout } = await execFileAsync( - "./src/probes/scripts/verify-m1.sh", - { - timeout: 120_000, - maxBuffer: 1024 * 1024 * 4, - }, - ) - - expect(stdout).toContain("Expected outputs") - expect(stdout).toContain("Actual outputs") - expect(stdout).toContain("Human review prompts") - expect(stdout).toContain("brief-001") - expect(stdout).toContain("workspace.snapshot") - expect(stdout).toContain("session.elicitationExchanges") - }) -}) diff --git a/src/probes/scripts/verify-m1.sh b/src/probes/scripts/verify-m1.sh deleted file mode 100755 index 6dcb3aaf..00000000 --- a/src/probes/scripts/verify-m1.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -set -u -o pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -TSX_LOADER="$ROOT/node_modules/tsx/dist/loader.mjs" -export ROOT TSX_LOADER -cd "$ROOT" || exit 1 - -failures=0 -TMP_WORKSPACE="" - -record_failure() { - echo "FAIL: $*" - failures=$((failures + 1)) -} - -run_check() { - local label="$1" - shift - printf "\n## %s\n" "$label" - if "$@"; then - printf "\nPASS: %s\n" "$label" - else - local status=$? - record_failure "$label exited $status" - fi -} - -cleanup() { - if [[ -n "$TMP_WORKSPACE" && -d "$TMP_WORKSPACE" ]]; then - rm -rf "$TMP_WORKSPACE" - fi -} -trap cleanup EXIT - -echo "# M1 mode shell and fixture driver probe" -echo -echo "## Expected outputs" -echo "- Each committed scripted bundle has one brunch.session_binding whose specTitle matches its brief title." -echo "- Each committed bundle metadata projection summary matches projection from its JSONL transcript." -echo "- Print mode emits a product-shaped workspace snapshot for a selected probe spec." -echo "- RPC workspace.snapshot and session.elicitationExchanges return product-shaped JSON-RPC results." -echo "- Human review remains responsible for brief quality and golden-capture representativeness." -echo -echo "## Actual outputs" - -run_check "Per-brief binding/title alignment and metadata/projection parity" \ - node --import "$TSX_LOADER" --input-type=module <<'NODE' -import { readFile } from "node:fs/promises" -import { join } from "node:path" -import { loadBriefLibrary } from "./src/probes/brief-library.ts" -import { loadJsonlTranscriptEntries, projectElicitationExchanges } from "./src/elicitation-exchange.ts" - -const briefs = await loadBriefLibrary(".fixtures/briefs") -const expected = new Map(briefs.map((brief) => [brief.id, brief.title])) -const briefIds = ["brief-001", "brief-002", "brief-003"] -const seenSpecIds = new Set() - -for (const briefId of briefIds) { - const runId = "scripted-001" - const runDir = join(".fixtures", briefId, runId) - const jsonlFile = join(runDir, `${runId}.jsonl`) - const metaFile = join(runDir, `${runId}.meta.json`) - const entries = await loadJsonlTranscriptEntries(jsonlFile) - const bindings = entries.filter((entry) => entry?.customType === "brunch.session_binding") - if (bindings.length !== 1) { - throw new Error(`${briefId}: expected one session binding, found ${bindings.length}`) - } - const binding = bindings[0] - const expectedTitle = expected.get(briefId) - if (binding.data.specTitle !== expectedTitle) { - throw new Error(`${briefId}: binding title ${binding.data.specTitle} did not match ${expectedTitle}`) - } - if (seenSpecIds.has(binding.data.specId)) { - throw new Error(`${briefId}: reused spec id ${binding.data.specId}`) - } - seenSpecIds.add(binding.data.specId) - - const metadata = JSON.parse(await readFile(metaFile, "utf8")) - const projection = projectElicitationExchanges(entries) - const actualSummary = { - status: projection.status, - exchangeCount: projection.exchanges.length, - openPrompt: projection.openPrompt !== null, - } - if (JSON.stringify(actualSummary) !== JSON.stringify(metadata.projectionSummary)) { - throw new Error(`${briefId}: projection summary mismatch`) - } - if (metadata.artifacts.graph.status !== "deferred" || metadata.artifacts.coherence.status !== "deferred") { - throw new Error(`${briefId}: graph/coherence artifacts should be deferred in M1`) - } - console.log(`${briefId}: ${binding.data.specTitle}; exchanges=${actualSummary.exchangeCount}; graph=${metadata.artifacts.graph.status}; coherence=${metadata.artifacts.coherence.status}`) -} -NODE - -TMP_WORKSPACE="$(mktemp -d "${TMPDIR:-/tmp}/brunch-m1-probe.XXXXXX")" -export TMP_WORKSPACE -node --import "$TSX_LOADER" --input-type=module <<'NODE' -import { createWorkspaceSessionCoordinator } from "./src/workspace-session-coordinator.ts" - -const cwd = process.env.TMP_WORKSPACE -const coordinator = createWorkspaceSessionCoordinator({ cwd }) -const workspace = await coordinator.createSetupSession({ specTitle: "M1 probe smoke" }) -workspace.session.manager.appendCustomMessageEntry( - "brunch.elicitation_prompt", - "Probe prompt: confirm the M1 mode shell is product-shaped.", - true, -) -workspace.session.manager.appendMessage({ role: "user", content: "Probe response" }) -await coordinator.bindCurrentSpecToReplacementSession(workspace.session.manager) -NODE - -run_check "Print-mode smoke output" \ - bash -c 'cd "$TMP_WORKSPACE" && node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode print | tee "$TMP_WORKSPACE/print.out" && grep -q "M1 probe smoke" "$TMP_WORKSPACE/print.out"' - -run_check "RPC workspace.snapshot smoke output" \ - bash -c 'cd "$TMP_WORKSPACE" && printf "%s\n" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"workspace.snapshot\"}" | node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode rpc > "$TMP_WORKSPACE/workspace-rpc.out" && node -e "const fs=require(\"node:fs\"); const path=process.env.TMP_WORKSPACE + \"/workspace-rpc.out\"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(path, \"utf8\")), null, 2))" && grep -q "M1 probe smoke" "$TMP_WORKSPACE/workspace-rpc.out" && grep -q "\"session\"" "$TMP_WORKSPACE/workspace-rpc.out"' - -run_check "RPC session.elicitationExchanges smoke output" \ - bash -c 'cd "$TMP_WORKSPACE" && printf "%s\n" "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session.elicitationExchanges\"}" | node --import "$TSX_LOADER" "$ROOT/src/brunch.ts" --mode rpc > "$TMP_WORKSPACE/exchanges-rpc.out" && node -e "const fs=require(\"node:fs\"); const path=process.env.TMP_WORKSPACE + \"/exchanges-rpc.out\"; console.log(JSON.stringify(JSON.parse(fs.readFileSync(path, \"utf8\")), null, 2))" && grep -q "\"status\":\"ready\"" "$TMP_WORKSPACE/exchanges-rpc.out" && grep -q "promptEntryIds" "$TMP_WORKSPACE/exchanges-rpc.out"' - -echo -echo "## Human review prompts" -echo "- Brief quality: Do briefs #1-#3 read like useful product briefs rather than implementation-shaped test fixtures?" -echo "- Golden-capture representativeness: Does at least one scripted-001 JSONL/meta bundle look plausible as a replay seed?" -echo "- Product shape: Do print/RPC outputs expose workspace/session/exchange concepts rather than generic file dumps?" - -if [[ "$failures" -gt 0 ]]; then - echo - echo "Probe failed with $failures structural failure(s)." - exit 1 -fi - -echo -echo "Probe structural checks passed; complete the human review prompts above before final M1 acceptance." From be2158556fce8d36c57e088630b9eb9fde6b66a5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 15:31:29 +0200 Subject: [PATCH 146/164] Bound public RPC parity to exchange permutations --- .../2026-05-29-public-rpc-parity/report.json | 27 +-- .../session.jsonl | 30 +-- .../transcript.md | 220 +----------------- memory/PLAN.md | 16 +- memory/SPEC.md | 22 +- src/probes/public-rpc-parity-proof.test.ts | 28 ++- src/probes/public-rpc-parity-proof.ts | 25 +- src/rpc/handlers.ts | 8 +- 8 files changed, 84 insertions(+), 292 deletions(-) diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json index 73ee0465..c90566da 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "probeId": "public-rpc-parity", "runId": "2026-05-29-public-rpc-parity", - "generatedAt": "2026-05-29T11:49:37.655Z", - "mission": "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", - "evaluationFocus": "Ten-turn tuple transcript/projection parity without raw Pi RPC or legacy prompt/response entries.", - "maxTurnBudget": 10, - "completedTurns": 10, + "generatedAt": "2026-05-29T13:30:38.654Z", + "mission": "Drive deterministic Brunch structured-exchange permutations through public JSON-RPC only.", + "evaluationFocus": "Tuple transcript/projection parity for current structured-exchange modes without raw Pi RPC or legacy prompt/response entries.", + "maxTurnBudget": 3, + "completedTurns": 3, "friction": [], - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc", - "specId": "spec-9a7e0a75-0932-4caa-a848-285e476dcb85", - "sessionId": "019e7391-86fb-78bd-a3b9-e54b48f316af", + "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-Y7G3Y6", + "specId": "spec-98433c35-3e61-4ab7-9c4f-72331e210aa2", + "sessionId": "019e73ee-02c2-7d43-90e5-7de4cd6ed486", "toolCoverage": [ "present_options", "present_question", @@ -21,16 +21,9 @@ "exchangeIds": [ "deterministic-grounding-choice-1", "deterministic-grounding-text-2", - "deterministic-grounding-multi-3", - "deterministic-grounding-choice-4", - "deterministic-grounding-text-5", - "deterministic-grounding-multi-6", - "deterministic-grounding-choice-7", - "deterministic-grounding-text-8", - "deterministic-grounding-multi-9", - "deterministic-grounding-choice-10" + "deterministic-grounding-multi-3" ], - "transcriptDisplayRows": 20, + "transcriptDisplayRows": 6, "artifacts": { "runDir": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity", "sessionJsonl": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl", diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl index 377738bb..20177cbe 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl @@ -1,22 +1,8 @@ -{"type":"session","version":3,"id":"019e7391-86fb-78bd-a3b9-e54b48f316af","timestamp":"2026-05-29T11:49:37.659Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc"} -{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e7391-86fb-78bd-a3b9-e54b48f316af","specId":"spec-9a7e0a75-0932-4caa-a848-285e476dcb85","specTitle":"Public RPC parity spec"},"id":"f645d5b2","parentId":null,"timestamp":"2026-05-29T11:49:37.659Z"} -{"type":"message","id":"fd1ba453","parentId":"f645d5b2","timestamp":"2026-05-29T11:49:37.661Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-1:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"c66de118","parentId":"fd1ba453","timestamp":"2026-05-29T11:49:37.662Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-1:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"5fef45b3","parentId":"c66de118","timestamp":"2026-05-29T11:49:37.663Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-2:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"7d034ba2","parentId":"5fef45b3","timestamp":"2026-05-29T11:49:37.664Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-2"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-2:request_answer","answer":"Answer for deterministic-grounding-text-2"}}} -{"type":"message","id":"1d53ebd5","parentId":"7d034ba2","timestamp":"2026-05-29T11:49:37.665Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-3:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"13315b5e","parentId":"1d53ebd5","timestamp":"2026-05-29T11:49:37.666Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-3:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"c6b177a4","parentId":"13315b5e","timestamp":"2026-05-29T11:49:37.667Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-4:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"e34d52f8","parentId":"c6b177a4","timestamp":"2026-05-29T11:49:37.668Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-4:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-4","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-4","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-4:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"2c626019","parentId":"e34d52f8","timestamp":"2026-05-29T11:49:37.669Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-5:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"eb17b2b5","parentId":"2c626019","timestamp":"2026-05-29T11:49:37.671Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-5:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-5"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-5","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-5","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-5:request_answer","answer":"Answer for deterministic-grounding-text-5"}}} -{"type":"message","id":"03dfaf20","parentId":"eb17b2b5","timestamp":"2026-05-29T11:49:37.671Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-6:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"c1de4979","parentId":"03dfaf20","timestamp":"2026-05-29T11:49:37.673Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-6:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-6","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-6","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-6:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"ff244d3e","parentId":"c1de4979","timestamp":"2026-05-29T11:49:37.674Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-7:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"0a1e6873","parentId":"ff244d3e","timestamp":"2026-05-29T11:49:37.676Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-7:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-7","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-7","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-7:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} -{"type":"message","id":"437c6082","parentId":"0a1e6873","timestamp":"2026-05-29T11:49:37.676Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-8:present_question","prompt":"What are we specifying?","details":"This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} -{"type":"message","id":"339e1f78","parentId":"437c6082","timestamp":"2026-05-29T11:49:37.678Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-8:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-8"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-8","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-8","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-8:request_answer","answer":"Answer for deterministic-grounding-text-8"}}} -{"type":"message","id":"be940616","parentId":"339e1f78","timestamp":"2026-05-29T11:49:37.679Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic agent-as-user proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-9:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic agent-as-user proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"1a058f03","parentId":"be940616","timestamp":"2026-05-29T11:49:37.681Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-9:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-9","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-9","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-9:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} -{"type":"message","id":"a4e04491","parentId":"1a058f03","timestamp":"2026-05-29T11:49:37.682Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-10:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} -{"type":"message","id":"7104f30b","parentId":"a4e04491","timestamp":"2026-05-29T11:49:37.683Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-10:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-10","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-10","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-10:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"session","version":3,"id":"019e73ee-02c2-7d43-90e5-7de4cd6ed486","timestamp":"2026-05-29T13:30:38.658Z","cwd":"/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-Y7G3Y6"} +{"type":"custom","customType":"brunch.session_binding","data":{"schemaVersion":1,"sessionId":"019e73ee-02c2-7d43-90e5-7de4cd6ed486","specId":"spec-98433c35-3e61-4ab7-9c4f-72331e210aa2","specTitle":"Public RPC parity spec"},"id":"18ac5486","parentId":null,"timestamp":"2026-05-29T13:30:38.658Z"} +{"type":"message","id":"c942d24e","parentId":"18ac5486","timestamp":"2026-05-29T13:30:38.662Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:present_options","toolName":"present_options","content":[{"type":"text","text":"## Is this a new product or feature from scratch?\n\nChoose the best starting context so later elicitation can ask useful follow-ups.\n\n### 1. Start a new spec workspace from a blank slate.\n\n**Rationale:** This keeps the parity run focused on initial grounding.\n\n<!-- option-id: new-from-scratch -->\n\n### 2. Ground the spec in existing implementation constraints.\n\n**Rationale:** Existing code changes what the elicitor should inspect next.\n\n<!-- option-id: existing-codebase -->\n\n### 3. Connect this work to a prior specification thread.\n\n**Rationale:** Continuity matters when prior graph intent exists.\n\n<!-- option-id: relates-to-existing-spec -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choice","required":true},"createdAtToolCallId":"deterministic-grounding-choice-1:present_options","prompt":"Is this a new product or feature from scratch?","details":"Choose the best starting context so later elicitation can ask useful follow-ups.","lens":"step-by-step","options":[{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."},{"id":"existing-codebase","label":"No — this builds on existing code","content":"Ground the spec in existing implementation constraints.","rationale":"Existing code changes what the elicitor should inspect next."},{"id":"relates-to-existing-spec","label":"It relates to an existing spec","content":"Connect this work to a prior specification thread.","rationale":"Continuity matters when prior graph intent exists."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"b71e722c","parentId":"c942d24e","timestamp":"2026-05-29T13:30:38.664Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-choice-1:request_choice","toolName":"request_choice","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Yes — this is new from scratch\n\nComment:\n\n> Chosen by deterministic public-RPC proof."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-choice-1","requestTool":"request_choice","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-choice-1","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-choice-1:request_choice","comment":"Chosen by deterministic public-RPC proof.","choice":{"id":"new-from-scratch","label":"Yes — this is new from scratch","content":"Start a new spec workspace from a blank slate.","rationale":"This keeps the parity run focused on initial grounding."}}}} +{"type":"message","id":"ddfb64a4","parentId":"b71e722c","timestamp":"2026-05-29T13:30:38.664Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:present_question","toolName":"present_question","content":[{"type":"text","text":"## What are we specifying?\n\nThis covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof."}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question","kind":"question","status":"presented","expectedRequest":{"tool":"request_answer","required":true},"createdAtToolCallId":"deterministic-grounding-text-2:present_question","prompt":"What are we specifying?","details":"This covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof.","lens":"step-by-step","options":[]},"isError":false,"timestamp":0}} +{"type":"message","id":"18b8c603","parentId":"ddfb64a4","timestamp":"2026-05-29T13:30:38.666Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-text-2:request_answer","toolName":"request_answer","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\nAnswer for deterministic-grounding-text-2"}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-text-2","requestTool":"request_answer","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-text-2","presentTool":"present_question"},"createdAtToolCallId":"deterministic-grounding-text-2:request_answer","answer":"Answer for deterministic-grounding-text-2"}}} +{"type":"message","id":"dbbbe5c9","parentId":"18b8c603","timestamp":"2026-05-29T13:30:38.667Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:present_options","toolName":"present_options","content":[{"type":"text","text":"## Which proof qualities matter for this parity run?\n\nSelect all qualities the deterministic structured-exchange permutation proof should preserve.\n\n### 1. Pi JSONL keeps every present/request tuple recoverable.\n\n**Rationale:** The transcript is the durable source of truth.\n\n<!-- option-id: transcript -->\n\n### 2. Brunch projections preserve semantic option artifacts.\n\n**Rationale:** Public clients depend on projected structured exchange data.\n\n<!-- option-id: projection -->\n\n### 3. Another proof quality should be captured in the note.\n\n**Rationale:** Other requires a comment so the transcript stays explicit.\n\n<!-- option-id: other -->\n\n### 4. No additional proof qualities matter for this run.\n\n**Rationale:** None requires a comment to avoid silent dismissal.\n\n<!-- option-id: none -->"}],"details":{"schema":"brunch.structured_exchange.present","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options","kind":"options","status":"presented","expectedRequest":{"tool":"request_choices","required":true},"createdAtToolCallId":"deterministic-grounding-multi-3:present_options","prompt":"Which proof qualities matter for this parity run?","details":"Select all qualities the deterministic structured-exchange permutation proof should preserve.","lens":"step-by-step","options":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"projection","label":"Projection fidelity","content":"Brunch projections preserve semantic option artifacts.","rationale":"Public clients depend on projected structured exchange data."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."},{"id":"none","label":"None","content":"No additional proof qualities matter for this run.","rationale":"None requires a comment to avoid silent dismissal."}]},"isError":false,"timestamp":0}} +{"type":"message","id":"f9b545bc","parentId":"dbbbe5c9","timestamp":"2026-05-29T13:30:38.668Z","message":{"role":"toolResult","toolCallId":"deterministic-grounding-multi-3:request_choices","toolName":"request_choices","isError":false,"timestamp":0,"content":[{"type":"text","text":"### Response\n\n- Transcript fidelity\n- Other\n\nComment:\n\n> Other: keep a compact blocker/friction report."}],"details":{"schema":"brunch.structured_exchange.request","schemaVersion":1,"exchangeId":"deterministic-grounding-multi-3","requestTool":"request_choices","status":"answered","respondsTo":{"exchangeId":"deterministic-grounding-multi-3","presentTool":"present_options"},"createdAtToolCallId":"deterministic-grounding-multi-3:request_choices","comment":"Other: keep a compact blocker/friction report.","choices":[{"id":"transcript","label":"Transcript fidelity","content":"Pi JSONL keeps every present/request tuple recoverable.","rationale":"The transcript is the durable source of truth."},{"id":"other","label":"Other","content":"Another proof quality should be captured in the note.","rationale":"Other requires a comment so the transcript stays explicit."}]}}} diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md index f8d1c298..b9a785c4 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md @@ -2,16 +2,16 @@ ## Session -- session: 019e7391-86fb-78bd-a3b9-e54b48f316af -- cwd: /var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-ITIRCc +- session: 019e73ee-02c2-7d43-90e5-7de4cd6ed486 +- cwd: /var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-Y7G3Y6 ## Session binding ```json { "schemaVersion": 1, - "sessionId": "019e7391-86fb-78bd-a3b9-e54b48f316af", - "specId": "spec-9a7e0a75-0932-4caa-a848-285e476dcb85", + "sessionId": "019e73ee-02c2-7d43-90e5-7de4cd6ed486", + "specId": "spec-98433c35-3e61-4ab7-9c4f-72331e210aa2", "specTitle": "Public RPC parity spec" } ``` @@ -54,7 +54,7 @@ Comment: ## What are we specifying? -This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. +This covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof. ## Exchange deterministic-grounding-text-2 — response (request_answer, answered) @@ -66,7 +66,7 @@ Answer for deterministic-grounding-text-2 ## Which proof qualities matter for this parity run? -Select all qualities the deterministic agent-as-user proof should preserve. +Select all qualities the deterministic structured-exchange permutation proof should preserve. ### 1. Pi JSONL keeps every present/request tuple recoverable. @@ -102,211 +102,3 @@ Select all qualities the deterministic agent-as-user proof should preserve. Comment: > Other: keep a compact blocker/friction report. - -## Exchange deterministic-grounding-choice-4 — prompt (present_options → request_choice) - -## Is this a new product or feature from scratch? - -Choose the best starting context so later elicitation can ask useful follow-ups. - -### 1. Start a new spec workspace from a blank slate. - -**Rationale:** This keeps the parity run focused on initial grounding. - -<!-- option-id: new-from-scratch --> - -### 2. Ground the spec in existing implementation constraints. - -**Rationale:** Existing code changes what the elicitor should inspect next. - -<!-- option-id: existing-codebase --> - -### 3. Connect this work to a prior specification thread. - -**Rationale:** Continuity matters when prior graph intent exists. - -<!-- option-id: relates-to-existing-spec --> - -## Exchange deterministic-grounding-choice-4 — response (request_choice, answered) - -### Response - -- Yes — this is new from scratch - -Comment: - -> Chosen by deterministic public-RPC proof. - -## Exchange deterministic-grounding-text-5 — prompt (present_question → request_answer) - -## What are we specifying? - -This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. - -## Exchange deterministic-grounding-text-5 — response (request_answer, answered) - -### Response - -Answer for deterministic-grounding-text-5 - -## Exchange deterministic-grounding-multi-6 — prompt (present_options → request_choices) - -## Which proof qualities matter for this parity run? - -Select all qualities the deterministic agent-as-user proof should preserve. - -### 1. Pi JSONL keeps every present/request tuple recoverable. - -**Rationale:** The transcript is the durable source of truth. - -<!-- option-id: transcript --> - -### 2. Brunch projections preserve semantic option artifacts. - -**Rationale:** Public clients depend on projected structured exchange data. - -<!-- option-id: projection --> - -### 3. Another proof quality should be captured in the note. - -**Rationale:** Other requires a comment so the transcript stays explicit. - -<!-- option-id: other --> - -### 4. No additional proof qualities matter for this run. - -**Rationale:** None requires a comment to avoid silent dismissal. - -<!-- option-id: none --> - -## Exchange deterministic-grounding-multi-6 — response (request_choices, answered) - -### Response - -- Transcript fidelity -- Other - -Comment: - -> Other: keep a compact blocker/friction report. - -## Exchange deterministic-grounding-choice-7 — prompt (present_options → request_choice) - -## Is this a new product or feature from scratch? - -Choose the best starting context so later elicitation can ask useful follow-ups. - -### 1. Start a new spec workspace from a blank slate. - -**Rationale:** This keeps the parity run focused on initial grounding. - -<!-- option-id: new-from-scratch --> - -### 2. Ground the spec in existing implementation constraints. - -**Rationale:** Existing code changes what the elicitor should inspect next. - -<!-- option-id: existing-codebase --> - -### 3. Connect this work to a prior specification thread. - -**Rationale:** Continuity matters when prior graph intent exists. - -<!-- option-id: relates-to-existing-spec --> - -## Exchange deterministic-grounding-choice-7 — response (request_choice, answered) - -### Response - -- Yes — this is new from scratch - -Comment: - -> Chosen by deterministic public-RPC proof. - -## Exchange deterministic-grounding-text-8 — prompt (present_question → request_answer) - -## What are we specifying? - -This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session. - -## Exchange deterministic-grounding-text-8 — response (request_answer, answered) - -### Response - -Answer for deterministic-grounding-text-8 - -## Exchange deterministic-grounding-multi-9 — prompt (present_options → request_choices) - -## Which proof qualities matter for this parity run? - -Select all qualities the deterministic agent-as-user proof should preserve. - -### 1. Pi JSONL keeps every present/request tuple recoverable. - -**Rationale:** The transcript is the durable source of truth. - -<!-- option-id: transcript --> - -### 2. Brunch projections preserve semantic option artifacts. - -**Rationale:** Public clients depend on projected structured exchange data. - -<!-- option-id: projection --> - -### 3. Another proof quality should be captured in the note. - -**Rationale:** Other requires a comment so the transcript stays explicit. - -<!-- option-id: other --> - -### 4. No additional proof qualities matter for this run. - -**Rationale:** None requires a comment to avoid silent dismissal. - -<!-- option-id: none --> - -## Exchange deterministic-grounding-multi-9 — response (request_choices, answered) - -### Response - -- Transcript fidelity -- Other - -Comment: - -> Other: keep a compact blocker/friction report. - -## Exchange deterministic-grounding-choice-10 — prompt (present_options → request_choice) - -## Is this a new product or feature from scratch? - -Choose the best starting context so later elicitation can ask useful follow-ups. - -### 1. Start a new spec workspace from a blank slate. - -**Rationale:** This keeps the parity run focused on initial grounding. - -<!-- option-id: new-from-scratch --> - -### 2. Ground the spec in existing implementation constraints. - -**Rationale:** Existing code changes what the elicitor should inspect next. - -<!-- option-id: existing-codebase --> - -### 3. Connect this work to a prior specification thread. - -**Rationale:** Continuity matters when prior graph intent exists. - -<!-- option-id: relates-to-existing-spec --> - -## Exchange deterministic-grounding-choice-10 — response (request_choice, answered) - -### Response - -- Yes — this is new from scratch - -Comment: - -> Chosen by deterministic public-RPC proof. diff --git a/memory/PLAN.md b/memory/PLAN.md index b17e3e55..9acb85a3 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first ten-turn tuple parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first structured-exchange permutation parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure @@ -25,7 +25,7 @@ The POC should maximize assumption falsification rather than merely implement mi | A1-L Pi substrate seams | A needed host/session/RPC/extension seam cannot be expressed without forking Pi. | Mostly exercised by M0-M3; FE-744 and `sealed-pi-profile-runtime-state` close the remaining UI/profile seams before graph-agent work depends on them. | | A3-L command layer sufficiency | Agent, UI, reviewer, or capture writes need shortcuts around one `CommandExecutor`. | `graph-data-plane`, `agent-graph-integration`, and `authority-model` must prove one command boundary for every write path. | | A4-L global LSN adequacy | Replay, staleness, or reconciliation ordering needs per-entity/vector clocks. | `graph-data-plane` establishes one-LSN-per-transaction; `turn-boundary-reconciliation` tries to break it with cross-session traces. | -| A5-L probe/transcript driver quality | Agent-as-user probes fail to catch regressions or cannot produce reviewable transcript evidence for realistic Brunch seams. | FE-744 has proved a deterministic public-RPC ten-turn elicitation driver; future brief-based or generative golden runs must pass through the `.fixtures/runs/<probe-id>/<run-id>/` probe/transcript artifact path. | +| A5-L probe/transcript driver quality | Agent-as-user probes fail to catch regressions or cannot produce reviewable transcript evidence for realistic Brunch seams. | FE-744 has proved a deterministic public-RPC structured-exchange permutation driver; future brief-based or generative golden runs must pass through the `.fixtures/runs/<probe-id>/<run-id>/` probe/transcript artifact path. | | A6-L unified `graph.*` namespace | Intent/oracle/design/plan semantics become confusing or unsafe under one umbrella. | `graph-data-plane` and `agent-graph-integration` should start unified but watch for namespace pressure. | | A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus targeted probe scenarios exercise framing; promote only if probe evidence demands it. | | A8-L reconciliation substrate | Gaps, contradictions, process debt, and conflicts need separate substrates immediately. | `graph-data-plane` builds the shared substrate; `coherence-first-class` and known-bad briefs test subtype pressure. | @@ -49,7 +49,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback and public Brunch JSON-RPC ten-turn tuple parity are covered: prove web real-time structured-exchange observation, then recover branded/themed chrome. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback and public Brunch JSON-RPC structured-exchange permutation parity are covered: prove web real-time structured-exchange observation, then recover branded/themed chrome. ### Next @@ -203,15 +203,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through ten deterministic assistant-first exchanges, parity hardening for distinct exchange ids, terminal non-answered statuses, and option content/rationale, and committed `.fixtures` public-RPC parity probe artifacts have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) -- **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC elicitation loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves ten-turn transcript/projection evidence comparable to a TUI session. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC elicitation session parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers ten distinct structured exchanges through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. The remaining active acceptance is that web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state, and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, ten-turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, and no repeated deterministic prompts, and committed `.fixtures` public-RPC parity probe artifacts have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) +- **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. The remaining active acceptance is that web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state, and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC elicitation parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives ten distinct assistant-first exchanges from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index f7818d72..3df7f4da 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -84,7 +84,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Verification & fixtures -24. Brunch must ship probe drivers over the public JSON-RPC surface that produce replayable transcript artifacts and property-checkable reports. The first product-level driver proof is a deterministic public-RPC elicitation session parity run: at least ten assistant-first exchanges through activated workspace/spec/session state, with Pi JSONL and Brunch projections comparable in kind and quality to an equivalent TUI-driven session. Brief-based golden fixtures are a future input style, not a separate required subsystem. +24. Brunch must ship probe drivers over the public JSON-RPC surface that produce replayable transcript artifacts and property-checkable reports. The first product-level driver proof is a deterministic public-RPC structured-exchange permutation run: the current `present_question`/`request_answer`, `present_options`/`request_choice`, and `present_options`/`request_choices` permutations are driven through activated workspace/spec/session state, with Pi JSONL and Brunch projections comparable in kind and quality to an equivalent TUI-driven session. Coherent ten-turn elicitation progress belongs to future generative/adversarial probes, not the deterministic transport-permutation proof. Brief-based golden fixtures are a future input style, not a separate required subsystem. #### Runtime profile & prompting @@ -102,7 +102,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A1-L | `pi-coding-agent` exposes enough seams (services, custom message roles, `prepareNextTurn`, `transformContext`, RPC mode, JSONL sessions, extension UI surface) to host all M0–M9 capabilities without forking pi. | high | open | D1-L | M0–M2: walking skeleton + mode shell + JSONL viability prove the substrate. | | A3-L | A single Brunch-owned command layer (with optimistic concurrency, validation, audit, and coherence triggers) is sufficient for both agent and human writers across all four modes for the POC's graph scale. | medium | open | D4-L | M4 + M5 + M6: graph plane, agent-↔-graph wiring, and authority tiers all routed through the same surface. | | A4-L | A monotonic global LSN per commit (one-LSN-per-transaction) is adequate for change-log replay, reconciliation-need ordering, and mention staleness without per-row vector clocks. | high | open | I1-L, I4-L | M4 + M7: replay fidelity and `worldUpdate` ordering tests. | -| A5-L | Agent-as-user probes over the public Brunch RPC surface can produce regression-quality transcript artifacts without depending on a parallel brief-library subsystem. | medium | partially validated | D5-L, D48-L, D49-L | FE-744 public-RPC parity proves the transport/projection substrate for ten deterministic structured exchanges; future brief-based golden-fixture work must enter through the probe/transcript artifact path. | +| A5-L | Agent-as-user probes over the public Brunch RPC surface can produce regression-quality transcript artifacts without depending on a parallel brief-library subsystem. | medium | partially validated | D5-L, D48-L, D49-L | FE-744 public-RPC parity proves the deterministic transport/projection substrate for current structured-exchange permutations; future brief-based or generative golden-fixture work must enter through the probe/transcript artifact path. | | A6-L | The graph-native vocabulary can be deferred from explicit per-plane namespacing (`intent.*`, `oracle.*`, etc.) and start unified under `graph.*` without painful rework later. | medium | open | D3-L | M4–M5: if intent-plane plus oracle-plane stubs both fit under one namespace cleanly, the assumption holds. | | A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Targeted probe runs that exercise framing pressure: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | @@ -119,7 +119,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | | A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | | A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness/posture updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | open | D18-L, D26-L, D45-L, I30-L | M5 agent-graph-integration fixtures and review: compare elicitor-captured graph updates against transcript evidence; track over-capture, missed obvious facts, and whether preface-led disambiguation resolves low-confidence material without an async observer owning primary extraction. | -| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive an assistant-first elicitation session for at least ten turns without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | high | validated | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC elicitation parity proof landed: method discovery, workspace/spec/session activation, deterministic start/resume/pending/respond lifecycle, ten distinct structured-exchange tuples, terminal non-answered status handling, option artifact parity, and Pi JSONL/projection comparison. | +| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive assistant-first structured exchanges without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | high | validated | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC structured-exchange parity proof landed: method discovery, workspace/spec/session activation, deterministic start/resume/pending/respond lifecycle, current structured-exchange permutation coverage, terminal non-answered status handling, option artifact parity, and Pi JSONL/projection comparison. Coherent ten-turn elicitation progress remains an outer-loop generative probe concern. | ### Active Decisions @@ -194,7 +194,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ``` - **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are TypeBox/JSON-Schema-shaped per D41-L, but discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. -- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial ten-turn parity run. Depends on: A23-L, D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. +- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: A23-L, D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. #### Persistence @@ -217,7 +217,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. - **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives ten distinct tuple-shaped exchanges over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives the current deterministic tuple-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -272,7 +272,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | -| I32-L | Public RPC elicitation driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and a deterministic ten-turn run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, ten distinct public-RPC structured-exchange tuples, terminal non-answered status handling, option content/rationale parity, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | +| I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and the deterministic permutation run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | | I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | planned (future capture-analysis schema/rendering tests plus transcript renderer fixture; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | ## Future Direction Register @@ -391,7 +391,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, cancelled, marked unavailable, or explicitly declared display-only. A distinct `skipped` terminal state is deferred until product pressure distinguishes “declined but continue” from cancellation or an explicit `none`/`other` answer. Depending on shape, it may be represented by a Brunch custom entry/message, a review-set proposal entry, or a registered Pi `present_*`/`request_*` tool tuple whose result details carry the structured display and response. | | **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `elicitation.respond`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | | **Agent-as-user driver** | A scripted or generative client that drives Brunch only through the public JSON-RPC surface as if it were a user: discover methods, activate workspace/spec/session, observe prompts, answer pending exchanges, and report blockers/frictions for probe reports. | -| **RPC elicitation session parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete at least ten assistant-first structured exchanges and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | +| **RPC structured-exchange parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete the current deterministic structured-exchange permutations and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with future generative elicitation-quality probes and with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` / future `capture_*` families. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response; `capture_*` tools persist assistant analysis of likely semantic changes without mutating graph truth. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | | **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. Its details include `exchangeId`, `presentTool`, `kind`, `status: presented`, `expectedRequest`, and `createdAtToolCallId`. | @@ -500,7 +500,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Probe transcript replay and property assertions | Probe runs preserve transcript evidence that can be replayed, rendered, and compared against current Brunch projections. Future brief-driven sessions, if revived, must produce the same probe-run artifact shape. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in probe metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives at least ten assistant-first pending exchanges through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives the current structured-exchange permutations through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, rejects repeated deterministic prompts, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Middle | Capture-analysis transcript oracle | Future `capture_*` probes persist ANALYSIS as normal Brunch toolResults, assert no graph writes occur, render full analysis in Markdown/ASCII transcripts, and assert the TUI path hides or collapses the same result without losing persisted content/details. | D17-L, D18-L, D37-L, D47-L, D50-L; I23-L, I30-L, I33-L. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative probe runs | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, and reviewer-finding precision through small targeted probe scenarios (brief-shaped inputs are allowed, but the probe run and transcript artifacts are canonical). POC scope remains one or two known-bad scenarios per relevant invariant, not exhaustive coverage. | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -549,13 +549,13 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/tui-client/.pi/extensions/subagents/agents/*.md` frontmatter and `src/tui-client/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | -| I32-L | FE-744 public-RPC elicitation parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic ten-turn agent-as-user run over Brunch JSON-RPC only, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | +| I32-L | FE-744 public-RPC structured-exchange parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic permutation run over Brunch JSON-RPC only, no repeated deterministic prompts, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | | I33-L | Future capture-analysis tests: `capture_*` result schema/rendering, no graph-write side effects, Brunch-semantic transcript inclusion, and hidden/collapsed TUI rendering fallback. | ### Design Notes - **Deterministic before generative.** Probe runs should prefer deterministic or tightly scripted paths before relying on LLM persona variance. Generative/adversarial probes come after the transcript substrate is trusted. Retired M1 scripted captures proved the early transport/projection substrate on then-current terms, but tuple-shaped FE-744 public-RPC probes are the current evidence path. -- **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality remains an outer-loop fixture concern after the transport/turn substrate is trustworthy. +- **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, current structured-exchange permutations, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality and coherent ten-turn progress remain outer-loop generative fixture concerns after the transport/turn substrate is trustworthy. - **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The schema/component shape should be designed separately before implementation; the durable commitment now is only the toolResult-family carrier and visibility policy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. @@ -585,4 +585,4 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` 5. The transcript strategy is validated: pi JSONL sessions either suffice for the POC, or their insufficiency is sharply bounded with a justified fallback. 6. Probe runs with transcript artifacts can exercise current Brunch seams, and future brief-based golden fixtures, if revived, pass through the same probe/transcript artifact path rather than a parallel brief-library subsystem. 7. Brunch can be built as a local product over pi without forking pi. -8. A public Brunch RPC agent-as-user can discover methods, activate workspace/spec/session, complete at least ten assistant-first structured elicitation turns, and leave JSONL/projection evidence comparable to a TUI session without speaking raw Pi RPC. +8. A public Brunch RPC agent-as-user can discover methods, activate workspace/spec/session, complete the current structured-exchange permutations, and leave JSONL/projection evidence comparable to a TUI session without speaking raw Pi RPC; coherent ten-turn elicitation progress is reserved for future generative probes. diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index 207c4861..ad924a4c 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest" import { runPublicRpcParityProof } from "./public-rpc-parity-proof.js" describe("public Brunch RPC structured-exchange parity proof", () => { - it("drives ten assistant-first structured exchanges from a fresh cwd", async () => { + it("drives each deterministic structured-exchange permutation from a fresh cwd", async () => { const report = await runPublicRpcParityProof() expect(report).toMatchObject({ @@ -17,10 +17,10 @@ describe("public Brunch RPC structured-exchange parity proof", () => { generatedAt: expect.any(String), mission: expect.stringContaining("public JSON-RPC only"), evaluationFocus: expect.stringContaining( - "tuple transcript/projection parity", + "Tuple transcript/projection parity", ), - maxTurnBudget: 10, - completedTurns: 10, + maxTurnBudget: 3, + completedTurns: 3, friction: [], specId: expect.any(String), sessionId: expect.any(String), @@ -33,10 +33,14 @@ describe("public Brunch RPC structured-exchange parity proof", () => { "request_choice", "request_choices", ]) - expect(report.exchangeIds).toHaveLength(10) - expect(new Set(report.exchangeIds).size).toBe(10) + expect(report.exchangeIds).toEqual([ + "deterministic-grounding-choice-1", + "deterministic-grounding-text-2", + "deterministic-grounding-multi-3", + ]) + expect(new Set(report.exchangeIds).size).toBe(3) expect(report.artifacts).toBeUndefined() - expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(20) + expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(6) }) it("writes a reviewable artifact bundle when given a fixture root", async () => { @@ -97,13 +101,17 @@ describe("public Brunch RPC structured-exchange parity proof", () => { runId: report.runId, generatedAt: report.generatedAt, mission: report.mission, - completedTurns: 10, + completedTurns: 3, exchangeIds: report.exchangeIds, artifacts: report.artifacts, }) expect(persistedReport.exchangeIds).toEqual(report.exchangeIds) - expect(persistedReport.exchangeIds).toHaveLength(10) - expect(new Set(persistedReport.exchangeIds).size).toBe(10) + expect(persistedReport.exchangeIds).toHaveLength(3) + expect(new Set(persistedReport.exchangeIds).size).toBe(3) + expect( + transcript.match(/Is this a new product or feature from scratch\?/g) ?? + [], + ).toHaveLength(1) for (const exchangeId of persistedReport.exchangeIds) { expect(sessionJsonl).toContain(exchangeId) expect(transcript).toContain(exchangeId) diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index c996cf11..5f6794fc 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -6,6 +6,8 @@ import { createRpcHandlers } from "../rpc/handlers.js" import { renderSessionTranscript } from "../session-transcript.js" import { createWorkspaceSessionCoordinator } from "../workspace-session-coordinator.js" +const PUBLIC_RPC_PARITY_PERMUTATION_COUNT = 3 + interface JsonRpcSuccess<T> { jsonrpc: "2.0" id: number @@ -110,6 +112,7 @@ interface ToolResultDetails { schema?: string requestTool?: string presentTool?: string + prompt?: string options?: ToolResultOptionDetails[] } @@ -230,7 +233,7 @@ export async function runPublicRpcParityProof( } const exchangeIds: string[] = [] - for (let turn = 0; turn < 10; turn += 1) { + for (let turn = 0; turn < PUBLIC_RPC_PARITY_PERMUTATION_COUNT; turn += 1) { const started = success<PendingResult>( await handlers.handle({ jsonrpc: "2.0", @@ -294,9 +297,9 @@ export async function runPublicRpcParityProof( method: "session.transcriptDisplay", }), ) - if (exchanges.exchanges.length !== 10) { + if (exchanges.exchanges.length !== PUBLIC_RPC_PARITY_PERMUTATION_COUNT) { throw new Error( - `Expected 10 completed exchanges, got ${exchanges.exchanges.length}`, + `Expected ${PUBLIC_RPC_PARITY_PERMUTATION_COUNT} completed exchanges, got ${exchanges.exchanges.length}`, ) } @@ -327,6 +330,16 @@ export async function runPublicRpcParityProof( throw new Error("Public RPC parity proof reused exchange IDs") } + const presentPrompts = tools + .filter( + (entry) => entry.details?.schema === "brunch.structured_exchange.present", + ) + .map((entry) => entry.details?.prompt) + .filter((prompt): prompt is string => prompt !== undefined) + if (new Set(presentPrompts).size !== presentPrompts.length) { + throw new Error("Public RPC parity proof repeated deterministic prompts") + } + const optionPresentResults = tools.filter( (entry) => entry.toolName === "present_options", ) @@ -386,10 +399,10 @@ export async function runPublicRpcParityProof( runId, generatedAt, mission: - "Drive an assistant-first Brunch elicitation session through public JSON-RPC only.", + "Drive deterministic Brunch structured-exchange permutations through public JSON-RPC only.", evaluationFocus: - "Ten-turn tuple transcript/projection parity without raw Pi RPC or legacy prompt/response entries.", - maxTurnBudget: 10, + "Tuple transcript/projection parity for current structured-exchange modes without raw Pi RPC or legacy prompt/response entries.", + maxTurnBudget: PUBLIC_RPC_PARITY_PERMUTATION_COUNT, completedTurns: exchanges.exchanges.length, friction, cwd, diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index 3f86b0e9..1cb376cc 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -498,7 +498,7 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ { method: "session.startElicitation", description: - "Start or resume the selected session's deterministic assistant-first elicitation loop and return the current pending structured exchange.", + "Start or resume the selected session's deterministic structured-exchange permutation loop and return the current pending exchange.", paramsSchema: NoParamsSchema, resultSchema: StartElicitationResultSchema, examples: [{ jsonrpc: "2.0", id: 8, method: "session.startElicitation" }], @@ -522,7 +522,7 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ { method: "elicitation.respond", description: - "Submit a text, single-choice, or multi-choice answer for the selected session's current deterministic tuple-shaped pending elicitation exchange.", + "Submit a text, single-choice, or multi-choice answer for the selected session's current deterministic tuple-shaped pending structured exchange.", paramsSchema: ElicitationRespondParamsSchema, resultSchema: ElicitationRespondResultSchema, examples: [ @@ -919,7 +919,7 @@ function nextDeterministicElicitationExchange( mode: "text", prompt: "What are we specifying?", details: - "This starts Brunch's deterministic public-RPC elicitation parity proof for an activated spec/session.", + "This covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof.", options: [], note: { allowed: true }, }, @@ -929,7 +929,7 @@ function nextDeterministicElicitationExchange( mode: "multi-select", prompt: "Which proof qualities matter for this parity run?", details: - "Select all qualities the deterministic agent-as-user proof should preserve.", + "Select all qualities the deterministic structured-exchange permutation proof should preserve.", options: [ { id: "transcript", From 08f144d84e59960d3bb94c0c7152cf6e47d63fec Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 16:07:37 +0200 Subject: [PATCH 147/164] Add web live updates for RPC exchanges --- memory/PLAN.md | 10 +-- memory/SPEC.md | 4 +- src/rpc/websocket.ts | 55 ++++++++++++-- src/web-client/app.test.tsx | 65 +++++++++++++++-- src/web-client/app.tsx | 17 ++++- src/web-client/rpc-client.test.ts | 49 +++++++++++++ src/web-client/rpc-client.ts | 44 +++++++++++- src/web-host.test.ts | 116 ++++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 19 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 9acb85a3..9c3ac545 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -49,7 +49,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback and public Brunch JSON-RPC structured-exchange permutation parity are covered: prove web real-time structured-exchange observation, then recover branded/themed chrome. +1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback, public Brunch JSON-RPC structured-exchange permutation parity, and web real-time observation of RPC-originated structured-exchange updates are covered: recover branded/themed chrome. ### Next @@ -203,15 +203,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, and no repeated deterministic prompts, and committed `.fixtures` public-RPC parity probe artifacts have landed. Current missing product seams are web real-time structured-exchange observation and visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, and web real-time observation of RPC-originated structured-exchange transcript updates have landed. Current missing product seam is visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. The remaining active acceptance is that web clients receive real-time product updates when TUI or RPC interactions change selected session/exchange state, and the branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. -- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via TUI or RPC. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to web real-time structured-exchange observation smoke and branded chrome recovery. Do not return to `graph-data-plane` until web observation and chrome recovery close the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 3df7f4da..ca761b6e 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -261,7 +261,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I18-L | Every elicitor-emitted prompt or proposal custom entry (`brunch.elicitor_intent_hint`, `brunch.establishment_offer`, `brunch.review_set_proposal`) carries a `lens` field; capture, reviewer, and future observer/auditor routing filters on this field. | planned (M5+ capture/reviewer routing tests; transcript-shape contract test) | D25-L, D26-L, D29-L | | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.elicitationExchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source/RPC evidence shows `session_before_fork` can also cancel clone/fork effects but exact interactive built-ins still need product-shell policy if visibility must be strict; dynamic chrome remains projection-only and does not add branch or mutation authority) | D24-L, D6-L, D11-L, D13-L, D34-L, D35-L | | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | -| I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | +| I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | @@ -559,7 +559,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` - **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The schema/component shape should be designed separately before implementation; the durable commitment now is only the toolResult-family carrier and visibility policy. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. -- **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. +- **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates by invalidating/refetching canonical projection handlers rather than introducing a view store. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. ### Acknowledged Blind Spots diff --git a/src/rpc/websocket.ts b/src/rpc/websocket.ts index 00efe53e..1830aa20 100644 --- a/src/rpc/websocket.ts +++ b/src/rpc/websocket.ts @@ -29,9 +29,14 @@ export function attachWebRpcTransport(options: { webSocketServer.on("connection", (webSocket) => { webSocket.on("message", (data) => { - void handleMessage(options.handlers, data).then((response) => { - webSocket.send(JSON.stringify(response)) - }) + void handleMessage(options.handlers, data).then( + ({ response, method }) => { + webSocket.send(JSON.stringify(response)) + if (isProductMutation(method) && !Object.hasOwn(response, "error")) { + broadcastProductUpdate() + } + }, + ) }) }) @@ -51,10 +56,52 @@ export function attachWebRpcTransport(options: { }) }, } + function broadcastProductUpdate(): void { + const notification = JSON.stringify({ + jsonrpc: "2.0", + method: "brunch.updated", + params: { + topics: [ + "workspace.snapshot", + "session.pendingExchange", + "session.elicitationExchanges", + "session.transcriptDisplay", + ], + }, + }) + for (const client of webSocketServer.clients) { + client.send(notification) + } + } } async function handleMessage(handlers: RpcHandlers, data: RawData) { - return dispatchJsonRpcMessage(websocketMessageToString(data), handlers) + const message = websocketMessageToString(data) + return { + response: await dispatchJsonRpcMessage(message, handlers), + method: requestMethod(message), + } +} + +function requestMethod(message: string): string | undefined { + try { + const value = JSON.parse(message) as unknown + return typeof value === "object" && + value !== null && + typeof (value as { method?: unknown }).method === "string" + ? (value as { method: string }).method + : undefined + } catch { + return undefined + } +} + +function isProductMutation(method: string | undefined): boolean { + return ( + method === "workspace.activate" || + method === "session.startElicitation" || + method === "elicitation.respond" + ) } function websocketMessageToString(data: RawData): string { diff --git a/src/web-client/app.test.tsx b/src/web-client/app.test.tsx index f648793b..8cb58c6b 100644 --- a/src/web-client/app.test.tsx +++ b/src/web-client/app.test.tsx @@ -1,12 +1,16 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react" +import { cleanup, render, screen, waitFor } from "@testing-library/react" import { afterEach, describe, expect, it, vi } from "vitest" import type { TranscriptDisplayProjection } from "../elicitation-exchange.js" import type { WorkspaceSnapshot } from "../print-snapshot.js" import { BrunchWebApp, createBrunchWebRuntime } from "./app.js" -import type { WebSocketRpcClient } from "./rpc-client.js" +import type { + WebSocketRpcClient, + WebSocketRpcNotification, + WebSocketRpcNotificationListener, +} from "./rpc-client.js" interface RpcCall { method: string @@ -44,13 +48,15 @@ const readyProjection: TranscriptDisplayProjection = { function rpcClient(options?: { snapshot?: WorkspaceSnapshot - projection?: TranscriptDisplayProjection + projection?: TranscriptDisplayProjection | (() => TranscriptDisplayProjection) projectionError?: Error calls?: RpcCall[] + listeners?: Set<WebSocketRpcNotificationListener> }): WebSocketRpcClient { const snapshot = options?.snapshot ?? readySnapshot const projection = options?.projection ?? readyProjection const calls = options?.calls + const listeners = options?.listeners ?? new Set() return { async request<T,>(method: string, params?: unknown): Promise<T> { calls?.push(params === undefined ? { method } : { method, params }) @@ -61,14 +67,29 @@ function rpcClient(options?: { if (options?.projectionError) { throw options.projectionError } - return projection as T + return ( + typeof projection === "function" ? projection() : projection + ) as T } throw new Error(`unexpected RPC method ${method}`) }, + subscribe(listener: WebSocketRpcNotificationListener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, close: vi.fn(), } as unknown as WebSocketRpcClient } +function emitNotification( + listeners: Set<WebSocketRpcNotificationListener>, + notification: WebSocketRpcNotification, +): void { + for (const listener of listeners) { + listener(notification) + } +} + afterEach(() => cleanup()) describe("Brunch React web app", () => { @@ -111,6 +132,42 @@ describe("Brunch React web app", () => { expect(await screen.findByText("No transcript messages yet.")).toBeTruthy() }) + it("refetches selected session transcript when the RPC client reports a product update", async () => { + const listeners = new Set<WebSocketRpcNotificationListener>() + let projection: TranscriptDisplayProjection = { rows: [] } + const runtime = createBrunchWebRuntime({ + rpcClient: rpcClient({ + listeners, + projection: () => projection, + }), + }) + + render(<BrunchWebApp runtime={runtime} />) + + expect(await screen.findByText("No transcript messages yet.")).toBeTruthy() + + projection = { + rows: [ + { + id: "prompt-2", + role: "prompt", + text: "Is this a new product or feature from scratch?", + }, + ], + } + emitNotification(listeners, { + jsonrpc: "2.0", + method: "brunch.updated", + params: { topics: ["session.transcriptDisplay"] }, + }) + + await waitFor(() => + expect( + screen.getByText("Is this a new product or feature from scratch?"), + ).toBeTruthy(), + ) + }) + it("does not request session projection when no session is selected", async () => { const calls: RpcCall[] = [] const runtime = createBrunchWebRuntime({ diff --git a/src/web-client/app.tsx b/src/web-client/app.tsx index 205f5610..db9a53cd 100644 --- a/src/web-client/app.tsx +++ b/src/web-client/app.tsx @@ -10,7 +10,7 @@ import { createRootRouteWithContext, createRouter, } from "@tanstack/react-router" -import { Suspense } from "react" +import { Suspense, useEffect } from "react" import type { TranscriptDisplayProjection } from "../elicitation-exchange.js" import type { WorkspaceSnapshot } from "../print-snapshot.js" @@ -136,7 +136,20 @@ function unreachableSessionProjectionTarget(): never { } function WorkspaceSnapshotPage() { - const { rpcClient } = rootRoute.useRouteContext() + const { queryClient, rpcClient } = rootRoute.useRouteContext() + useEffect( + () => + rpcClient.subscribe((notification) => { + if (notification.method !== "brunch.updated") return + void queryClient.invalidateQueries({ + queryKey: ["workspace.snapshot"], + }) + void queryClient.invalidateQueries({ + queryKey: ["session.transcriptDisplay"], + }) + }), + [queryClient, rpcClient], + ) const { data: snapshot } = useSuspenseQuery( workspaceSnapshotQueryOptions(rpcClient), ) diff --git a/src/web-client/rpc-client.test.ts b/src/web-client/rpc-client.test.ts index f4a9b6ab..dedc724e 100644 --- a/src/web-client/rpc-client.test.ts +++ b/src/web-client/rpc-client.test.ts @@ -89,6 +89,55 @@ describe("browser WebSocket RPC client", () => { await expect(second).resolves.toBe("second") }) + it("delivers JSON-RPC notifications without disturbing pending requests", async () => { + const client = rpcClient() + const notifications: unknown[] = [] + client.subscribe((notification) => notifications.push(notification)) + const request = client.request("workspace.snapshot") + const socket = FakeWebSocket.instances[0]! + + socket.emit("open") + socket.emit( + "message", + JSON.stringify({ + jsonrpc: "2.0", + method: "brunch.updated", + params: { topics: ["session.transcriptDisplay"] }, + }), + ) + socket.emit( + "message", + JSON.stringify({ jsonrpc: "2.0", id: 1, result: "snapshot" }), + ) + + await expect(request).resolves.toBe("snapshot") + expect(notifications).toEqual([ + { + jsonrpc: "2.0", + method: "brunch.updated", + params: { topics: ["session.transcriptDisplay"] }, + }, + ]) + }) + + it("unsubscribes notification listeners", () => { + const client = rpcClient() + const notifications: unknown[] = [] + const unsubscribe = client.subscribe((notification) => + notifications.push(notification), + ) + const socket = FakeWebSocket.instances[0]! + + socket.emit("open") + unsubscribe() + socket.emit( + "message", + JSON.stringify({ jsonrpc: "2.0", method: "brunch.updated" }), + ) + + expect(notifications).toEqual([]) + }) + it("rejects JSON-RPC failures with code and message", async () => { const client = rpcClient() const request = client.request("workspace.snapshot") diff --git a/src/web-client/rpc-client.ts b/src/web-client/rpc-client.ts index 9e9f6247..a31d62e1 100644 --- a/src/web-client/rpc-client.ts +++ b/src/web-client/rpc-client.ts @@ -17,9 +17,20 @@ type WebSocketConstructor = new (url: string) => WebSocketLike export interface WebSocketRpcClient { request<T>(method: string, params?: unknown): Promise<T> + subscribe(listener: WebSocketRpcNotificationListener): () => void close(): void } +export interface WebSocketRpcNotification { + jsonrpc: "2.0" + method: string + params?: unknown +} + +export type WebSocketRpcNotificationListener = ( + notification: WebSocketRpcNotification, +) => void + export class JsonRpcClientError extends Error { readonly code: number @@ -37,7 +48,7 @@ type PendingRequest = { interface ResponseFrameSuccess { ok: true - value: JsonRpcResponse + value: JsonRpcResponse | WebSocketRpcNotification } interface ResponseFrameFailure { @@ -54,6 +65,7 @@ export function createWebSocketRpcClient(options: { const url = options.url ?? defaultRpcUrl() const socket = new WebSocketImpl(url) const pending = new Map<JsonRpcId, PendingRequest>() + const notificationListeners = new Set<WebSocketRpcNotificationListener>() const queued: string[] = [] let nextId = 1 let isOpen = false @@ -74,6 +86,13 @@ export function createWebSocketRpcClient(options: { return } + if (isJsonRpcNotification(parsed.value)) { + for (const listener of notificationListeners) { + listener(parsed.value) + } + return + } + const response = parsed.value const request = pending.get(response.id) if (!request) { @@ -151,6 +170,13 @@ export function createWebSocketRpcClient(options: { }) }, + subscribe(listener: WebSocketRpcNotificationListener) { + notificationListeners.add(listener) + return () => { + notificationListeners.delete(listener) + } + }, + close() { if (isClosed) { return @@ -165,12 +191,26 @@ export function createWebSocketRpcClient(options: { function parseResponseFrame(data: unknown): ResponseFrameParseResult { try { const value = JSON.parse(String(data)) as unknown - return isJsonRpcResponse(value) ? { ok: true, value } : { ok: false } + return isJsonRpcResponse(value) || isJsonRpcNotification(value) + ? { ok: true, value } + : { ok: false } } catch { return { ok: false } } } +function isJsonRpcNotification( + value: unknown, +): value is WebSocketRpcNotification { + return ( + typeof value === "object" && + value !== null && + (value as { jsonrpc?: unknown }).jsonrpc === "2.0" && + typeof (value as { method?: unknown }).method === "string" && + !Object.hasOwn(value, "id") + ) +} + function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { if ( typeof value !== "object" || diff --git a/src/web-host.test.ts b/src/web-host.test.ts index 80c25967..b423d0c2 100644 --- a/src/web-host.test.ts +++ b/src/web-host.test.ts @@ -272,6 +272,107 @@ describe("web host", () => { } }) + it("notifies attached web observers after RPC structured-exchange mutations", async () => { + const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-live-")) + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ + specTitle: "Live web spec", + }) + const host = await startWebHost({ + cwd, + port: 0, + coordinator: createWorkspaceSessionCoordinator({ cwd }), + }) + const observer = await openWebSocket( + `${host.url.replace(/^http/u, "ws")}/rpc`, + ) + const actor = await openWebSocket(`${host.url.replace(/^http/u, "ws")}/rpc`) + try { + const observerNotification = nextWebSocketMessage(observer) + const actorResponse = nextWebSocketMessage(actor) + + actor.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 21, + method: "session.startElicitation", + }), + ) + + await expect(actorResponse).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 21, + result: { + status: "pending", + exchange: { exchangeId: "deterministic-grounding-choice-1" }, + }, + }) + await expect(observerNotification).resolves.toEqual({ + jsonrpc: "2.0", + method: "brunch.updated", + params: { + topics: [ + "workspace.snapshot", + "session.pendingExchange", + "session.elicitationExchanges", + "session.transcriptDisplay", + ], + }, + }) + + const responseNotification = nextWebSocketMessage(observer) + const respond = await websocketRpc(host.url, { + jsonrpc: "2.0", + id: 23, + method: "elicitation.respond", + params: { + exchangeId: "deterministic-grounding-choice-1", + answer: { optionId: "new-from-scratch" }, + note: "Observed by the web live-update proof.", + }, + }) + + expect(respond).toMatchObject({ + jsonrpc: "2.0", + id: 23, + result: { status: "accepted" }, + }) + await expect(responseNotification).resolves.toMatchObject({ + jsonrpc: "2.0", + method: "brunch.updated", + }) + + const display = await websocketRpc(host.url, { + jsonrpc: "2.0", + id: 22, + method: "session.transcriptDisplay", + }) + expect(display).toMatchObject({ + jsonrpc: "2.0", + id: 22, + result: { + rows: [ + { + role: "prompt", + text: expect.stringContaining( + "Is this a new product or feature from scratch?", + ), + }, + { + role: "user", + text: expect.stringContaining( + "Observed by the web live-update proof.", + ), + }, + ], + }, + }) + } finally { + observer.close() + actor.close() + await host.close() + } + }) + it("multiplexes two JSON-RPC requests over one WebSocket", async () => { const cwd = await mkdtemp(join(tmpdir(), "brunch-web-rpc-multiplex-")) await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ @@ -473,6 +574,21 @@ async function websocketRaw(url: string, message: string): Promise<unknown> { } } +function nextWebSocketMessage(socket: WebSocket): Promise<unknown> { + return new Promise((resolve, reject) => { + socket.addEventListener( + "message", + (event) => resolve(JSON.parse(String(event.data)) as unknown), + { once: true }, + ) + socket.addEventListener( + "error", + () => reject(new Error("WebSocket error")), + { once: true }, + ) + }) +} + function openWebSocket(url: string): Promise<WebSocket> { const socket = new WebSocket(url) return new Promise<WebSocket>((resolve, reject) => { From f101d1f5a405979c4569ed67ba7cf883bf723a5c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Fri, 29 May 2026 18:14:29 +0200 Subject: [PATCH 148/164] spec and scope capture re tool schemas, in great detail --- memory/CARDS.md | 1084 +++++++++++++++++++++++++++++++++++++++++++++++ memory/SPEC.md | 10 +- 2 files changed, 1089 insertions(+), 5 deletions(-) create mode 100644 memory/CARDS.md diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..bb0e89b0 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,1084 @@ +<!-- CARDS.md — temporary scope-card queue inside one frontier item. + Created by ln-scope. Delete when exhausted or superseded. + Frontier boundary: pi-ui-extension-patterns / FE-744. +--> + +# Structured-exchange schema authoring queue + +## Orientation + +- **Containing seam:** `src/tui-client/.pi/extensions/structured-exchange/`, inside the FE-744 `pi-ui-extension-patterns` frontier. +- **Current durable change:** `memory/SPEC.md` D41-L now permits Zod v4 as a product/protocol schema source when JSON Schema export is proven; TypeBox remains valid for direct JSON-Schema-shaped seams. +- **Main open risk:** schema authoring can drift from the carefully designed thread model by adding plausible fields, changing names, or prematurely filling underspecified areas. +- **Cross-cutting obligations:** preserve Pi transcript truth (`present_* -> request_* -> capture_*` toolResult details), classify by typed details rather than tool name alone, keep public/Pi boundaries JSON-Schema-exportable, and route future graph writes through `CommandExecutor` only. + +## Queue discipline + +These cards stay inside the existing FE-744 frontier and branch. They do not create new Linear issues or branches. Build in order. + +**Strict no-drift rule:** implement exactly the model captured below. Do not add fields because they seem useful. If implementation pressure suggests a new field, stop and ask; do not improvise. + +## Exact captured design from this thread + +This section is the volatile handoff. Treat it as binding for the build. + +### Schema naming + +User convention: + +```ts +import * as z from "zod" + +// schema value / Zod source runtime parser +const zPresentCandidatesDetails = z.object({}) + +// inferred type +type PresentCandidatesDetails = z.infer<typeof zPresentCandidatesDetails> + +// parsing +const candidateDetails = zPresentCandidatesDetails.parse({}) + +// JSON schema conversion +const PresentCandidatesDetailsSchema = z.toJSONSchema(zPresentCandidatesDetails) +``` + +Rules: + +- Do **not** name Zod source values `*Schema`. +- Zod source values use `z` prefix: `zPresentCandidatesDetails`. +- Inferred TS types use the bare domain name: `PresentCandidatesDetails`. +- `*Schema` suffix means “this is JSON Schema-shaped”. It is allowed for: + - JSON Schema generated from Zod with `z.toJSONSchema(...)`. + - TypeBox schemas, because TypeBox schemas are already JSON-Schema-shaped. +- If TypeBox source values need a library prefix in a non-boundary helper, use `tb*`. +- Words such as `Details`, `Params`, `Payload`, and `Result` are data-type name parts; they do not identify a schema library. + +### File organization + +Use layer-first organization: + +```text +src/tui-client/.pi/extensions/structured-exchange/schemas/ + README.md + shared.ts + present.ts + request.ts + capture.ts + index.ts +``` + +Reason: ease of overview and sharing. `capture.ts` exists because `capture_` tools are the fourth schema layer discussed in this thread. + +### Global details header rule + +`schema` and `v` are realistic only if readers validate them. We chose to keep them as checked discriminants. + +```yaml +details_header: + schema: "brunch.structured_exchange.present" | "brunch.structured_exchange.request" | "brunch.structured_exchange.capture" + v: 1 + exchange_id: string +``` + +Rules: + +- `schema` discriminates structured-exchange details from ordinary tool results without trusting `toolName` alone. +- `v` must be validated; unsupported versions should fail/ignore rather than silently parse. +- Use `v`, not `schema_version`, in the new Zod-authored details model. + +### `tool_meta` sequence/sibling information + +We rejected separate `present_tool`, `kind`, and `expected_request` fields in favor of a compact sequence descriptor. + +We considered `tool_kind`, then preferred something like `tool_meta`. + +Present side: + +```yaml +tool_meta: + curr: present_question | present_options | present_review_set | present_candidates + next: request_answer | request_choice | request_choices | request_review +``` + +Request side: + +```yaml +tool_meta: + prev: present_question | present_options | present_review_set | present_candidates + curr: request_answer | request_choice | request_choices | request_review + next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Capture side: + +```yaml +tool_meta: + prev: request_answer | request_choice | request_choices | request_review + curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Rules: + +- No `phase` field. It proves nothing; it is derivable from `curr` / layer. +- No present-side `status: presented`. If a present result exists, it was presented. +- No `prev_required` / `next_required` fields for now. +- Request terminal state is **not** a string `status`; it is a property-presence union. + +### `comment` vs `message` + +Rule: + +```yaml +comment: + meaning: user-authored supplementary text + source: human input + examples: + - optional explanation after selecting a listed option + - required explanation for Other / None + - review change-request rationale + - rejection reason if user supplies one + +message: + meaning: system-authored explanatory text + source: Brunch/tool/runtime + examples: + - "User cancelled the request." + - "request_choices requires interactive UI." + - "Invalid JSON in editor fallback." + - "Unknown choice id." +``` + +Do not use `note` in the new schema model. Use `comment` for user input and `message` for system/runtime explanation. + +## Present layer: exact shapes and rules + +### General present shape + +```yaml +present: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: string + + tool_meta: + curr: present_question | present_options | present_review_set | present_candidates + next: request_answer | request_choice | request_choices | request_review + + display: + heading: string + body?: markdown + preface?: markdown +``` + +### `present_options` naming + +The drift was `present_option_set` vs `present_options`. Decision: keep `present_options`; the existing tool name is fine. + +### `present_candidates` + +This is the exact shape the user wrote and approved: + +```yaml +present_candidates: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: string + + tool_meta: + curr: present_candidates + next: request_choice + + display: + heading: string + body?: markdown + + candidates: + - id: string + title: string + + user_rubric: + core_bet: markdown + best_fit: markdown + cost_complexity: markdown + covers_well: markdown + main_risks: markdown + lock_in_constraints: markdown + recommendation?: markdown + + meta_rubric: + legibility_cost_of_knowing?: markdown + failure_modes?: markdown + coverage_range?: markdown + commitment?: markdown + + graph_refs: + - node_id: string +``` + +Rules for `present_candidates`: + +- `core_bet` effectively acts as the headline/thesis of the candidate-proposal unit. +- `user_rubric` is the human-readable comparison surface. +- `meta_rubric` is persisted internal reasoning trace for later capture; it may be used by the assistant/capture step but is not necessarily rendered by default. +- Internally, the assistant may reason in terms of the four D31-L meta-rubric axes, then derive the `user_rubric` structure for the `present_candidates` tool. +- `graph_refs` are per-candidate. +- `graph_refs` consist strictly of graph node references: `{ node_id: string }` only. +- Do **not** add roles, caveats, assumptions, observations, grounding prose, or ad-hoc text to `graph_refs`. +- If such information matters, it should either already be in the graph or be captured in the `capture_` phase. +- Avoid low/medium/high scalar ratings for cost/risk/confidence/timeline by default; they usually obscure comparison rather than clarify. + +User-facing rubric remap captured from conversation: + +```yaml +instead_of: + - Confidence + - Timeline + - Complexity + - Risk + - Verification + - Key tradeoff + +use: + core_bet: "why choose this option" + best_fit: "what you get" + cost_complexity: "what it costs you" + covers_well: "what it hits" + main_risks: "what it misses" + lock_in_constraints: "what it commits you to" + recommendation: "the LLM's opinion" +``` + +Relationship to D31-L meta-rubric: + +```yaml +internal_meta_axes: + - legibility_cost_of_knowing + - failure_modes + - coverage_range + - commitment + +user_facing_facets: + core_bet: + role: headline / product-thesis-fit + question: "What thesis is this option making, and why would we choose it?" + best_fit: + role: where this option shines + sources: [legibility_cost_of_knowing, coverage_range] + cost_complexity: + role: what it asks of us + sources: [legibility_cost_of_knowing, commitment] + covers_well: + role: positive coverage + sources: [coverage_range] + main_risks: + role: negative coverage / failure + sources: [failure_modes, coverage_range] + lock_in_constraints: + role: downstream commitment + sources: [commitment] + recommendation: + role: agent judgment + sources: [all_facets] +``` + +### Other present tools + +The thread did not fully redesign exact payloads for `present_question`, `present_options`, or `present_review_set` beyond the general present shape and existing tool family names. Build conservative schemas from the existing implementation and the rules above. Do **not** invent extra candidate/review semantics beyond what is already in code/docs. + +Known from the original sketch: + +```yaml +present_question: + purpose: question heading and body; presentationally looks like normal assistant message + +present_options: + purpose: options, each with content and optional rationale + +present_review_set: + purpose: requirement or criterion nodes proposed as a set + caution: review-set semantics are documented elsewhere; do not make candidate selection into a review-set flow +``` + +Examples to include in README/tests where useful: + +#### `present_question` + +```yaml +present_question: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: "problem-frame" + + tool_meta: + curr: present_question + next: request_answer + + display: + heading: "What problem are we solving first?" + body: "Name the pain, the protagonist, and the constraint that matters most." + preface: "We have the project shape, but not the user-facing pull yet." +``` + +#### `present_options` for single choice + +```yaml +present_options: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + curr: present_options + next: request_choice + + display: + heading: "Which product shape should we optimize for?" + body: "Pick the shape that best matches the POC posture." + + options: + - id: "local-first" + content: "Local-first app" + rationale: "Matches the current single-machine POC constraint." + - id: "cloud-collab" + content: "Cloud collaboration app" + rationale: "Better for teams, but outside the current deployment target." +``` + +#### `present_options` for multiple choices + +```yaml +present_options: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: "open-risks" + + tool_meta: + curr: present_options + next: request_choices + + display: + heading: "Which risks should we keep visible?" + body: "Choose one or more risks to carry into the next slice." + + options: + - id: "transport" + content: "Transport contract" + rationale: "Public RPC behavior is now a product seam." + - id: "chrome" + content: "Chrome recovery" + rationale: "Visual product ownership remains open before FE-744 closes." +``` + +#### `present_review_set` conservative example + +```yaml +present_review_set: + schema: "brunch.structured_exchange.present" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + curr: present_review_set + next: request_review + + display: + heading: "Review proposed requirements" + body: "Approve the set, request changes, or reject it." + + review_set: + proposal_entry_id: "entry-review-proposal-17" +``` + +Do not elaborate `review_set` beyond existing design docs unless the builder first routes back through design/spec. + +## Request layer: exact shapes and rules + +Request details use property presence as the terminal discriminator. Runtime code should check for property presence. + +```yaml +request: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: string + + tool_meta: + prev: present_question | present_options | present_review_set | present_candidates + curr: request_answer | request_choice | request_choices | request_review + next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate + + answered: + # variant-specific payload here + cancelled?: + message?: string + unavailable?: + message: string +``` + +But the schema must enforce exactly one of: + +```yaml +- answered +- cancelled +- unavailable +``` + +Pseudo-TypeScript intent: + +```ts +type RequestDetails = RequestBase & + ( + | { answered: AnsweredPayload; cancelled?: never; unavailable?: never } + | { answered?: never; cancelled: { message?: string }; unavailable?: never } + | { answered?: never; cancelled?: never; unavailable: { message: string } } + ) +``` + +### Request examples: every variant and terminal outcome + +#### `request_answer` — answered + +```yaml +request_answer: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "problem-frame" + + tool_meta: + prev: present_question + curr: request_answer + next: capture_answer + + answered: + text: "The hard part is keeping the agent and graph coherent across sessions." +``` + +#### `request_answer` — cancelled + +```yaml +request_answer: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "problem-frame" + + tool_meta: + prev: present_question + curr: request_answer + + cancelled: + message: "User cancelled." +``` + +#### `request_answer` — unavailable + +```yaml +request_answer: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "problem-frame" + + tool_meta: + prev: present_question + curr: request_answer + + unavailable: + message: "request_answer requires interactive UI." +``` + +#### `request_choice` after `present_options` — answered with listed choice + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: present_options + curr: request_choice + next: capture_choice + + answered: + choice: + id: "local-first" + label: "Local-first app" + kind: listed + comment: "This fits the POC constraints." +``` + +#### `request_choice` after `present_options` — answered with other choice + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: present_options + curr: request_choice + next: capture_choice + + answered: + choice: + id: "other" + label: "A local-first app with optional cloud sync later" + kind: other + comment: "The listed local-first option is close, but cloud sync should stay imaginable." +``` + +#### `request_choice` after `present_options` — answered with none choice + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: present_options + curr: request_choice + next: capture_choice + + answered: + choice: + id: "none" + label: "None of these" + kind: none + comment: "All of these assume too much about deployment." +``` + +#### `request_choice` after `present_candidates` — answered + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "candidate-direction" + + tool_meta: + prev: present_candidates + curr: request_choice + next: capture_candidate + + answered: + choice: + id: "candidate-local-workbench" + label: "Local workbench for graph-native specs" + kind: listed + comment: "This matches the product thesis; carry over the chrome/coherence emphasis." +``` + +#### `request_choice` — cancelled + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: present_options + curr: request_choice + + cancelled: + message: "User cancelled." +``` + +#### `request_choice` — unavailable + +```yaml +request_choice: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: present_options + curr: request_choice + + unavailable: + message: "request_choice requires interactive UI." +``` + +#### `request_choices` — answered with listed choices + +```yaml +request_choices: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: present_options + curr: request_choices + next: capture_choices + + answered: + choices: + - id: "transport" + label: "Transport contract" + kind: listed + - id: "chrome" + label: "Chrome recovery" + kind: listed + comment: "These are the ones I care about before graph work." +``` + +#### `request_choices` — answered with listed plus other + +```yaml +request_choices: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: present_options + curr: request_choices + next: capture_choices + + answered: + choices: + - id: "transport" + label: "Transport contract" + kind: listed + - id: "other" + label: "Schema source-of-truth drift" + kind: other + comment: "The schema-library decision could affect both runtime and web client boundaries." +``` + +#### `request_choices` — answered with none + +```yaml +request_choices: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: present_options + curr: request_choices + next: capture_choices + + answered: + choices: + - id: "none" + label: "None of these" + kind: none + comment: "These are not the risks I want to prioritize." +``` + +#### `request_choices` — cancelled + +```yaml +request_choices: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: present_options + curr: request_choices + + cancelled: + message: "User cancelled." +``` + +#### `request_choices` — unavailable + +```yaml +request_choices: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: present_options + curr: request_choices + + unavailable: + message: "request_choices requires interactive UI." +``` + +#### `request_review` — approve + +```yaml +request_review: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: present_review_set + curr: request_review + next: capture_review + + answered: + decision: approve + comment: "This is ready to commit." +``` + +#### `request_review` — request changes + +```yaml +request_review: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: present_review_set + curr: request_review + next: capture_review + + answered: + decision: request_changes + comment: "Regenerate this with clearer non-goals." +``` + +#### `request_review` — reject + +```yaml +request_review: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: present_review_set + curr: request_review + next: capture_review + + answered: + decision: reject + comment: "This is solving the wrong problem." +``` + +#### `request_review` — cancelled + +```yaml +request_review: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: present_review_set + curr: request_review + + cancelled: + message: "User cancelled." +``` + +#### `request_review` — unavailable + +```yaml +request_review: + schema: "brunch.structured_exchange.request" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: present_review_set + curr: request_review + + unavailable: + message: "request_review requires interactive UI." +``` + +Request rules: + +- Use `comment`, not `note`, for user-authored supplementary text. +- `request_choice` can follow `present_options` or `present_candidates`. +- If `request_choice` follows `present_candidates`, the later capture tool is `capture_candidate`. +- `request_choices` follows `present_options`. +- `request_review` follows `present_review_set`. +- `request_review` supports `approve`, `request_changes`, and `reject`; `comment` is required for `request_changes`. +- `other` / `none` choices require a user `comment`. + +## Capture layer: exact decisions and limits + +The thread established a capture layer but did **not** fully design graph payload schemas. Do not invent them. + +Decisions: + +- There will be `capture_` tool entries after `request_` tool results. +- Capture is where semantic/generative work happens after user response. +- For `present_candidates`, graph generation happens **after** the user makes a choice. +- `capture_candidate` draws on: + - the selected candidate’s user-facing description (`user_rubric`), + - the selected candidate’s internal `meta_rubric`, + - the selected candidate’s `graph_refs`, + - the user’s selected choice, + - the user’s `comment`, if any. +- `present_candidates` may capture meta-rubric reasoning trace in `details`; that trace is later input to capture. +- `present_candidates` does **not** generate graph sets directly. +- Do not add ad-hoc observations to present details for later capture. +- All semantic capture happens at `capture_*`. +- Actual graph writes still route through `CommandExecutor`. + +Minimum capture sequence shape discussed: + +```yaml +capture: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: string + + tool_meta: + prev: request_answer | request_choice | request_choices | request_review + curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Do **not** add committed graph nodes, graph edges, LSNs, or `CommandExecutor` result fields to capture details in this schema pass unless the user explicitly approves a concrete shape. + +Capture examples for every current permutation: + +```yaml +capture_answer: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "problem-frame" + + tool_meta: + prev: request_answer + curr: capture_answer +``` + +```yaml +capture_choice: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "domain-shape" + + tool_meta: + prev: request_choice + curr: capture_choice +``` + +```yaml +capture_choices: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "open-risks" + + tool_meta: + prev: request_choices + curr: capture_choices +``` + +```yaml +capture_review: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "review-set-17" + + tool_meta: + prev: request_review + curr: capture_review +``` + +```yaml +capture_candidate: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "candidate-direction" + + tool_meta: + prev: request_choice + curr: capture_candidate +``` + +`capture_candidate` consumes the selected candidate id from the prior `request_choice`; do not duplicate candidate/user/meta rubric payloads into capture details unless the user approves that change. + +## Prepared scope-card queue + +--- + +## Card 1 — Write schema README from exact captured contract + +**Weight:** full scope card +**Status:** next + +### Target Behavior + +The structured-exchange schema directory contains a README that records the exact naming, layering, validation, export, and semantic-boundary rules captured above. + +### Boundary Crossings + +```text +-> memory/CARDS.md exact captured design contract +-> src/tui-client/.pi/extensions/structured-exchange/schemas/README.md +-> future schema implementation guardrails +``` + +### Risks and Assumptions + +- **RISK:** The README introduces new drift. + -> **MITIGATION:** copy the captured contract faithfully; do not add fields or new vocabulary. +- **RISK:** The README becomes design prose that tests do not enforce. + -> **MITIGATION:** Cards 2+ add tests for machine-checkable rules. + +### Acceptance Criteria + +- [ ] `src/tui-client/.pi/extensions/structured-exchange/schemas/README.md` exists. +- [ ] README captures the exact naming rules, file layout, header, `tool_meta`, `comment`/`message`, present, request, candidate, and capture rules above. +- [ ] README explicitly says not to invent graph payload fields in capture details. + +### Verification Approach + +- **Inner:** Markdown review against this card. +- **Middle:** none unless formatting tools touch markdown. +- **Outer:** none. + +### Cross-cutting obligations + +- Do not implement schemas in Card 1. +- Do not migrate runtime code in Card 1. + +--- + +## Card 2 — Add shared Zod primitives and JSON Schema export convention + +**Weight:** full scope card +**Status:** queued + +### Target Behavior + +The schema layer exposes shared Zod primitives for the exact shared vocabulary captured above. + +### Acceptance Criteria + +- [ ] `zod` is added as a dependency. +- [ ] `schemas/shared.ts` defines `z*` source schemas and bare inferred types for the details header, markdown string alias, graph node ref, tool names, and `tool_meta` variants. +- [ ] JSON Schema exports use the `*Schema` suffix only for JSON-Schema-shaped outputs. +- [ ] Tests prove representative shared schemas parse and export via `z.toJSONSchema(..., { unrepresentable: "throw" })`. + +### Verification Approach + +- **Inner:** targeted Vitest parse/export tests. +- **Middle:** `npm run fix`. +- **Gate:** `npm run verify` before commit. + +### Cross-cutting obligations + +- Do not alter existing runtime structured-exchange parsing/projection. + +--- + +## Card 3 — Add present detail Zod schemas + +**Weight:** full scope card +**Status:** queued + +### Target Behavior + +The schema layer models the present-side details vocabulary captured above without adding fields. + +### Acceptance Criteria + +- [ ] `schemas/present.ts` defines `zPresentQuestionDetails`, `zPresentOptionsDetails`, `zPresentReviewSetDetails`, `zPresentCandidatesDetails`, and a present union. +- [ ] `zPresentCandidatesDetails` exactly captures the approved `present_candidates` shape. +- [ ] Invalid candidate `graph_refs` with fields other than `node_id` fail validation. +- [ ] No present schema includes `phase`, `status`, `next_required`, `schema_version`, ad-hoc assumptions/caveats/observations, or scalar rating fields. +- [ ] JSON Schema export succeeds. + +### Verification Approach + +- **Inner:** targeted Vitest parse/export tests. +- **Middle:** `npm run fix`. +- **Gate:** `npm run verify` before commit. + +### Cross-cutting obligations + +- Use conservative shapes for `present_question`, `present_options`, and `present_review_set`; do not elaborate beyond existing implementation/docs and captured rules. + +--- + +## Card 4 — Add request detail Zod schemas + +**Weight:** full scope card +**Status:** queued + +### Target Behavior + +The schema layer models request-side details as exactly-one property-presence terminal outcome unions. + +### Acceptance Criteria + +- [ ] `schemas/request.ts` defines `zRequestAnswerDetails`, `zRequestChoiceDetails`, `zRequestChoicesDetails`, `zRequestReviewDetails`, and a request union. +- [ ] Request schemas accept exactly one of `answered`, `cancelled`, or `unavailable`. +- [ ] Tests reject multiple outcomes and missing outcome. +- [ ] `comment` appears only in user-authored answered payloads. +- [ ] `message` appears only in `cancelled` / `unavailable` system-authored payloads. +- [ ] `request_choice` supports `prev: present_options | present_candidates`. +- [ ] `request_review` requires `comment` when `decision = request_changes`. +- [ ] JSON Schema export succeeds. + +### Verification Approach + +- **Inner:** targeted Vitest parse/export tests. +- **Middle:** `npm run fix`. +- **Gate:** `npm run verify` before commit. + +### Cross-cutting obligations + +- Do not change public RPC behavior in this card. + +--- + +## Card 5 — Add capture detail Zod schemas at the agreed minimum + +**Weight:** full scope card +**Status:** queued + +### Target Behavior + +The schema layer models capture-side details only to the extent explicitly agreed: header plus request-to-capture `tool_meta` sequencing. + +### Acceptance Criteria + +- [ ] `schemas/capture.ts` defines capture tool-name schemas and minimal capture detail schemas for `capture_answer`, `capture_choice`, `capture_choices`, `capture_review`, and `capture_candidate`. +- [ ] Capture schemas include `schema`, `v`, `exchange_id`, and capture `tool_meta`. +- [ ] `capture_candidate` may include `selected_candidate_id` only if implementation keeps it as the selected choice id already recorded by request; do not add graph payloads. +- [ ] Capture schemas do not include committed graph nodes, graph edges, LSNs, `CommandExecutor` results, assumptions, caveats, or observations. +- [ ] JSON Schema export succeeds. + +### Verification Approach + +- **Inner:** targeted Vitest parse/export tests. +- **Middle:** `npm run fix`. +- **Gate:** `npm run verify` before commit. + +### Cross-cutting obligations + +- Capture remains a transcript layer, not graph truth. +- If the builder feels capture needs prose analysis fields, stop and ask; do not invent them. + +--- + +## Card 6 — Consolidate schema exports and gap report + +**Weight:** light scope card +**Status:** queued + +### Objective + +The structured-exchange schema layer exports a coherent public surface and records unresolved gaps before runtime migration begins. + +### Acceptance Criteria + +- [ ] `schemas/index.ts` re-exports the intended schema/type surface. +- [ ] README has a short “Known gaps before runtime migration” section only if implementation reveals gaps. +- [ ] Existing runtime code is not migrated in this queue. + +### Verification Approach + +- **Inner:** import/compile smoke via targeted tests. +- **Middle:** `npm run fix`. +- **Gate:** `npm run verify` before commit. + +### Assumption dependency + +Depends on: D41-L as revised — Zod v4 source schemas are allowed when JSON Schema export is proven. diff --git a/memory/SPEC.md b/memory/SPEC.md index ca761b6e..ac82e566 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -193,23 +193,23 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c Tool returns toolResult.content + self-contained toolResult.details ``` -- **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are TypeBox/JSON-Schema-shaped per D41-L, but discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. +- **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are JSON-Schema-shaped per D41-L, regardless of whether their source authoring library is Zod or TypeBox; discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. - **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: A23-L, D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. #### Persistence - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. - **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Side tasks are main-agent-invoked, non-blocking work items: the main agent fires them and continues without awaiting a return value. A Brunch-owned `SideTaskRegistry` tracks status; the only path a side task influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary through the existing `prepareNextTurn` path — never mid-turn. Side-task writes remain subject to the same command-layer authority as primary-agent writes. This is distinct from D44-L Subagent (main-agent-invoked **blocking** tool call whose result is returned directly as tool content). Depends on: A11-L, D4-L. Supersedes: —. -- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions via TypeBox per D41-L; the Drizzle version pin is open per A20-L. Depends on: A3-L, A4-L. Refined by: D41-L. Supersedes: —. +- **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions through a D41-L-compatible adapter (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent chosen during the A20-L graph-data-plane spike) rather than hand-authored alongside the table. The Drizzle version pin is open per A20-L. Depends on: A3-L, A4-L. Refined by: D41-L. Supersedes: —. - **D18-L — Post-exchange capture is synchronous elicitor work for the POC; observer/auditor queues are deferred backstops, not primary extraction authority.** After a user response closes an elicitation exchange, the elicitor may run a post-exchange capture step in the same turn-boundary flow: commit high-confidence extractive facts, concrete reconciliation needs, and spec readiness/posture updates through the `CommandExecutor`; fold low-confidence implications into later questions rather than graph truth. Brunch may still introduce durable observer/auditor jobs keyed by session id plus exchange entry ids for restartable audit, quality checks, or later backfill, but those jobs are not the load-bearing path for keeping the next turn's world fresh. Any async job writes still route through the command layer and remain operational queue state unless they surface semantic work as reconciliation needs. Depends on: A13-L, A22-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model and the earlier observer-owned primary extraction path. - **D28-L — Regenerated review-set proposals are appended as successor entries in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor proposal entry that references its predecessor via `supersedes`; prior proposals are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model. - **D29-L — Reviewer is an async advisory role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. -- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a TypeBox schema per D41-L when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. +- **D43-L — Auto-compaction is a Brunch-owned `session_before_compact` extension whose anchor preservation contract is an externalized JSON config.** Brunch always owns this hook because Pi's default summary cannot know about Brunch's transcript-native continuity entries. The extension composes a deterministic preserved-anchor header (rendered byte-stable from the configured anchor set against the pre-compaction branch) with an LLM-generated narrative summary, then returns Pi's standard `{ compaction: { summary, firstKeptEntryId, tokensBefore } }` shape. The summarization model is resolved through the active runtime bundle (D40-L) — typically a cheap/fast "compaction" preset (e.g. Gemini Flash, Haiku) — with fallback to Pi's default compaction on missing auth, empty output, or unexpected error so compaction is never gated on extension success. The anchor contract lives in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) as `{ kind, select, rationale }` rules (`select ∈ first | latest | active-leaves | all-unresolved`) so it can be reviewed and updated without SPEC churn; the file is validated through a D41-L-compatible runtime schema when the module lands. Brunch-initiated proactive compaction (post-`acceptReviewSet`, on shutdown) and reactor-side compaction triggers are deferred. Session-scoped continuity metadata (`lastSeenLsn`, interest sets) is *projected* from the change log plus the preserved anchor entries — it is not itself an anchor and never appears in the JSON. Depends on: D6-L, D15-L, D17-L, D40-L, D41-L. Supersedes: relying on Pi's default `session_before_compact` summary to keep Brunch-specific continuity intelligible. #### Schema & validation -- **D41-L — TypeBox is Brunch's single runtime schema vocabulary; Drizzle is the source of truth for persisted shapes.** Every Brunch boundary that needs a runtime schema speaks TypeBox: Pi tool parameters (Pi's `registerTool` already requires JSON-Schema-shaped objects, as in [src/tui-client/.pi/extensions/alternatives.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/alternatives.ts)), `brunch.*` custom-entry payloads, Brunch JSON-RPC request/response payloads, capture/reviewer/deferred-auditor result shapes, and SQLite row/insert/update validation projected from Drizzle. Drizzle table definitions remain canonical for persisted shapes; row/insert/update schemas are derived via `drizzle-orm/typebox` (or `drizzle-typebox` while on Drizzle 0.x — see A20-L) rather than hand-authored alongside the table. The runtime library is the new `typebox` package (matching the existing `alternatives.ts` import and `drizzle-orm/typebox` modern path), not `@sinclair/typebox`; `drizzle-orm/typebox-legacy` is permitted only as a temporary fallback if A20-L resolves toward staying on Drizzle 0.x. Static TS types come from `Static<typeof Schema>`; runtime parsing/validation uses `typebox/value` (`Value.Parse`, `Value.Check`, `Value.Errors`). Zod is not adopted. If a downstream library that ships only Zod adapters lands later (for example a TanStack Router search-param validator), Zod stays scoped to that adapter and must not leak into command, RPC, custom-entry, or DB layers. Depends on: D4-L, D5-L, D16-L. Supersedes: an implicit "any runtime schema library is fine" posture, and the existing ambiguity between `typebox` and `@sinclair/typebox`. +- **D41-L — Boundary schemas are runtime-validated and JSON-Schema-exportable; Zod v4 may be the product/protocol schema source.** Brunch boundary shapes must have one runtime schema source of truth, derived static TypeScript types, and JSON Schema output wherever a public protocol or Pi tool boundary needs discoverability. Zod v4 is permitted — and preferred for structured-exchange product/protocol schemas — when the schema stays inside Zod's JSON-representable subset and tests prove `z.toJSONSchema(..., { unrepresentable: "throw" })` succeeds for the exported boundary. TypeBox remains valid for Pi tool parameter objects, small config/frontmatter contracts, and any seam where the direct JSON-Schema-shaped authoring style is cheaper. Do not hand-author parallel Zod and TypeBox definitions for the same boundary; if a Pi API requires a JSON-Schema-shaped object, generate or adapt it from the chosen source schema and test the adapter. Drizzle table definitions remain canonical for persisted shapes; row/insert/update validation must be derived from Drizzle through a single adapter path (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent selected during A20-L) rather than hand-authored alongside the table. Boundary Zod schemas must avoid transforms, custom validators, `Date`, `Map`, `Set`, `bigint`, and other unrepresentable constructs unless an explicit adapter owns the input/output split and JSON Schema export tests cover it. Static TS types come from the schema source (`z.infer<typeof Schema>` for Zod, `Static<typeof Schema>` for TypeBox); runtime parsing uses the matching library (`Schema.parse`/`safeParse` for Zod, `Value.Parse`/`Value.Check` for TypeBox). Depends on: D4-L, D5-L, D16-L. Supersedes: TypeBox as Brunch's single runtime schema vocabulary, the ban on Zod outside downstream adapters, and an implicit "any runtime schema library is fine" posture. #### Interaction & UI shape @@ -229,7 +229,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. - **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. - **D47-L — Structured-exchange `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-exchange payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Future `capture_*` analysis entries provide a separate post-exchange/review evidence surface for candidate semantic changes; they do not replace preface as next-question orientation and do not become graph truth. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L, D50-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. -- **D50-L — `capture_*` tools persist transcript-native ANALYSIS, not graph mutations.** Brunch may add a third structured-exchange tool family such as `capture_analysis` alongside `present_*` and `request_*`. A `capture_*` tool returns a normal persisted Pi `toolResult` with Brunch details and markdown content describing likely graph/node/edge changes, grouped into high-confidence candidates that could be committed later and low-confidence candidates that should drive clarification. `capture_*` output is transcript-visible evidence for Markdown/ASCII review and later graph-mutation cross-checking, but it is not graph truth and never bypasses the `CommandExecutor`. Product UI should hide capture analysis entirely if Pi exposes a supported hide seam; otherwise `renderResult` should be maximally collapsed/minimal while preserving full persisted `toolResult.content`/`details` for transcript renderers. The exact TypeBox details schema and shared component subparts (`Preface`, prompt body, option list, answer summary, capture analysis) require a later `ln-design` pass before implementation. Depends on: D12-L, D17-L, D18-L, D37-L, D41-L, D47-L. Supersedes: using ad hoc hidden custom entries, probe-only side files, or graph writes as the first carrier for pre-graph analysis. +- **D50-L — `capture_*` tools persist transcript-native ANALYSIS, not graph mutations.** Brunch may add a third structured-exchange tool family such as `capture_analysis` alongside `present_*` and `request_*`. A `capture_*` tool returns a normal persisted Pi `toolResult` with Brunch details and markdown content describing likely graph/node/edge changes, grouped into high-confidence candidates that could be committed later and low-confidence candidates that should drive clarification. `capture_*` output is transcript-visible evidence for Markdown/ASCII review and later graph-mutation cross-checking, but it is not graph truth and never bypasses the `CommandExecutor`. Product UI should hide capture analysis entirely if Pi exposes a supported hide seam; otherwise `renderResult` should be maximally collapsed/minimal while preserving full persisted `toolResult.content`/`details` for transcript renderers. The exact details schema and shared component subparts (`Preface`, prompt body, option list, answer summary, capture analysis) require a later `ln-design` pass before implementation. Depends on: D12-L, D17-L, D18-L, D37-L, D41-L, D47-L. Supersedes: using ad hoc hidden custom entries, probe-only side files, or graph writes as the first carrier for pre-graph analysis. - **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/tui-client/.pi/extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. From f8bb3d4c75671523c8936964b104eed2f2aadfc6 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:31:18 +0200 Subject: [PATCH 149/164] Add Brunch prompt-pack topology --- memory/PLAN.md | 4 +- memory/SPEC.md | 4 +- package.json | 2 +- src/brunch-tui.test.ts | 6 +- .../.pi/__tests__/extension-registry.test.ts | 3 + .../.pi/__tests__/operational-mode.test.ts | 17 +- .../.pi/__tests__/prompting.test.ts | 181 ++++++++++++++++++ src/tui-client/.pi/context/README.md | 9 + src/tui-client/.pi/context/builders/README.md | 5 + .../.pi/context/builders/graph-context.ts | 7 + .../.pi/context/builders/readiness-context.ts | 10 + .../builders/structured-exchange-context.ts | 9 + .../.pi/context/compose-brunch-prompt.ts | 104 ++++++++++ .../.pi/context/prompt-packs/brunch-base.md | 6 + .../prompt-packs/candidate-proposals.md | 9 + .../context/prompt-packs/capture-analysis.md | 7 + .../.pi/context/prompt-packs/elicit.md | 6 + .../.pi/context/prompt-packs/elicitor.md | 6 + .../prompt-packs/structured-exchange.md | 11 ++ .../.pi/extensions/operational-mode.ts | 36 +--- src/tui-client/.pi/extensions/prompting.ts | 57 ++++++ src/tui-client/pi-extension-shell.ts | 4 + 22 files changed, 444 insertions(+), 59 deletions(-) create mode 100644 src/tui-client/.pi/__tests__/prompting.test.ts create mode 100644 src/tui-client/.pi/context/README.md create mode 100644 src/tui-client/.pi/context/builders/README.md create mode 100644 src/tui-client/.pi/context/builders/graph-context.ts create mode 100644 src/tui-client/.pi/context/builders/readiness-context.ts create mode 100644 src/tui-client/.pi/context/builders/structured-exchange-context.ts create mode 100644 src/tui-client/.pi/context/compose-brunch-prompt.ts create mode 100644 src/tui-client/.pi/context/prompt-packs/brunch-base.md create mode 100644 src/tui-client/.pi/context/prompt-packs/candidate-proposals.md create mode 100644 src/tui-client/.pi/context/prompt-packs/capture-analysis.md create mode 100644 src/tui-client/.pi/context/prompt-packs/elicit.md create mode 100644 src/tui-client/.pi/context/prompt-packs/elicitor.md create mode 100644 src/tui-client/.pi/context/prompt-packs/structured-exchange.md create mode 100644 src/tui-client/.pi/extensions/prompting.ts diff --git a/memory/PLAN.md b/memory/PLAN.md index 9c3ac545..565854bc 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -203,7 +203,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, and web real-time observation of RPC-originated structured-exchange transcript updates have landed. Current missing product seam is visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, and private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/` have landed. Current missing product seam is visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -211,7 +211,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`: `registerBrunchPrompting` appends deterministic code-composed prompt packs from the explicit shell, and `operational-mode.ts` remains responsible for runtime state/tool policy rather than prompt text duplication; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index ac82e566..16eba423 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -128,7 +128,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/tui-client/pi-extension-shell.ts`) rather than ambient discovery; *explicit* means the shell statically imports its product extensions and registers them from a fixed ordered list — it must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the shell wires those dependencies at the call site; the `default` exports under `src/tui-client/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`, or replacing the explicit static shell list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. -- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns this initial posture is `src/tui-client/.pi/extensions/operational-mode.ts`, not a generic permanent read-only tool-policy toggle. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, or modeling read-only posture as a volatile allowlist of every safe tool. +- **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. Brunch product prompt packs are private code-composed assets under `src/tui-client/.pi/context/prompt-packs/*`, composed only through `src/tui-client/.pi/context/compose-brunch-prompt.ts` and appended by the explicit `src/tui-client/.pi/extensions/prompting.ts` product extension; they are not Pi prompt templates, skills, context-file discovery, or user-invoked slash-command resources. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns tool policy is `src/tui-client/.pi/extensions/operational-mode.ts`, while product prompting is owned separately by `src/tui-client/.pi/extensions/prompting.ts`; neither should duplicate the other's control-plane responsibility. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, modeling read-only posture as a volatile allowlist of every safe tool, or exposing Brunch prompt packs through Pi resource discovery. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. @@ -297,7 +297,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Prompt/runtime profile architecture -- Brunch prompt composition should be explicit and layered: base Brunch product prompt + operational-mode prompt pack + top-level role preset + strategy prompt pack + lens prompt pack + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules. +- Brunch prompt composition is explicit and layered: base Brunch product prompt + operational-mode prompt pack + top-level role preset + strategy prompt pack + lens prompt pack + spec readiness grade + elicitation posture + current graph/coherence/world state + pending structured-interaction rules. The initial private prompt-pack topology lives under `src/tui-client/.pi/context/`, with deterministic composition through `compose-brunch-prompt.ts` and future dynamic context renderers under `context/builders/`. - Readiness is an internal forward gate, not a user-facing workflow stepper or session-local phase. `readiness_grade` and `elicitation_posture` live on the spec row per D45-L; validators may warn when graph/transcript evidence and assigned grade/posture diverge. Before these fields drive hard tool/agent authority beyond the POC, Brunch needs explicit rubrics for what evidence advances, blocks, or regresses grade/posture. - Core role/lens prompting should usually be product prompt packs rather than Pi skills. Pi skills remain available as Brunch-owned explicit resources when progressive disclosure is the right mechanism, but they are not the primary authority for operational mode/tool policy. diff --git a/package.json b/package.json index b0853b97..82f2961b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "scripts": { "dev": "tsx src/brunch.ts", "build": "tsc -p tsconfig.build.json && npm run build:pi-assets && npm run build:web", - "build:pi-assets": "mkdir -p dist/tui-client/.pi/components/workspace-dialog && cp -R src/tui-client/.pi/components/workspace-dialog/assets dist/tui-client/.pi/components/workspace-dialog/", + "build:pi-assets": "mkdir -p dist/tui-client/.pi/components/workspace-dialog dist/tui-client/.pi/context/prompt-packs && cp -R src/tui-client/.pi/components/workspace-dialog/assets dist/tui-client/.pi/components/workspace-dialog/ && cp src/tui-client/.pi/context/prompt-packs/*.md dist/tui-client/.pi/context/prompt-packs/", "build:web": "vite build", "test": "vitest --run", "test:watch": "vitest", diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index ff414a6d..efcbf1ac 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -730,11 +730,7 @@ describe("Brunch TUI boot", () => { Promise.resolve( events.before_agent_start?.({ systemPrompt: "base" } as never), ), - ).resolves.toMatchObject({ - systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, present_question, present_options, request_answer, request_choice, request_choices.", - ), - }) + ).resolves.toBeUndefined() await expect( Promise.resolve(events.tool_call?.({ toolName: "write" } as never)), ).resolves.toMatchObject({ block: true }) diff --git a/src/tui-client/.pi/__tests__/extension-registry.test.ts b/src/tui-client/.pi/__tests__/extension-registry.test.ts index dc3ea08b..fc39cc4d 100644 --- a/src/tui-client/.pi/__tests__/extension-registry.test.ts +++ b/src/tui-client/.pi/__tests__/extension-registry.test.ts @@ -10,6 +10,7 @@ import chrome from "../extensions/chrome.js" import commandPolicy from "../extensions/command-policy.js" import mentionAutocomplete from "../extensions/mention-autocomplete.js" import operationalMode from "../extensions/operational-mode.js" +import prompting from "../extensions/prompting.js" import sessionLifecycle from "../extensions/session-lifecycle.js" import structuredExchange, { PRESENT_OPTIONS_TOOL, @@ -28,6 +29,7 @@ const extensionDefaults = { "command-policy.ts": commandPolicy, "mention-autocomplete.ts": mentionAutocomplete, "operational-mode.ts": operationalMode, + "prompting.ts": prompting, "session-lifecycle.ts": sessionLifecycle, "structured-exchange/index.ts": structuredExchange, "workspace-dialog.ts": workspaceDialog, @@ -79,6 +81,7 @@ describe("Brunch explicit Pi extension registry", () => { "tool_call", "user_bash", "before_agent_start", + "before_agent_start", "session_start", ]) diff --git a/src/tui-client/.pi/__tests__/operational-mode.test.ts b/src/tui-client/.pi/__tests__/operational-mode.test.ts index 2d584542..6ae73112 100644 --- a/src/tui-client/.pi/__tests__/operational-mode.test.ts +++ b/src/tui-client/.pi/__tests__/operational-mode.test.ts @@ -155,22 +155,7 @@ describe("Brunch agent runtime-state projection", () => { "present_alternatives", ], ]) - expect(promptResult).toMatchObject({ - systemPrompt: expect.stringContaining("Operational mode: elicit."), - }) - expect(promptResult).toMatchObject({ - systemPrompt: expect.stringContaining("Agent role: elicitor."), - }) - expect(promptResult).toMatchObject({ - systemPrompt: expect.stringContaining( - "Agent strategy: disambiguate-via-examples.", - ), - }) - expect(promptResult).toMatchObject({ - systemPrompt: expect.stringContaining( - "Brunch exposes only elicit-safe tools: read, grep, find, ls, structured_exchange, present_alternatives.", - ), - }) + expect(promptResult).toBeUndefined() for (const toolName of ["bash", "edit", "write"]) { await expect( Promise.resolve(events.tool_call?.({ toolName } as never)), diff --git a/src/tui-client/.pi/__tests__/prompting.test.ts b/src/tui-client/.pi/__tests__/prompting.test.ts new file mode 100644 index 00000000..59f3ad54 --- /dev/null +++ b/src/tui-client/.pi/__tests__/prompting.test.ts @@ -0,0 +1,181 @@ +import { readFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it } from "vitest" + +import { composeBrunchPrompt } from "../context/compose-brunch-prompt.js" +import { + DEFAULT_BRUNCH_AGENT_STATE, + type BrunchAgentState, +} from "../extensions/operational-mode.js" +import { registerBrunchPrompting } from "../extensions/prompting.js" +import { createBrunchPiExtensionShell } from "../../pi-extension-shell.js" + +function runtimeEntry(state: BrunchAgentState) { + return { + type: "custom", + customType: "brunch.agent_runtime_state", + data: { + schemaVersion: 1, + reason: "switch", + state, + source: "user", + }, + } +} + +describe("Brunch prompt-pack topology", () => { + it("composes deterministic private prompt packs in stable order", () => { + const result = composeBrunchPrompt({ + operationalMode: "elicit", + agentRole: "elicitor", + agentStrategy: "step-by-step", + agentLens: "step-by-step", + activeTools: ["read", "grep", "present_options"], + }) + + expect(result.packIds).toEqual([ + "brunch-base", + "elicit", + "elicitor", + "structured-exchange", + "candidate-proposals", + "capture-analysis", + ]) + expect(result.prompt).toContain("[Brunch agent state]") + expect(result.prompt).toContain("Operational mode: elicit.") + expect(result.prompt).toContain("Agent role: elicitor.") + expect(result.prompt).toContain( + "Brunch exposes only elicit-safe tools: read, grep, present_options.", + ) + expect(result.prompt.indexOf("# Brunch base")).toBeLessThan( + result.prompt.indexOf("# Operational mode: elicit"), + ) + expect(result.prompt.indexOf("# Structured exchanges")).toBeLessThan( + result.prompt.indexOf("# Candidate proposals"), + ) + expect(result.prompt).toContain( + "Request outcomes are an exactly-one property-presence union", + ) + expect(result.prompt).toContain( + "`graph_refs` are per-candidate and strictly existing graph node references", + ) + expect(result.prompt).toContain( + "Capture is transcript-native analysis, not graph mutation.", + ) + expect(result.prompt).not.toContain("CommandExecutor result shapes") + }) + + it("appends composed Brunch prompting from runtime-state projection", async () => { + const latestState: BrunchAgentState = { + ...DEFAULT_BRUNCH_AGENT_STATE, + agentStrategy: "disambiguate-via-examples", + agentLens: "disambiguate-via-examples", + } + const events: Record<string, (event: never, ctx?: never) => unknown> = {} + + registerBrunchPrompting({ + on: (event: string, handler: (event: never, ctx?: never) => unknown) => { + events[event] = handler + }, + getAllTools: () => + ["read", "grep", "bash", "write", "present_options"].map((name) => ({ + name, + })), + } as never) + + const result = await Promise.resolve( + events.before_agent_start?.({ systemPrompt: "base" } as never, { + sessionManager: { + getEntries: () => [runtimeEntry(latestState)], + }, + } as never), + ) + + expect(result).toMatchObject({ + systemPrompt: expect.stringContaining("base\n\n[Brunch agent state]"), + }) + expect(result).toMatchObject({ + systemPrompt: expect.stringContaining( + "Agent strategy: disambiguate-via-examples.", + ), + }) + expect(result).toMatchObject({ + systemPrompt: expect.stringContaining( + "Brunch exposes only elicit-safe tools: read, grep, present_options.", + ), + }) + }) + + it("is registered by the explicit shell after operational-mode policy", async () => { + const eventNames: string[] = [] + + await createBrunchPiExtensionShell( + { + cwd: "/tmp/brunch", + chatMode: "interactive", + phase: "ready", + spec: { id: "spec-1", title: "Spec" }, + session: { id: "session-1", label: "Session" }, + }, + undefined, + { + coordinator: {} as never, + graphMentionSource: { listMentionCandidates: () => [] }, + }, + )({ + on: (eventName: string) => eventNames.push(eventName), + registerTool() {}, + registerCommand() {}, + registerShortcut() {}, + registerMessageRenderer() {}, + sendMessage() {}, + getAllTools: () => ["read", "bash", "write"].map((name) => ({ name })), + setActiveTools() {}, + } as never) + + const operationalToolPolicyIndex = eventNames.indexOf("tool_call") + const userBashPolicyIndex = eventNames.indexOf("user_bash") + const promptingIndex = eventNames.indexOf( + "before_agent_start", + userBashPolicyIndex + 1, + ) + const nextBeforeAgentStartIndex = eventNames.indexOf( + "before_agent_start", + promptingIndex + 1, + ) + + expect(operationalToolPolicyIndex).toBeGreaterThan(-1) + expect(userBashPolicyIndex).toBeGreaterThan(operationalToolPolicyIndex) + expect(promptingIndex).toBeGreaterThan(userBashPolicyIndex) + expect(promptingIndex).toBeLessThan(nextBeforeAgentStartIndex) + }) + + it("does not expose private prompt packs through Pi resource discovery", async () => { + const [promptingSource, composerSource] = await Promise.all([ + readFile( + join(projectRoot(), "src/tui-client/.pi/extensions/prompting.ts"), + "utf8", + ), + readFile( + join( + projectRoot(), + "src/tui-client/.pi/context/compose-brunch-prompt.ts", + ), + "utf8", + ), + ]) + + expect(promptingSource).not.toContain("resources_discover") + expect(promptingSource).not.toContain("promptPaths") + expect(composerSource).not.toContain("resources_discover") + expect(composerSource).not.toContain("promptPaths") + }) +}) + +function projectRoot(): string { + return dirname( + dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), + ) +} diff --git a/src/tui-client/.pi/context/README.md b/src/tui-client/.pi/context/README.md new file mode 100644 index 00000000..a3633911 --- /dev/null +++ b/src/tui-client/.pi/context/README.md @@ -0,0 +1,9 @@ +# Brunch private context and prompt packs + +This directory contains private Brunch product context and prompt assets for the embedded Pi runtime. + +It is intentionally under `.pi/context/`, not `.pi/prompts/`, so these files are not Pi prompt-template resources and are not user-invoked slash-command prompt templates. Product code imports and composes prompt packs deterministically through `compose-brunch-prompt.ts` and the `registerBrunchPrompting` extension. + +`prompt-packs/` contains Brunch-owned markdown fragments. They are product control-plane assets, not ambient Pi skills or templates, and must never be returned from `resources_discover.promptPaths`. + +Future dynamic context renderers live under `builders/`. Builders should project already-canonical state into prompt text; they must not become hidden stores, query ambient Pi resources, or invent uncaptured facts. diff --git a/src/tui-client/.pi/context/builders/README.md b/src/tui-client/.pi/context/builders/README.md new file mode 100644 index 00000000..39381bc1 --- /dev/null +++ b/src/tui-client/.pi/context/builders/README.md @@ -0,0 +1,5 @@ +# Brunch context builders + +Builders are deterministic renderers over already-canonical state. They may later render graph, readiness, or structured-exchange snapshots into prompt context. + +Builders must not query ambient Pi resources, mutate graph truth, call the `CommandExecutor`, or invent uncaptured facts. If a fact is not already canonical or explicitly supplied in the builder snapshot, do not render it as product truth. diff --git a/src/tui-client/.pi/context/builders/graph-context.ts b/src/tui-client/.pi/context/builders/graph-context.ts new file mode 100644 index 00000000..1fe7b43c --- /dev/null +++ b/src/tui-client/.pi/context/builders/graph-context.ts @@ -0,0 +1,7 @@ +export interface GraphContextSnapshot { + graphNodeCount?: number +} + +export function renderGraphContext(_snapshot?: GraphContextSnapshot): string { + return "" +} diff --git a/src/tui-client/.pi/context/builders/readiness-context.ts b/src/tui-client/.pi/context/builders/readiness-context.ts new file mode 100644 index 00000000..1f8c2c57 --- /dev/null +++ b/src/tui-client/.pi/context/builders/readiness-context.ts @@ -0,0 +1,10 @@ +export interface ReadinessContextSnapshot { + readinessGrade?: string + elicitationPosture?: string +} + +export function renderReadinessContext( + _snapshot?: ReadinessContextSnapshot, +): string { + return "" +} diff --git a/src/tui-client/.pi/context/builders/structured-exchange-context.ts b/src/tui-client/.pi/context/builders/structured-exchange-context.ts new file mode 100644 index 00000000..f2183ae4 --- /dev/null +++ b/src/tui-client/.pi/context/builders/structured-exchange-context.ts @@ -0,0 +1,9 @@ +export interface StructuredExchangeContextSnapshot { + pendingExchangeId?: string +} + +export function renderStructuredExchangeContext( + _snapshot?: StructuredExchangeContextSnapshot, +): string { + return "" +} diff --git a/src/tui-client/.pi/context/compose-brunch-prompt.ts b/src/tui-client/.pi/context/compose-brunch-prompt.ts new file mode 100644 index 00000000..38ec5159 --- /dev/null +++ b/src/tui-client/.pi/context/compose-brunch-prompt.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs" + +import { renderGraphContext } from "./builders/graph-context.js" +import { renderReadinessContext } from "./builders/readiness-context.js" +import { renderStructuredExchangeContext } from "./builders/structured-exchange-context.js" + +export interface BrunchPromptCompositionState { + operationalMode: string + agentRole: string + agentStrategy: string + agentLens: string | null + activeTools: readonly string[] +} + +export interface BrunchPromptPack { + id: string + title: string + markdown: string +} + +export interface BrunchPromptCompositionResult { + prompt: string + packIds: readonly string[] +} + +const PROMPT_PACK_ORDER = [ + "brunch-base", + "elicit", + "elicitor", + "structured-exchange", + "candidate-proposals", + "capture-analysis", +] as const + +type PromptPackId = typeof PROMPT_PACK_ORDER[number] + +const PROMPT_PACK_TITLES: Record<PromptPackId, string> = { + "brunch-base": "Brunch base", + elicit: "Operational mode: elicit", + elicitor: "Agent role: elicitor", + "structured-exchange": "Structured exchanges", + "candidate-proposals": "Candidate proposals", + "capture-analysis": "Capture analysis", +} + +function readPromptPack(id: PromptPackId): BrunchPromptPack { + return { + id, + title: PROMPT_PACK_TITLES[id], + markdown: readFileSync( + new URL(`./prompt-packs/${id}.md`, import.meta.url), + "utf8", + ).trim(), + } +} + +const PROMPT_PACKS = PROMPT_PACK_ORDER.map(readPromptPack) + +function renderAgentState(state: BrunchPromptCompositionState): string { + const tools = state.activeTools.join(", ") || "none" + const lens = state.agentLens ?? "none" + + return [ + "[Brunch agent state]", + `- Operational mode: ${state.operationalMode}.`, + `- Agent role: ${state.agentRole}.`, + `- Agent strategy: ${state.agentStrategy}.`, + `- Agent lens: ${lens}.`, + `- Prompt packs: ${PROMPT_PACK_ORDER.join(", ")}.`, + "", + "[Brunch tool policy]", + `- Brunch exposes only elicit-safe tools: ${tools}.`, + "- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.", + "- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.", + ].join("\n") +} + +function joinPromptSections(sections: readonly string[]): string { + return sections + .map((section) => section.trim()) + .filter((section) => section.length > 0) + .join("\n\n") +} + +export function composeBrunchPrompt( + state: BrunchPromptCompositionState, +): BrunchPromptCompositionResult { + const packSections = PROMPT_PACKS.map((pack) => pack.markdown) + const dynamicSections = [ + renderGraphContext(), + renderReadinessContext(), + renderStructuredExchangeContext(), + ] + const prompt = joinPromptSections([ + renderAgentState(state), + ...packSections, + ...dynamicSections, + ]) + + return { + prompt, + packIds: PROMPT_PACKS.map((pack) => pack.id), + } +} diff --git a/src/tui-client/.pi/context/prompt-packs/brunch-base.md b/src/tui-client/.pi/context/prompt-packs/brunch-base.md new file mode 100644 index 00000000..e4a05253 --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/brunch-base.md @@ -0,0 +1,6 @@ +# Brunch base + +- Brunch is an elicitation-first specification product built over Pi. +- Use Brunch product tools and Pi transcript truth; do not treat ambient free chat as the primary workflow. +- Do not expose Pi customization APIs, prompt templates, skills, themes, or extensions as Brunch product behavior. +- Do not mutate graph truth except through the future Brunch command layer and approved product tools. diff --git a/src/tui-client/.pi/context/prompt-packs/candidate-proposals.md b/src/tui-client/.pi/context/prompt-packs/candidate-proposals.md new file mode 100644 index 00000000..e5e9f78d --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/candidate-proposals.md @@ -0,0 +1,9 @@ +# Candidate proposals + +- Internally reason using the D31-L meta-rubric axes: `legibility_cost_of_knowing`, `failure_modes`, `coverage_range`, and `commitment`. +- Derive user-facing `present_candidates` fields: `core_bet`, `best_fit`, `cost_complexity`, `covers_well`, `main_risks`, `lock_in_constraints`, and optional `recommendation`. +- `core_bet` is the candidate headline or thesis. +- Avoid fake low/medium/high scalar ratings for cost, risk, confidence, timeline, or verification. +- `graph_refs` are per-candidate and strictly existing graph node references: `{ node_id: string }` only. +- Do not add ad-hoc assumptions, caveats, observations, or grounding prose to `graph_refs`. +- `present_candidates` does not generate graph truth; it records user-facing comparison plus persisted meta-rubric reasoning trace for later capture. diff --git a/src/tui-client/.pi/context/prompt-packs/capture-analysis.md b/src/tui-client/.pi/context/prompt-packs/capture-analysis.md new file mode 100644 index 00000000..b64496bd --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/capture-analysis.md @@ -0,0 +1,7 @@ +# Capture analysis + +- `capture_*` follows `request_*`. +- For candidate selection, capture consumes the selected candidate `user_rubric`, selected candidate `meta_rubric`, selected candidate `graph_refs`, and the user's `comment` if present. +- Capture is transcript-native analysis, not graph mutation. +- Do not invent final graph payloads, LSNs, or `CommandExecutor` result shapes in this prompt pack. +- Future graph writes must route through `CommandExecutor`; this pack must not imply a bypass. diff --git a/src/tui-client/.pi/context/prompt-packs/elicit.md b/src/tui-client/.pi/context/prompt-packs/elicit.md new file mode 100644 index 00000000..abd9b2a1 --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/elicit.md @@ -0,0 +1,6 @@ +# Operational mode: elicit + +- You are operating in `elicit` mode. +- Ask or present the next useful structured exchange. +- Keep side effects out of elicit mode. +- Prefer buildable, transcript-backed structured interactions over hidden state. diff --git a/src/tui-client/.pi/context/prompt-packs/elicitor.md b/src/tui-client/.pi/context/prompt-packs/elicitor.md new file mode 100644 index 00000000..d77fb6b3 --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/elicitor.md @@ -0,0 +1,6 @@ +# Agent role: elicitor + +- The active role is `elicitor`. +- Own next-move selection; establishment offers orient rather than becoming a default menu. +- Use lenses as elicitor strategies, not agent roles. +- Preserve `lens` metadata where Brunch schemas or tools require it. diff --git a/src/tui-client/.pi/context/prompt-packs/structured-exchange.md b/src/tui-client/.pi/context/prompt-packs/structured-exchange.md new file mode 100644 index 00000000..334ed782 --- /dev/null +++ b/src/tui-client/.pi/context/prompt-packs/structured-exchange.md @@ -0,0 +1,11 @@ +# Structured exchanges + +- Structured exchanges are transcript-native `present_* -> request_* -> capture_*` tool result families. +- `toolResult.content` is durable markdown for transcript display and model-readable context. +- `toolResult.details` is structured recovery and projection data. +- `renderCall` is not semantic and must not carry durable Brunch meaning. +- Classify structured-exchange rows by `details.schema`, not `toolName` alone. +- Use `schema` plus `v` as checked discriminants in the details model. +- Use `tool_meta` for sequence and sibling information. +- Use `comment` for user-authored text and `message` for system/runtime-authored text. +- Request outcomes are an exactly-one property-presence union: `answered`, `cancelled`, or `unavailable`. diff --git a/src/tui-client/.pi/extensions/operational-mode.ts b/src/tui-client/.pi/extensions/operational-mode.ts index ef8c52ce..184c4c3d 100644 --- a/src/tui-client/.pi/extensions/operational-mode.ts +++ b/src/tui-client/.pi/extensions/operational-mode.ts @@ -320,7 +320,7 @@ function supportsBrunchAgentStateEntries( ) } -function activeToolNamesForState( +export function activeToolNamesForBrunchAgentState( pi: ExtensionAPI, state: ResolvedBrunchAgentState, ): string[] { @@ -340,31 +340,7 @@ function applyBrunchToolPolicy( pi: ExtensionAPI, state: ResolvedBrunchAgentState, ): void { - pi.setActiveTools(activeToolNamesForState(pi, state)) -} - -function composeBrunchAgentStatePrompt( - state: ResolvedBrunchAgentState, - activeTools: readonly string[], -): string { - const tools = activeTools.join(", ") || "none" - const lens = state.agentLens ?? "none" - - return ( - `\n\n[Brunch agent state]\n` + - `- Operational mode: ${state.operationalMode}.\n` + - `- Agent role: ${state.agentRole}.\n` + - `- Agent strategy: ${state.agentStrategy}.\n` + - `- Agent lens: ${lens}.\n` + - `- Prompt packs: ${[ - ...state.operationalModeDefinition.promptPackIds, - ...state.agentRoleDefinition.promptPackIds, - ].join(", ")}.\n` + - `\n[Brunch tool policy]\n` + - `- Brunch exposes only elicit-safe tools: ${tools}.\n` + - `- Do not attempt to write files, edit code, run shell commands, change git state, install dependencies, start processes, or mutate external systems.\n` + - `- If the user asks for a side-effecting action, explain that this Brunch prototype is read-only for now.` - ) + pi.setActiveTools(activeToolNamesForBrunchAgentState(pi, state)) } interface TextLikeContent { @@ -570,15 +546,9 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { applyBrunchToolPolicy(pi, state) }) - pi.on("before_agent_start", async (event, ctx) => { + pi.on("before_agent_start", async (_event, ctx) => { const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager) - const activeTools = activeToolNamesForState(pi, state) applyBrunchToolPolicy(pi, state) - - return { - systemPrompt: - event.systemPrompt + composeBrunchAgentStatePrompt(state, activeTools), - } }) pi.on("tool_call", async (event) => { diff --git a/src/tui-client/.pi/extensions/prompting.ts b/src/tui-client/.pi/extensions/prompting.ts new file mode 100644 index 00000000..da0bfc3e --- /dev/null +++ b/src/tui-client/.pi/extensions/prompting.ts @@ -0,0 +1,57 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +import { composeBrunchPrompt } from "../context/compose-brunch-prompt.js" +import { + activeToolNamesForBrunchAgentState, + projectBrunchAgentState, +} from "./operational-mode.js" + +type BrunchAgentStateEntries = Parameters<typeof projectBrunchAgentState>[0] + +interface SessionManagerLike { + getEntries(): BrunchAgentStateEntries +} + +interface BeforeAgentStartEventLike { + systemPrompt?: string +} + +interface BeforeAgentStartContextLike { + sessionManager?: SessionManagerLike +} + +function supportsPrompting(pi: ExtensionAPI): boolean { + return typeof (pi as Partial<ExtensionAPI>).on === "function" +} + +function projectState(ctx: BeforeAgentStartContextLike | undefined) { + return projectBrunchAgentState(ctx?.sessionManager?.getEntries() ?? []) +} + +export function registerBrunchPrompting(pi: ExtensionAPI): void { + if (!supportsPrompting(pi)) return + + pi.on("before_agent_start", async (event, ctx) => { + const state = projectState(ctx as BeforeAgentStartContextLike | undefined) + const activeTools = + typeof (pi as Partial<ExtensionAPI>).getAllTools === "function" + ? activeToolNamesForBrunchAgentState(pi, state) + : [] + const { prompt } = composeBrunchPrompt({ + operationalMode: state.operationalMode, + agentRole: state.agentRole, + agentStrategy: state.agentStrategy, + agentLens: state.agentLens, + activeTools, + }) + + if (prompt.trim().length === 0) return undefined + + const basePrompt = (event as BeforeAgentStartEventLike).systemPrompt ?? "" + return { + systemPrompt: `${basePrompt}\n\n${prompt}`, + } + }) +} + +export default registerBrunchPrompting diff --git a/src/tui-client/pi-extension-shell.ts b/src/tui-client/pi-extension-shell.ts index 10c5b8c1..b30bfd77 100644 --- a/src/tui-client/pi-extension-shell.ts +++ b/src/tui-client/pi-extension-shell.ts @@ -12,6 +12,7 @@ import { registerBrunchMentionAutocomplete, } from "./.pi/extensions/mention-autocomplete.js" import { registerBrunchOperationalModePolicy } from "./.pi/extensions/operational-mode.js" +import { registerBrunchPrompting } from "./.pi/extensions/prompting.js" import { registerBrunchSessionBoundary } from "./.pi/extensions/session-lifecycle.js" import { registerStructuredExchange } from "./.pi/extensions/structured-exchange/index.js" import { type BrunchChromeState } from "./.pi/extensions/chrome.js" @@ -30,6 +31,7 @@ export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, appendBrunchAgentRuntimeInit, + activeToolNamesForBrunchAgentState, appendBrunchAgentRuntimeSwitch, projectBrunchAgentState, registerBrunchOperationalModePolicy, @@ -44,6 +46,7 @@ export { type OperationalModeId, type ResolvedBrunchAgentState, } from "./.pi/extensions/operational-mode.js" +export { registerBrunchPrompting } from "./.pi/extensions/prompting.js" export { chromeStateForWorkspace, projectBrunchChromeFooterLines, @@ -93,6 +96,7 @@ export function createBrunchPiExtensionShell( (api) => registerBrunchChrome(api, chrome), registerBrunchBranchPolicyHandlers, registerBrunchOperationalModePolicy, + registerBrunchPrompting, (api) => registerBrunchMentionAutocomplete(api, graphMentionSource), registerBrunchAlternatives, registerStructuredExchange, From b693e4e8bd2da8b46dc3a5e1dd3ced96621ced68 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:33:12 +0200 Subject: [PATCH 150/164] Add structured exchange schema contract README --- memory/CARDS.md | 2 +- .../structured-exchange/schemas/README.md | 386 ++++++++++++++++++ 2 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/README.md diff --git a/memory/CARDS.md b/memory/CARDS.md index bb0e89b0..85ea27fe 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -899,7 +899,7 @@ capture_candidate: ## Card 1 — Write schema README from exact captured contract **Weight:** full scope card -**Status:** next +**Status:** done ### Target Behavior diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md b/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md new file mode 100644 index 00000000..dfc2f1d9 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md @@ -0,0 +1,386 @@ +# Structured-exchange schema contract + +This directory owns the Zod-authored, JSON-Schema-exportable details model for structured-exchange transcript tool results. It records the exact contract for the schema pass; runtime migration is separate work. + +## Naming + +```ts +import * as z from "zod" + +const zPresentCandidatesDetails = z.object({}) +type PresentCandidatesDetails = z.infer<typeof zPresentCandidatesDetails> +const candidateDetails = zPresentCandidatesDetails.parse({}) +const PresentCandidatesDetailsSchema = z.toJSONSchema(zPresentCandidatesDetails) +``` + +- Zod source values use the `z` prefix and are not named `*Schema`. +- Inferred TypeScript types use the bare domain name. +- `*Schema` means JSON-Schema-shaped output: either generated with `z.toJSONSchema(...)` or authored directly with TypeBox. +- If TypeBox source values need a prefix in non-boundary helpers, use `tb*`. +- `Details`, `Params`, `Payload`, and `Result` are data-type name parts, not schema-library markers. + +## File layout + +```text +schemas/ + README.md + shared.ts + present.ts + request.ts + capture.ts + index.ts +``` + +The organization is layer-first: shared vocabulary, present details, request details, capture details, and one public export barrel. + +## Global details header + +All detail payloads carry checked discriminants: + +```yaml +schema: "brunch.structured_exchange.present" | "brunch.structured_exchange.request" | "brunch.structured_exchange.capture" +v: 1 +exchange_id: string +``` + +- `schema` identifies structured-exchange details without trusting `toolName` alone. +- `v` is validated; unsupported versions fail parsing and should be ignored by readers. +- Use `v`, not `schema_version`, in this Zod-authored model. + +## Tool sequencing metadata + +No `phase` field is used. Layer and `tool_meta.curr` are sufficient. + +Present details: + +```yaml +tool_meta: + curr: present_question | present_options | present_review_set | present_candidates + next: request_answer | request_choice | request_choices | request_review +``` + +Request details: + +```yaml +tool_meta: + prev: present_question | present_options | present_review_set | present_candidates + curr: request_answer | request_choice | request_choices | request_review + next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Capture details: + +```yaml +tool_meta: + prev: request_answer | request_choice | request_choices | request_review + curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Do not add `present_tool`, `kind`, `expected_request`, `prev_required`, `next_required`, present-side `status: presented`, or request-side string `status` fields in this model. + +## `comment` and `message` + +- `comment` is user-authored supplementary text: option-selection explanation, required Other/None explanation, review change-request rationale, or rejection reason when supplied. +- `message` is system/tool/runtime-authored explanatory text: cancellation text, unavailable UI text, invalid JSON in editor fallback, or unknown choice diagnostics. +- Do not use `note` in the new schema model. + +## Present layer + +General present shape: + +```yaml +schema: "brunch.structured_exchange.present" +v: 1 +exchange_id: string +tool_meta: + curr: present_question | present_options | present_review_set | present_candidates + next: request_answer | request_choice | request_choices | request_review +display: + heading: string + body?: markdown + preface?: markdown +``` + +### `present_question` + +A question heading/body that presents like a normal assistant message: + +```yaml +schema: "brunch.structured_exchange.present" +v: 1 +exchange_id: "problem-frame" +tool_meta: + curr: present_question + next: request_answer +display: + heading: "What problem are we solving first?" + body: "Name the pain, the protagonist, and the constraint that matters most." + preface: "We have the project shape, but not the user-facing pull yet." +``` + +### `present_options` + +Keep the existing `present_options` name. Options have content and optional rationale. + +```yaml +schema: "brunch.structured_exchange.present" +v: 1 +exchange_id: "domain-shape" +tool_meta: + curr: present_options + next: request_choice +display: + heading: "Which product shape should we optimize for?" + body: "Pick the shape that best matches the POC posture." +options: + - id: "local-first" + content: "Local-first app" + rationale: "Matches the current single-machine POC constraint." + - id: "cloud-collab" + content: "Cloud collaboration app" + rationale: "Better for teams, but outside the current deployment target." +``` + +For multiple choice, `tool_meta.next` is `request_choices`. + +### `present_review_set` + +Keep review-set semantics conservative and defer to existing design docs. Do not turn candidate selection into a review-set flow. + +```yaml +schema: "brunch.structured_exchange.present" +v: 1 +exchange_id: "review-set-17" +tool_meta: + curr: present_review_set + next: request_review +display: + heading: "Review proposed requirements" + body: "Approve the set, request changes, or reject it." +review_set: + proposal_entry_id: "entry-review-proposal-17" +``` + +### `present_candidates` + +Exact approved shape: + +```yaml +schema: "brunch.structured_exchange.present" +v: 1 +exchange_id: string +tool_meta: + curr: present_candidates + next: request_choice +display: + heading: string + body?: markdown +candidates: + - id: string + title: string + user_rubric: + core_bet: markdown + best_fit: markdown + cost_complexity: markdown + covers_well: markdown + main_risks: markdown + lock_in_constraints: markdown + recommendation?: markdown + meta_rubric: + legibility_cost_of_knowing?: markdown + failure_modes?: markdown + coverage_range?: markdown + commitment?: markdown + graph_refs: + - node_id: string +``` + +Rules: + +- `core_bet` is the headline/thesis of the candidate-proposal unit. +- `user_rubric` is the human-readable comparison surface. +- `meta_rubric` is persisted internal reasoning trace for later capture; it is not necessarily rendered by default. +- The assistant may reason in D31-L meta-rubric axes, then derive the user rubric. +- `graph_refs` are per-candidate and consist strictly of `{ node_id: string }`. +- Do not add roles, caveats, assumptions, observations, grounding prose, or ad-hoc text to `graph_refs`. +- Avoid low/medium/high scalar ratings for cost, risk, confidence, or timeline. + +User-facing facets replace confidence/timeline/complexity/risk/verification/key-tradeoff scalar surfaces: + +- `core_bet`: why choose this option. +- `best_fit`: what you get. +- `cost_complexity`: what it costs you. +- `covers_well`: what it hits. +- `main_risks`: what it misses. +- `lock_in_constraints`: what it commits you to. +- `recommendation`: the LLM's opinion. + +Relationship to D31-L meta-rubric: + +- `legibility_cost_of_knowing`, `failure_modes`, `coverage_range`, and `commitment` are internal meta axes. +- `best_fit` derives from legibility/cost of knowing plus coverage range. +- `cost_complexity` derives from legibility/cost of knowing plus commitment. +- `covers_well` derives from coverage range. +- `main_risks` derives from failure modes plus coverage range. +- `lock_in_constraints` derives from commitment. +- `recommendation` may draw on all facets. + +## Request layer + +Request terminal outcome is a property-presence union. Exactly one of `answered`, `cancelled`, or `unavailable` must be present. + +```yaml +schema: "brunch.structured_exchange.request" +v: 1 +exchange_id: string +tool_meta: + prev: present_question | present_options | present_review_set | present_candidates + curr: request_answer | request_choice | request_choices | request_review + next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +answered: + # variant-specific payload +cancelled?: + message?: string +unavailable?: + message: string +``` + +Rules: + +- Use `comment`, not `note`, for user-authored supplementary text. +- `message` appears only under `cancelled` or `unavailable`. +- `request_answer` follows `present_question` and may lead to `capture_answer`. +- `request_choice` follows `present_options` or `present_candidates`; after candidates it may lead to `capture_candidate`. +- `request_choices` follows `present_options` and may lead to `capture_choices`. +- `request_review` follows `present_review_set` and may lead to `capture_review`. +- `request_review` supports `approve`, `request_changes`, and `reject`; `comment` is required for `request_changes`. +- `other` and `none` choices require a user `comment`. + +Variant payload examples: + +```yaml +request_answer answered: + answered: + text: "The hard part is keeping the agent and graph coherent across sessions." +``` + +```yaml +request_choice answered: + answered: + choice: + id: "local-first" + label: "Local-first app" + kind: listed + comment: "This fits the POC constraints." +``` + +```yaml +request_choices answered: + answered: + choices: + - id: "transport" + label: "Transport contract" + kind: listed + - id: "chrome" + label: "Chrome recovery" + kind: listed + comment: "These are the ones I care about before graph work." +``` + +```yaml +request_review answered: + answered: + decision: request_changes + comment: "Regenerate this with clearer non-goals." +``` + +```yaml +cancelled: + message: "User cancelled." +``` + +```yaml +unavailable: + message: "request_choices requires interactive UI." +``` + +## Capture layer + +Capture exists, but graph payloads are intentionally not designed in this schema pass. + +Minimum shape: + +```yaml +schema: "brunch.structured_exchange.capture" +v: 1 +exchange_id: string +tool_meta: + prev: request_answer | request_choice | request_choices | request_review + curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate +``` + +Rules: + +- Capture is where semantic/generative post-response work happens. +- For `present_candidates`, graph generation happens after the user chooses a candidate. +- `capture_candidate` draws on the selected candidate description, meta rubric, graph refs, selected choice, and user comment from prior transcript evidence. +- `present_candidates` may carry meta-rubric reasoning trace in details for later capture. +- `present_candidates` does not generate graph sets directly. +- Do not add ad-hoc observations to present details for later capture. +- All semantic capture happens at `capture_*`. +- Actual graph writes still route through `CommandExecutor`. +- Do not invent committed graph nodes, graph edges, LSNs, `CommandExecutor` results, assumptions, caveats, observations, or graph payload fields in capture details. + +Examples: + +```yaml +capture_answer: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "problem-frame" + tool_meta: + prev: request_answer + curr: capture_answer +``` + +```yaml +capture_choice: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "domain-shape" + tool_meta: + prev: request_choice + curr: capture_choice +``` + +```yaml +capture_choices: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "open-risks" + tool_meta: + prev: request_choices + curr: capture_choices +``` + +```yaml +capture_review: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "review-set-17" + tool_meta: + prev: request_review + curr: capture_review +``` + +```yaml +capture_candidate: + schema: "brunch.structured_exchange.capture" + v: 1 + exchange_id: "candidate-direction" + tool_meta: + prev: request_choice + curr: capture_candidate +``` + +`capture_candidate` consumes the selected candidate id from the prior `request_choice`; do not duplicate candidate, user-rubric, or meta-rubric payloads into capture details unless a later design approves that change. From c5c06f7b21bc33118db9a7d69304ce8e0ae1b031 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:34:55 +0200 Subject: [PATCH 151/164] Add structured exchange shared schemas --- memory/CARDS.md | 2 +- package-lock.json | 3 +- package.json | 3 +- .../structured-exchange-schemas.test.ts | 94 ++++++++ .../structured-exchange/schemas/shared.ts | 204 ++++++++++++++++++ 5 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/shared.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 85ea27fe..45c1122e 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -942,7 +942,7 @@ The structured-exchange schema directory contains a README that records the exac ## Card 2 — Add shared Zod primitives and JSON Schema export convention **Weight:** full scope card -**Status:** queued +**Status:** done ### Target Behavior diff --git a/package-lock.json b/package-lock.json index 226eb984..146e1a32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", "react-dom": "^19.2.6", - "ws": "^8.20.1" + "ws": "^8.20.1", + "zod": "^4.4.3" }, "bin": { "brunch-next": "bin/brunch.js" diff --git a/package.json b/package.json index 82f2961b..2aa14cf6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "@tanstack/react-router": "^1.170.6", "react": "^19.2.6", "react-dom": "^19.2.6", - "ws": "^8.20.1" + "ws": "^8.20.1", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts new file mode 100644 index 00000000..5ced64a0 --- /dev/null +++ b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" +import * as z from "zod" + +import { + zCaptureDetailsHeader, + zCaptureToolMeta, + zGraphNodeRef, + zMarkdown, + zPresentDetailsHeader, + zPresentToolMeta, + zRequestDetailsHeader, + zRequestToolMeta, +} from "../extensions/structured-exchange/schemas/shared.js" + +function expectJsonSchemaExport(schema: z.ZodType) { + expect(() => + z.toJSONSchema(schema, { unrepresentable: "throw" }), + ).not.toThrow() +} + +describe("structured exchange shared schemas", () => { + it("parses checked details headers and rejects unsupported versions", () => { + expect( + zPresentDetailsHeader.parse({ + schema: "brunch.structured_exchange.present", + v: 1, + exchange_id: "problem-frame", + }), + ).toMatchObject({ exchange_id: "problem-frame" }) + + expect(() => + zPresentDetailsHeader.parse({ + schema: "brunch.structured_exchange.present", + v: 2, + exchange_id: "problem-frame", + }), + ).toThrow() + expect(() => + zRequestDetailsHeader.parse({ + schema: "brunch.structured_exchange.request", + v: 2, + exchange_id: "problem-frame", + }), + ).toThrow() + expect(() => + zCaptureDetailsHeader.parse({ + schema: "brunch.structured_exchange.capture", + v: 2, + exchange_id: "problem-frame", + }), + ).toThrow() + }) + + it("parses shared markdown, graph refs, and tool sequencing metadata", () => { + expect(zMarkdown.parse("**markdown**")).toBe("**markdown**") + expect(zGraphNodeRef.parse({ node_id: "node-1" })).toEqual({ + node_id: "node-1", + }) + + expect( + zPresentToolMeta.parse({ + curr: "present_options", + next: "request_choices", + }), + ).toEqual({ curr: "present_options", next: "request_choices" }) + expect( + zRequestToolMeta.parse({ + prev: "present_candidates", + curr: "request_choice", + next: "capture_candidate", + }), + ).toEqual({ + prev: "present_candidates", + curr: "request_choice", + next: "capture_candidate", + }) + expect( + zCaptureToolMeta.parse({ + prev: "request_choice", + curr: "capture_candidate", + }), + ).toEqual({ prev: "request_choice", curr: "capture_candidate" }) + }) + + it("exports representative shared schemas to JSON Schema", () => { + expectJsonSchemaExport(zPresentDetailsHeader) + expectJsonSchemaExport(zRequestDetailsHeader) + expectJsonSchemaExport(zCaptureDetailsHeader) + expectJsonSchemaExport(zGraphNodeRef) + expectJsonSchemaExport(zPresentToolMeta) + expectJsonSchemaExport(zRequestToolMeta) + expectJsonSchemaExport(zCaptureToolMeta) + }) +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/shared.ts b/src/tui-client/.pi/extensions/structured-exchange/schemas/shared.ts new file mode 100644 index 00000000..cb5f4f1c --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/shared.ts @@ -0,0 +1,204 @@ +import * as z from "zod" + +export const STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA = + "brunch.structured_exchange.present" as const +export const STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA = + "brunch.structured_exchange.request" as const +export const STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA = + "brunch.structured_exchange.capture" as const +export const STRUCTURED_EXCHANGE_DETAILS_VERSION = 1 as const + +export const zMarkdown = z.string() +export type Markdown = z.infer<typeof zMarkdown> +export const MarkdownSchema = z.toJSONSchema(zMarkdown, { + unrepresentable: "throw", +}) + +export const zGraphNodeRef = z.object({ node_id: z.string().min(1) }).strict() +export type GraphNodeRef = z.infer<typeof zGraphNodeRef> +export const GraphNodeRefSchema = z.toJSONSchema(zGraphNodeRef, { + unrepresentable: "throw", +}) + +export const zPresentToolName = z.enum([ + "present_question", + "present_options", + "present_review_set", + "present_candidates", +]) +export type PresentToolName = z.infer<typeof zPresentToolName> +export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { + unrepresentable: "throw", +}) + +export const zRequestToolName = z.enum([ + "request_answer", + "request_choice", + "request_choices", + "request_review", +]) +export type RequestToolName = z.infer<typeof zRequestToolName> +export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { + unrepresentable: "throw", +}) + +export const zCaptureToolName = z.enum([ + "capture_answer", + "capture_choice", + "capture_choices", + "capture_review", + "capture_candidate", +]) +export type CaptureToolName = z.infer<typeof zCaptureToolName> +export const CaptureToolNameSchema = z.toJSONSchema(zCaptureToolName, { + unrepresentable: "throw", +}) + +export const zPresentDetailsHeader = z + .object({ + schema: z.literal(STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA), + v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), + exchange_id: z.string().min(1), + }) + .strict() +export type PresentDetailsHeader = z.infer<typeof zPresentDetailsHeader> +export const PresentDetailsHeaderSchema = z.toJSONSchema( + zPresentDetailsHeader, + { unrepresentable: "throw" }, +) + +export const zRequestDetailsHeader = z + .object({ + schema: z.literal(STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA), + v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), + exchange_id: z.string().min(1), + }) + .strict() +export type RequestDetailsHeader = z.infer<typeof zRequestDetailsHeader> +export const RequestDetailsHeaderSchema = z.toJSONSchema( + zRequestDetailsHeader, + { unrepresentable: "throw" }, +) + +export const zCaptureDetailsHeader = z + .object({ + schema: z.literal(STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA), + v: z.literal(STRUCTURED_EXCHANGE_DETAILS_VERSION), + exchange_id: z.string().min(1), + }) + .strict() +export type CaptureDetailsHeader = z.infer<typeof zCaptureDetailsHeader> +export const CaptureDetailsHeaderSchema = z.toJSONSchema( + zCaptureDetailsHeader, + { unrepresentable: "throw" }, +) + +export const zPresentToolMeta = z.discriminatedUnion("curr", [ + z + .object({ + curr: z.literal("present_question"), + next: z.literal("request_answer"), + }) + .strict(), + z + .object({ + curr: z.literal("present_options"), + next: z.enum(["request_choice", "request_choices"]), + }) + .strict(), + z + .object({ + curr: z.literal("present_review_set"), + next: z.literal("request_review"), + }) + .strict(), + z + .object({ + curr: z.literal("present_candidates"), + next: z.literal("request_choice"), + }) + .strict(), +]) +export type PresentToolMeta = z.infer<typeof zPresentToolMeta> +export const PresentToolMetaSchema = z.toJSONSchema(zPresentToolMeta, { + unrepresentable: "throw", +}) + +export const zRequestToolMeta = z.union([ + z + .object({ + prev: z.literal("present_question"), + curr: z.literal("request_answer"), + next: z.literal("capture_answer").optional(), + }) + .strict(), + z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choice"), + next: z.literal("capture_choice").optional(), + }) + .strict(), + z + .object({ + prev: z.literal("present_candidates"), + curr: z.literal("request_choice"), + next: z.literal("capture_candidate").optional(), + }) + .strict(), + z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choices"), + next: z.literal("capture_choices").optional(), + }) + .strict(), + z + .object({ + prev: z.literal("present_review_set"), + curr: z.literal("request_review"), + next: z.literal("capture_review").optional(), + }) + .strict(), +]) +export type RequestToolMeta = z.infer<typeof zRequestToolMeta> +export const RequestToolMetaSchema = z.toJSONSchema(zRequestToolMeta, { + unrepresentable: "throw", +}) + +export const zCaptureToolMeta = z.union([ + z + .object({ + prev: z.literal("request_answer"), + curr: z.literal("capture_answer"), + }) + .strict(), + z + .object({ + prev: z.literal("request_choice"), + curr: z.literal("capture_choice"), + }) + .strict(), + z + .object({ + prev: z.literal("request_choices"), + curr: z.literal("capture_choices"), + }) + .strict(), + z + .object({ + prev: z.literal("request_review"), + curr: z.literal("capture_review"), + }) + .strict(), + z + .object({ + prev: z.literal("request_choice"), + curr: z.literal("capture_candidate"), + }) + .strict(), +]) +export type CaptureToolMeta = z.infer<typeof zCaptureToolMeta> +export const CaptureToolMetaSchema = z.toJSONSchema(zCaptureToolMeta, { + unrepresentable: "throw", +}) From 549593b1e514d428fdf5fcca7d938ca519f3f922 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:36:57 +0200 Subject: [PATCH 152/164] Add structured exchange present schemas --- memory/CARDS.md | 2 +- .../structured-exchange-schemas.test.ts | 148 ++++++++++++++++ .../structured-exchange/schemas/present.ts | 161 ++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/present.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 45c1122e..5ad1c484 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -970,7 +970,7 @@ The schema layer exposes shared Zod primitives for the exact shared vocabulary c ## Card 3 — Add present detail Zod schemas **Weight:** full scope card -**Status:** queued +**Status:** done ### Target Behavior diff --git a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts index 5ced64a0..30cce0c6 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest" import * as z from "zod" +import { + zPresentCandidatesDetails, + zPresentDetails, + zPresentOptionsDetails, + zPresentQuestionDetails, + zPresentReviewSetDetails, +} from "../extensions/structured-exchange/schemas/present.js" import { zCaptureDetailsHeader, zCaptureToolMeta, @@ -92,3 +99,144 @@ describe("structured exchange shared schemas", () => { expectJsonSchemaExport(zCaptureToolMeta) }) }) + +describe("structured exchange present schemas", () => { + const candidateDetails = { + schema: "brunch.structured_exchange.present", + v: 1, + exchange_id: "candidate-direction", + tool_meta: { curr: "present_candidates", next: "request_choice" }, + display: { + heading: "Which direction should we take?", + body: "Pick one candidate.", + }, + candidates: [ + { + id: "candidate-local-workbench", + title: "Local workbench for graph-native specs", + user_rubric: { + core_bet: "Make local graph work the thesis.", + best_fit: "Keeps the POC focused.", + cost_complexity: "Requires owning local state clearly.", + covers_well: "Covers chrome, transcript, and graph coherence.", + main_risks: "Does not solve cloud collaboration.", + lock_in_constraints: "Commits to local-first semantics.", + recommendation: "Choose this for the POC.", + }, + meta_rubric: { + legibility_cost_of_knowing: "Easy to inspect locally.", + failure_modes: "May under-test multi-user cases.", + coverage_range: "Strong for current assumptions.", + commitment: "Defers cloud concerns.", + }, + graph_refs: [{ node_id: "node-1" }], + }, + ], + } + + it("parses conservative present variants and exact candidate details", () => { + expect( + zPresentQuestionDetails.parse({ + schema: "brunch.structured_exchange.present", + v: 1, + exchange_id: "problem-frame", + tool_meta: { curr: "present_question", next: "request_answer" }, + display: { + heading: "What problem are we solving first?", + body: "Name the pain.", + preface: "We need the user-facing pull.", + }, + }), + ).toMatchObject({ tool_meta: { curr: "present_question" } }) + + expect( + zPresentOptionsDetails.parse({ + schema: "brunch.structured_exchange.present", + v: 1, + exchange_id: "domain-shape", + tool_meta: { curr: "present_options", next: "request_choices" }, + display: { heading: "Which risks should stay visible?" }, + options: [ + { + id: "transport", + content: "Transport contract", + rationale: "Public RPC is a product seam.", + }, + ], + }), + ).toMatchObject({ tool_meta: { next: "request_choices" } }) + + expect( + zPresentReviewSetDetails.parse({ + schema: "brunch.structured_exchange.present", + v: 1, + exchange_id: "review-set-17", + tool_meta: { curr: "present_review_set", next: "request_review" }, + display: { heading: "Review proposed requirements" }, + review_set: { proposal_entry_id: "entry-review-proposal-17" }, + }), + ).toMatchObject({ + review_set: { proposal_entry_id: "entry-review-proposal-17" }, + }) + + expect(zPresentCandidatesDetails.parse(candidateDetails)).toMatchObject({ + candidates: [{ graph_refs: [{ node_id: "node-1" }] }], + }) + expect(zPresentDetails.parse(candidateDetails)).toMatchObject({ + tool_meta: { curr: "present_candidates" }, + }) + }) + + it("rejects candidate graph refs and rubric drift fields", () => { + expect(() => + zPresentCandidatesDetails.parse({ + ...candidateDetails, + candidates: [ + { + ...candidateDetails.candidates[0], + graph_refs: [{ node_id: "node-1", role: "supporting" }], + }, + ], + }), + ).toThrow() + + expect(() => + zPresentCandidatesDetails.parse({ + ...candidateDetails, + candidates: [ + { + ...candidateDetails.candidates[0], + user_rubric: { + ...candidateDetails.candidates[0].user_rubric, + confidence: "high", + }, + }, + ], + }), + ).toThrow() + }) + + it("rejects retired present-side control fields", () => { + for (const field of [ + "phase", + "status", + "next_required", + "schema_version", + ] as const) { + expect(() => + zPresentCandidatesDetails.parse({ + ...candidateDetails, + [field]: field === "status" ? "presented" : true, + }), + ).toThrow() + } + }) + + it("exports present schemas to JSON Schema", () => { + expectJsonSchemaExport(zPresentQuestionDetails) + expectJsonSchemaExport(zPresentOptionsDetails) + expectJsonSchemaExport(zPresentReviewSetDetails) + expectJsonSchemaExport(zPresentCandidatesDetails) + expectJsonSchemaExport(zPresentDetails) + }) +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/present.ts b/src/tui-client/.pi/extensions/structured-exchange/schemas/present.ts new file mode 100644 index 00000000..b38fd01d --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/present.ts @@ -0,0 +1,161 @@ +import * as z from "zod" + +import { zGraphNodeRef, zMarkdown, zPresentDetailsHeader } from "./shared.js" + +export const zPresentDisplay = z + .object({ + heading: z.string().min(1), + body: zMarkdown.optional(), + preface: zMarkdown.optional(), + }) + .strict() +export type PresentDisplay = z.infer<typeof zPresentDisplay> +export const PresentDisplaySchema = z.toJSONSchema(zPresentDisplay, { + unrepresentable: "throw", +}) + +export const zPresentQuestionDetails = zPresentDetailsHeader + .extend({ + tool_meta: z + .object({ + curr: z.literal("present_question"), + next: z.literal("request_answer"), + }) + .strict(), + display: zPresentDisplay, + }) + .strict() +export type PresentQuestionDetails = z.infer<typeof zPresentQuestionDetails> +export const PresentQuestionDetailsSchema = z.toJSONSchema( + zPresentQuestionDetails, + { unrepresentable: "throw" }, +) + +export const zPresentOption = z + .object({ + id: z.string().min(1), + content: zMarkdown, + rationale: zMarkdown.optional(), + }) + .strict() +export type PresentOption = z.infer<typeof zPresentOption> +export const PresentOptionSchema = z.toJSONSchema(zPresentOption, { + unrepresentable: "throw", +}) + +export const zPresentOptionsDetails = zPresentDetailsHeader + .extend({ + tool_meta: z + .object({ + curr: z.literal("present_options"), + next: z.enum(["request_choice", "request_choices"]), + }) + .strict(), + display: zPresentDisplay, + options: z.array(zPresentOption).min(1), + }) + .strict() +export type PresentOptionsDetails = z.infer<typeof zPresentOptionsDetails> +export const PresentOptionsDetailsSchema = z.toJSONSchema( + zPresentOptionsDetails, + { unrepresentable: "throw" }, +) + +export const zPresentReviewSetDetails = zPresentDetailsHeader + .extend({ + tool_meta: z + .object({ + curr: z.literal("present_review_set"), + next: z.literal("request_review"), + }) + .strict(), + display: zPresentDisplay, + review_set: z + .object({ + proposal_entry_id: z.string().min(1), + }) + .strict(), + }) + .strict() +export type PresentReviewSetDetails = z.infer<typeof zPresentReviewSetDetails> +export const PresentReviewSetDetailsSchema = z.toJSONSchema( + zPresentReviewSetDetails, + { unrepresentable: "throw" }, +) + +export const zCandidateUserRubric = z + .object({ + core_bet: zMarkdown, + best_fit: zMarkdown, + cost_complexity: zMarkdown, + covers_well: zMarkdown, + main_risks: zMarkdown, + lock_in_constraints: zMarkdown, + recommendation: zMarkdown.optional(), + }) + .strict() +export type CandidateUserRubric = z.infer<typeof zCandidateUserRubric> +export const CandidateUserRubricSchema = z.toJSONSchema(zCandidateUserRubric, { + unrepresentable: "throw", +}) + +export const zCandidateMetaRubric = z + .object({ + legibility_cost_of_knowing: zMarkdown.optional(), + failure_modes: zMarkdown.optional(), + coverage_range: zMarkdown.optional(), + commitment: zMarkdown.optional(), + }) + .strict() +export type CandidateMetaRubric = z.infer<typeof zCandidateMetaRubric> +export const CandidateMetaRubricSchema = z.toJSONSchema(zCandidateMetaRubric, { + unrepresentable: "throw", +}) + +export const zPresentedCandidate = z + .object({ + id: z.string().min(1), + title: z.string().min(1), + user_rubric: zCandidateUserRubric, + meta_rubric: zCandidateMetaRubric, + graph_refs: z.array(zGraphNodeRef), + }) + .strict() +export type PresentedCandidate = z.infer<typeof zPresentedCandidate> +export const PresentedCandidateSchema = z.toJSONSchema(zPresentedCandidate, { + unrepresentable: "throw", +}) + +export const zPresentCandidatesDetails = zPresentDetailsHeader + .extend({ + tool_meta: z + .object({ + curr: z.literal("present_candidates"), + next: z.literal("request_choice"), + }) + .strict(), + display: z + .object({ + heading: z.string().min(1), + body: zMarkdown.optional(), + }) + .strict(), + candidates: z.array(zPresentedCandidate).min(1), + }) + .strict() +export type PresentCandidatesDetails = z.infer<typeof zPresentCandidatesDetails> +export const PresentCandidatesDetailsSchema = z.toJSONSchema( + zPresentCandidatesDetails, + { unrepresentable: "throw" }, +) + +export const zPresentDetails = z.union([ + zPresentQuestionDetails, + zPresentOptionsDetails, + zPresentReviewSetDetails, + zPresentCandidatesDetails, +]) +export type PresentDetails = z.infer<typeof zPresentDetails> +export const PresentDetailsSchema = z.toJSONSchema(zPresentDetails, { + unrepresentable: "throw", +}) From 7e097b3e27027aafbb828e011eb22d83ec7d2ab0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:39:14 +0200 Subject: [PATCH 153/164] Add structured exchange request schemas --- memory/CARDS.md | 2 +- .../structured-exchange-schemas.test.ts | 228 ++++++++++++ .../structured-exchange/schemas/request.ts | 332 ++++++++++++++++++ 3 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/request.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 5ad1c484..6bdb64af 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -999,7 +999,7 @@ The schema layer models the present-side details vocabulary captured above witho ## Card 4 — Add request detail Zod schemas **Weight:** full scope card -**Status:** queued +**Status:** done ### Target Behavior diff --git a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts index 30cce0c6..93103820 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts @@ -8,6 +8,13 @@ import { zPresentQuestionDetails, zPresentReviewSetDetails, } from "../extensions/structured-exchange/schemas/present.js" +import { + zRequestAnswerDetails, + zRequestChoiceDetails, + zRequestChoicesDetails, + zRequestDetails, + zRequestReviewDetails, +} from "../extensions/structured-exchange/schemas/request.js" import { zCaptureDetailsHeader, zCaptureToolMeta, @@ -240,3 +247,224 @@ describe("structured exchange present schemas", () => { expectJsonSchemaExport(zPresentDetails) }) }) + +describe("structured exchange request schemas", () => { + const answerBase = { + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "problem-frame", + tool_meta: { + prev: "present_question", + curr: "request_answer", + next: "capture_answer", + }, + } + + it("parses answered, cancelled, and unavailable outcomes", () => { + expect( + zRequestAnswerDetails.parse({ + ...answerBase, + answered: { + text: "The hard part is coherence across sessions.", + }, + }), + ).toMatchObject({ + answered: { text: "The hard part is coherence across sessions." }, + }) + + expect( + zRequestAnswerDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "problem-frame", + tool_meta: { prev: "present_question", curr: "request_answer" }, + cancelled: { message: "User cancelled." }, + }), + ).toMatchObject({ cancelled: { message: "User cancelled." } }) + + expect( + zRequestAnswerDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "problem-frame", + tool_meta: { prev: "present_question", curr: "request_answer" }, + unavailable: { message: "request_answer requires interactive UI." }, + }), + ).toMatchObject({ + unavailable: { message: "request_answer requires interactive UI." }, + }) + }) + + it("rejects missing or multiple terminal outcomes", () => { + expect(() => zRequestAnswerDetails.parse(answerBase)).toThrow() + expect(() => + zRequestAnswerDetails.parse({ + ...answerBase, + answered: { text: "Yes." }, + cancelled: { message: "User cancelled." }, + }), + ).toThrow() + }) + + it("keeps comment on answered payloads and message on terminal runtime payloads", () => { + expect( + zRequestChoiceDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "domain-shape", + tool_meta: { + prev: "present_options", + curr: "request_choice", + next: "capture_choice", + }, + answered: { + choice: { + id: "local-first", + label: "Local-first app", + kind: "listed", + }, + comment: "This fits the POC constraints.", + }, + }), + ).toMatchObject({ answered: { comment: "This fits the POC constraints." } }) + + expect(() => + zRequestChoiceDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "domain-shape", + tool_meta: { prev: "present_options", curr: "request_choice" }, + cancelled: { message: "User cancelled." }, + comment: "human text in the wrong place", + }), + ).toThrow() + + expect(() => + zRequestChoiceDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "domain-shape", + tool_meta: { prev: "present_options", curr: "request_choice" }, + answered: { + choice: { + id: "local-first", + label: "Local-first app", + kind: "listed", + }, + message: "runtime text in the wrong place", + }, + }), + ).toThrow() + }) + + it("supports candidate choices and requires comments for other or none choices", () => { + expect( + zRequestChoiceDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "candidate-direction", + tool_meta: { + prev: "present_candidates", + curr: "request_choice", + next: "capture_candidate", + }, + answered: { + choice: { + id: "candidate-local-workbench", + label: "Local workbench for graph-native specs", + kind: "listed", + }, + }, + }), + ).toMatchObject({ tool_meta: { prev: "present_candidates" } }) + + expect(() => + zRequestChoiceDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "domain-shape", + tool_meta: { prev: "present_options", curr: "request_choice" }, + answered: { + choice: { id: "none", label: "None of these", kind: "none" }, + }, + }), + ).toThrow() + }) + + it("parses multiple choices and requires comments for other or none selections", () => { + expect( + zRequestChoicesDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "open-risks", + tool_meta: { + prev: "present_options", + curr: "request_choices", + next: "capture_choices", + }, + answered: { + choices: [ + { id: "transport", label: "Transport contract", kind: "listed" }, + { + id: "other", + label: "Schema source-of-truth drift", + kind: "other", + }, + ], + comment: "Keep schema drift visible.", + }, + }), + ).toMatchObject({ + answered: { choices: [{ id: "transport" }, { id: "other" }] }, + }) + + expect(() => + zRequestChoicesDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "open-risks", + tool_meta: { prev: "present_options", curr: "request_choices" }, + answered: { + choices: [{ id: "none", label: "None of these", kind: "none" }], + }, + }), + ).toThrow() + }) + + it("requires a comment for request_changes review decisions", () => { + expect( + zRequestReviewDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "review-set-17", + tool_meta: { + prev: "present_review_set", + curr: "request_review", + next: "capture_review", + }, + answered: { + decision: "approve", + comment: "This is ready to commit.", + }, + }), + ).toMatchObject({ answered: { decision: "approve" } }) + + expect(() => + zRequestReviewDetails.parse({ + schema: "brunch.structured_exchange.request", + v: 1, + exchange_id: "review-set-17", + tool_meta: { prev: "present_review_set", curr: "request_review" }, + answered: { decision: "request_changes" }, + }), + ).toThrow() + }) + + it("exports request schemas to JSON Schema", () => { + expectJsonSchemaExport(zRequestAnswerDetails) + expectJsonSchemaExport(zRequestChoiceDetails) + expectJsonSchemaExport(zRequestChoicesDetails) + expectJsonSchemaExport(zRequestReviewDetails) + expectJsonSchemaExport(zRequestDetails) + }) +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/request.ts b/src/tui-client/.pi/extensions/structured-exchange/schemas/request.ts new file mode 100644 index 00000000..aa9d06f4 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/request.ts @@ -0,0 +1,332 @@ +import * as z from "zod" + +import { zMarkdown, zRequestDetailsHeader } from "./shared.js" + +export const zCancelledOutcome = z + .object({ + cancelled: z + .object({ + message: z.string().min(1).optional(), + }) + .strict(), + }) + .strict() +export type CancelledOutcome = z.infer<typeof zCancelledOutcome> +export const CancelledOutcomeSchema = z.toJSONSchema(zCancelledOutcome, { + unrepresentable: "throw", +}) + +export const zUnavailableOutcome = z + .object({ + unavailable: z + .object({ + message: z.string().min(1), + }) + .strict(), + }) + .strict() +export type UnavailableOutcome = z.infer<typeof zUnavailableOutcome> +export const UnavailableOutcomeSchema = z.toJSONSchema(zUnavailableOutcome, { + unrepresentable: "throw", +}) + +export const zChoiceKind = z.enum(["listed", "other", "none"]) +export type ChoiceKind = z.infer<typeof zChoiceKind> +export const ChoiceKindSchema = z.toJSONSchema(zChoiceKind, { + unrepresentable: "throw", +}) + +export const zSelectedChoice = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + kind: zChoiceKind, + }) + .strict() +export type SelectedChoice = z.infer<typeof zSelectedChoice> +export const SelectedChoiceSchema = z.toJSONSchema(zSelectedChoice, { + unrepresentable: "throw", +}) + +const zChoiceAnsweredPayload = z + .object({ + choice: zSelectedChoice, + comment: zMarkdown.optional(), + }) + .strict() + .superRefine((payload, ctx) => { + if ( + (payload.choice.kind === "other" || payload.choice.kind === "none") && + (!payload.comment || payload.comment.trim().length === 0) + ) { + ctx.addIssue({ + code: "custom", + path: ["comment"], + message: "other and none choices require comment", + }) + } + }) +export const zRequestChoiceAnswered = zChoiceAnsweredPayload +export type RequestChoiceAnswered = z.infer<typeof zRequestChoiceAnswered> + +const zChoicesAnsweredPayload = z + .object({ + choices: z.array(zSelectedChoice).min(1), + comment: zMarkdown.optional(), + }) + .strict() + .superRefine((payload, ctx) => { + if ( + payload.choices.some( + (choice) => choice.kind === "other" || choice.kind === "none", + ) && + (!payload.comment || payload.comment.trim().length === 0) + ) { + ctx.addIssue({ + code: "custom", + path: ["comment"], + message: "other and none choices require comment", + }) + } + }) +export const zRequestChoicesAnswered = zChoicesAnsweredPayload +export type RequestChoicesAnswered = z.infer<typeof zRequestChoicesAnswered> + +export const zRequestAnswerDetails = z.union([ + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_question"), + curr: z.literal("request_answer"), + next: z.literal("capture_answer").optional(), + }) + .strict(), + answered: z + .object({ + text: zMarkdown, + }) + .strict(), + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_question"), + curr: z.literal("request_answer"), + }) + .strict(), + cancelled: zCancelledOutcome.shape.cancelled, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_question"), + curr: z.literal("request_answer"), + }) + .strict(), + unavailable: zUnavailableOutcome.shape.unavailable, + }) + .strict(), +]) +export type RequestAnswerDetails = z.infer<typeof zRequestAnswerDetails> +export const RequestAnswerDetailsSchema = z.toJSONSchema( + zRequestAnswerDetails, + { unrepresentable: "throw" }, +) + +export const zRequestChoiceDetails = z.union([ + zRequestDetailsHeader + .extend({ + tool_meta: z.union([ + z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choice"), + next: z.literal("capture_choice").optional(), + }) + .strict(), + z + .object({ + prev: z.literal("present_candidates"), + curr: z.literal("request_choice"), + next: z.literal("capture_candidate").optional(), + }) + .strict(), + ]), + answered: zRequestChoiceAnswered, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z.union([ + z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choice"), + }) + .strict(), + z + .object({ + prev: z.literal("present_candidates"), + curr: z.literal("request_choice"), + }) + .strict(), + ]), + cancelled: zCancelledOutcome.shape.cancelled, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z.union([ + z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choice"), + }) + .strict(), + z + .object({ + prev: z.literal("present_candidates"), + curr: z.literal("request_choice"), + }) + .strict(), + ]), + unavailable: zUnavailableOutcome.shape.unavailable, + }) + .strict(), +]) +export type RequestChoiceDetails = z.infer<typeof zRequestChoiceDetails> +export const RequestChoiceDetailsSchema = z.toJSONSchema( + zRequestChoiceDetails, + { unrepresentable: "throw" }, +) + +export const zRequestChoicesDetails = z.union([ + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choices"), + next: z.literal("capture_choices").optional(), + }) + .strict(), + answered: zRequestChoicesAnswered, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choices"), + }) + .strict(), + cancelled: zCancelledOutcome.shape.cancelled, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_options"), + curr: z.literal("request_choices"), + }) + .strict(), + unavailable: zUnavailableOutcome.shape.unavailable, + }) + .strict(), +]) +export type RequestChoicesDetails = z.infer<typeof zRequestChoicesDetails> +export const RequestChoicesDetailsSchema = z.toJSONSchema( + zRequestChoicesDetails, + { unrepresentable: "throw" }, +) + +export const zReviewDecision = z.enum(["approve", "request_changes", "reject"]) +export type ReviewDecision = z.infer<typeof zReviewDecision> +export const ReviewDecisionSchema = z.toJSONSchema(zReviewDecision, { + unrepresentable: "throw", +}) + +const zReviewAnsweredPayload = z.union([ + z + .object({ + decision: z.literal("approve"), + comment: zMarkdown.optional(), + }) + .strict(), + z + .object({ + decision: z.literal("request_changes"), + comment: zMarkdown.refine((value) => value.trim().length > 0, { + message: "request_changes requires comment", + }), + }) + .strict(), + z + .object({ + decision: z.literal("reject"), + comment: zMarkdown.optional(), + }) + .strict(), +]) +export const zRequestReviewAnswered = zReviewAnsweredPayload +export type RequestReviewAnswered = z.infer<typeof zRequestReviewAnswered> + +export const zRequestReviewDetails = z.union([ + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_review_set"), + curr: z.literal("request_review"), + next: z.literal("capture_review").optional(), + }) + .strict(), + answered: zRequestReviewAnswered, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_review_set"), + curr: z.literal("request_review"), + }) + .strict(), + cancelled: zCancelledOutcome.shape.cancelled, + }) + .strict(), + zRequestDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("present_review_set"), + curr: z.literal("request_review"), + }) + .strict(), + unavailable: zUnavailableOutcome.shape.unavailable, + }) + .strict(), +]) +export type RequestReviewDetails = z.infer<typeof zRequestReviewDetails> +export const RequestReviewDetailsSchema = z.toJSONSchema( + zRequestReviewDetails, + { unrepresentable: "throw" }, +) + +export const zRequestDetails = z.union([ + zRequestAnswerDetails, + zRequestChoiceDetails, + zRequestChoicesDetails, + zRequestReviewDetails, +]) +export type RequestDetails = z.infer<typeof zRequestDetails> +export const RequestDetailsSchema = z.toJSONSchema(zRequestDetails, { + unrepresentable: "throw", +}) From 52f9a966cf48a48c63508e32363626a7b6b4dafa Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:40:21 +0200 Subject: [PATCH 154/164] Add minimal structured exchange capture schemas --- memory/CARDS.md | 2 +- .../structured-exchange-schemas.test.ts | 89 +++++++++++++++++ .../structured-exchange/schemas/capture.ts | 95 +++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/capture.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 6bdb64af..106cd784 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -1031,7 +1031,7 @@ The schema layer models request-side details as exactly-one property-presence te ## Card 5 — Add capture detail Zod schemas at the agreed minimum **Weight:** full scope card -**Status:** queued +**Status:** done ### Target Behavior diff --git a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts index 93103820..c4483d7e 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest" import * as z from "zod" +import { + zCaptureAnswerDetails, + zCaptureCandidateDetails, + zCaptureChoiceDetails, + zCaptureChoicesDetails, + zCaptureDetails, + zCaptureReviewDetails, +} from "../extensions/structured-exchange/schemas/capture.js" import { zPresentCandidatesDetails, zPresentDetails, @@ -468,3 +476,84 @@ describe("structured exchange request schemas", () => { expectJsonSchemaExport(zRequestDetails) }) }) + +describe("structured exchange capture schemas", () => { + it("parses the agreed minimal capture variants", () => { + expect( + zCaptureAnswerDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "problem-frame", + tool_meta: { prev: "request_answer", curr: "capture_answer" }, + }), + ).toMatchObject({ tool_meta: { curr: "capture_answer" } }) + + expect( + zCaptureChoiceDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "domain-shape", + tool_meta: { prev: "request_choice", curr: "capture_choice" }, + }), + ).toMatchObject({ tool_meta: { curr: "capture_choice" } }) + + expect( + zCaptureChoicesDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "open-risks", + tool_meta: { prev: "request_choices", curr: "capture_choices" }, + }), + ).toMatchObject({ tool_meta: { curr: "capture_choices" } }) + + expect( + zCaptureReviewDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "review-set-17", + tool_meta: { prev: "request_review", curr: "capture_review" }, + }), + ).toMatchObject({ tool_meta: { curr: "capture_review" } }) + + expect( + zCaptureCandidateDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "candidate-direction", + tool_meta: { prev: "request_choice", curr: "capture_candidate" }, + }), + ).toMatchObject({ tool_meta: { curr: "capture_candidate" } }) + }) + + it("rejects graph payloads and analysis/provenance fields", () => { + for (const field of [ + "committed_graph_nodes", + "graph_edges", + "lsn", + "command_result", + "assumptions", + "caveats", + "observations", + "selected_candidate_id", + ] as const) { + expect(() => + zCaptureCandidateDetails.parse({ + schema: "brunch.structured_exchange.capture", + v: 1, + exchange_id: "candidate-direction", + tool_meta: { prev: "request_choice", curr: "capture_candidate" }, + [field]: field, + }), + ).toThrow() + } + }) + + it("exports capture schemas to JSON Schema", () => { + expectJsonSchemaExport(zCaptureAnswerDetails) + expectJsonSchemaExport(zCaptureChoiceDetails) + expectJsonSchemaExport(zCaptureChoicesDetails) + expectJsonSchemaExport(zCaptureReviewDetails) + expectJsonSchemaExport(zCaptureCandidateDetails) + expectJsonSchemaExport(zCaptureDetails) + }) +}) diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/capture.ts b/src/tui-client/.pi/extensions/structured-exchange/schemas/capture.ts new file mode 100644 index 00000000..faeb3517 --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/capture.ts @@ -0,0 +1,95 @@ +import * as z from "zod" + +import { zCaptureDetailsHeader } from "./shared.js" + +export const zCaptureAnswerDetails = zCaptureDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("request_answer"), + curr: z.literal("capture_answer"), + }) + .strict(), + }) + .strict() +export type CaptureAnswerDetails = z.infer<typeof zCaptureAnswerDetails> +export const CaptureAnswerDetailsSchema = z.toJSONSchema( + zCaptureAnswerDetails, + { unrepresentable: "throw" }, +) + +export const zCaptureChoiceDetails = zCaptureDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("request_choice"), + curr: z.literal("capture_choice"), + }) + .strict(), + }) + .strict() +export type CaptureChoiceDetails = z.infer<typeof zCaptureChoiceDetails> +export const CaptureChoiceDetailsSchema = z.toJSONSchema( + zCaptureChoiceDetails, + { unrepresentable: "throw" }, +) + +export const zCaptureChoicesDetails = zCaptureDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("request_choices"), + curr: z.literal("capture_choices"), + }) + .strict(), + }) + .strict() +export type CaptureChoicesDetails = z.infer<typeof zCaptureChoicesDetails> +export const CaptureChoicesDetailsSchema = z.toJSONSchema( + zCaptureChoicesDetails, + { unrepresentable: "throw" }, +) + +export const zCaptureReviewDetails = zCaptureDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("request_review"), + curr: z.literal("capture_review"), + }) + .strict(), + }) + .strict() +export type CaptureReviewDetails = z.infer<typeof zCaptureReviewDetails> +export const CaptureReviewDetailsSchema = z.toJSONSchema( + zCaptureReviewDetails, + { unrepresentable: "throw" }, +) + +export const zCaptureCandidateDetails = zCaptureDetailsHeader + .extend({ + tool_meta: z + .object({ + prev: z.literal("request_choice"), + curr: z.literal("capture_candidate"), + }) + .strict(), + }) + .strict() +export type CaptureCandidateDetails = z.infer<typeof zCaptureCandidateDetails> +export const CaptureCandidateDetailsSchema = z.toJSONSchema( + zCaptureCandidateDetails, + { unrepresentable: "throw" }, +) + +export const zCaptureDetails = z.union([ + zCaptureAnswerDetails, + zCaptureChoiceDetails, + zCaptureChoicesDetails, + zCaptureReviewDetails, + zCaptureCandidateDetails, +]) +export type CaptureDetails = z.infer<typeof zCaptureDetails> +export const CaptureDetailsSchema = z.toJSONSchema(zCaptureDetails, { + unrepresentable: "throw", +}) From a016b88f5af2b17821e91a5c06eca5f3b7b93a9d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 08:41:46 +0200 Subject: [PATCH 155/164] Export structured exchange schema surface --- memory/CARDS.md | 1084 ----------------- memory/PLAN.md | 4 +- .../structured-exchange-schemas.test.ts | 22 +- .../structured-exchange/schemas/README.md | 4 + .../structured-exchange/schemas/index.ts | 4 + 5 files changed, 18 insertions(+), 1100 deletions(-) delete mode 100644 memory/CARDS.md create mode 100644 src/tui-client/.pi/extensions/structured-exchange/schemas/index.ts diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 106cd784..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,1084 +0,0 @@ -<!-- CARDS.md — temporary scope-card queue inside one frontier item. - Created by ln-scope. Delete when exhausted or superseded. - Frontier boundary: pi-ui-extension-patterns / FE-744. ---> - -# Structured-exchange schema authoring queue - -## Orientation - -- **Containing seam:** `src/tui-client/.pi/extensions/structured-exchange/`, inside the FE-744 `pi-ui-extension-patterns` frontier. -- **Current durable change:** `memory/SPEC.md` D41-L now permits Zod v4 as a product/protocol schema source when JSON Schema export is proven; TypeBox remains valid for direct JSON-Schema-shaped seams. -- **Main open risk:** schema authoring can drift from the carefully designed thread model by adding plausible fields, changing names, or prematurely filling underspecified areas. -- **Cross-cutting obligations:** preserve Pi transcript truth (`present_* -> request_* -> capture_*` toolResult details), classify by typed details rather than tool name alone, keep public/Pi boundaries JSON-Schema-exportable, and route future graph writes through `CommandExecutor` only. - -## Queue discipline - -These cards stay inside the existing FE-744 frontier and branch. They do not create new Linear issues or branches. Build in order. - -**Strict no-drift rule:** implement exactly the model captured below. Do not add fields because they seem useful. If implementation pressure suggests a new field, stop and ask; do not improvise. - -## Exact captured design from this thread - -This section is the volatile handoff. Treat it as binding for the build. - -### Schema naming - -User convention: - -```ts -import * as z from "zod" - -// schema value / Zod source runtime parser -const zPresentCandidatesDetails = z.object({}) - -// inferred type -type PresentCandidatesDetails = z.infer<typeof zPresentCandidatesDetails> - -// parsing -const candidateDetails = zPresentCandidatesDetails.parse({}) - -// JSON schema conversion -const PresentCandidatesDetailsSchema = z.toJSONSchema(zPresentCandidatesDetails) -``` - -Rules: - -- Do **not** name Zod source values `*Schema`. -- Zod source values use `z` prefix: `zPresentCandidatesDetails`. -- Inferred TS types use the bare domain name: `PresentCandidatesDetails`. -- `*Schema` suffix means “this is JSON Schema-shaped”. It is allowed for: - - JSON Schema generated from Zod with `z.toJSONSchema(...)`. - - TypeBox schemas, because TypeBox schemas are already JSON-Schema-shaped. -- If TypeBox source values need a library prefix in a non-boundary helper, use `tb*`. -- Words such as `Details`, `Params`, `Payload`, and `Result` are data-type name parts; they do not identify a schema library. - -### File organization - -Use layer-first organization: - -```text -src/tui-client/.pi/extensions/structured-exchange/schemas/ - README.md - shared.ts - present.ts - request.ts - capture.ts - index.ts -``` - -Reason: ease of overview and sharing. `capture.ts` exists because `capture_` tools are the fourth schema layer discussed in this thread. - -### Global details header rule - -`schema` and `v` are realistic only if readers validate them. We chose to keep them as checked discriminants. - -```yaml -details_header: - schema: "brunch.structured_exchange.present" | "brunch.structured_exchange.request" | "brunch.structured_exchange.capture" - v: 1 - exchange_id: string -``` - -Rules: - -- `schema` discriminates structured-exchange details from ordinary tool results without trusting `toolName` alone. -- `v` must be validated; unsupported versions should fail/ignore rather than silently parse. -- Use `v`, not `schema_version`, in the new Zod-authored details model. - -### `tool_meta` sequence/sibling information - -We rejected separate `present_tool`, `kind`, and `expected_request` fields in favor of a compact sequence descriptor. - -We considered `tool_kind`, then preferred something like `tool_meta`. - -Present side: - -```yaml -tool_meta: - curr: present_question | present_options | present_review_set | present_candidates - next: request_answer | request_choice | request_choices | request_review -``` - -Request side: - -```yaml -tool_meta: - prev: present_question | present_options | present_review_set | present_candidates - curr: request_answer | request_choice | request_choices | request_review - next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate -``` - -Capture side: - -```yaml -tool_meta: - prev: request_answer | request_choice | request_choices | request_review - curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate -``` - -Rules: - -- No `phase` field. It proves nothing; it is derivable from `curr` / layer. -- No present-side `status: presented`. If a present result exists, it was presented. -- No `prev_required` / `next_required` fields for now. -- Request terminal state is **not** a string `status`; it is a property-presence union. - -### `comment` vs `message` - -Rule: - -```yaml -comment: - meaning: user-authored supplementary text - source: human input - examples: - - optional explanation after selecting a listed option - - required explanation for Other / None - - review change-request rationale - - rejection reason if user supplies one - -message: - meaning: system-authored explanatory text - source: Brunch/tool/runtime - examples: - - "User cancelled the request." - - "request_choices requires interactive UI." - - "Invalid JSON in editor fallback." - - "Unknown choice id." -``` - -Do not use `note` in the new schema model. Use `comment` for user input and `message` for system/runtime explanation. - -## Present layer: exact shapes and rules - -### General present shape - -```yaml -present: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: string - - tool_meta: - curr: present_question | present_options | present_review_set | present_candidates - next: request_answer | request_choice | request_choices | request_review - - display: - heading: string - body?: markdown - preface?: markdown -``` - -### `present_options` naming - -The drift was `present_option_set` vs `present_options`. Decision: keep `present_options`; the existing tool name is fine. - -### `present_candidates` - -This is the exact shape the user wrote and approved: - -```yaml -present_candidates: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: string - - tool_meta: - curr: present_candidates - next: request_choice - - display: - heading: string - body?: markdown - - candidates: - - id: string - title: string - - user_rubric: - core_bet: markdown - best_fit: markdown - cost_complexity: markdown - covers_well: markdown - main_risks: markdown - lock_in_constraints: markdown - recommendation?: markdown - - meta_rubric: - legibility_cost_of_knowing?: markdown - failure_modes?: markdown - coverage_range?: markdown - commitment?: markdown - - graph_refs: - - node_id: string -``` - -Rules for `present_candidates`: - -- `core_bet` effectively acts as the headline/thesis of the candidate-proposal unit. -- `user_rubric` is the human-readable comparison surface. -- `meta_rubric` is persisted internal reasoning trace for later capture; it may be used by the assistant/capture step but is not necessarily rendered by default. -- Internally, the assistant may reason in terms of the four D31-L meta-rubric axes, then derive the `user_rubric` structure for the `present_candidates` tool. -- `graph_refs` are per-candidate. -- `graph_refs` consist strictly of graph node references: `{ node_id: string }` only. -- Do **not** add roles, caveats, assumptions, observations, grounding prose, or ad-hoc text to `graph_refs`. -- If such information matters, it should either already be in the graph or be captured in the `capture_` phase. -- Avoid low/medium/high scalar ratings for cost/risk/confidence/timeline by default; they usually obscure comparison rather than clarify. - -User-facing rubric remap captured from conversation: - -```yaml -instead_of: - - Confidence - - Timeline - - Complexity - - Risk - - Verification - - Key tradeoff - -use: - core_bet: "why choose this option" - best_fit: "what you get" - cost_complexity: "what it costs you" - covers_well: "what it hits" - main_risks: "what it misses" - lock_in_constraints: "what it commits you to" - recommendation: "the LLM's opinion" -``` - -Relationship to D31-L meta-rubric: - -```yaml -internal_meta_axes: - - legibility_cost_of_knowing - - failure_modes - - coverage_range - - commitment - -user_facing_facets: - core_bet: - role: headline / product-thesis-fit - question: "What thesis is this option making, and why would we choose it?" - best_fit: - role: where this option shines - sources: [legibility_cost_of_knowing, coverage_range] - cost_complexity: - role: what it asks of us - sources: [legibility_cost_of_knowing, commitment] - covers_well: - role: positive coverage - sources: [coverage_range] - main_risks: - role: negative coverage / failure - sources: [failure_modes, coverage_range] - lock_in_constraints: - role: downstream commitment - sources: [commitment] - recommendation: - role: agent judgment - sources: [all_facets] -``` - -### Other present tools - -The thread did not fully redesign exact payloads for `present_question`, `present_options`, or `present_review_set` beyond the general present shape and existing tool family names. Build conservative schemas from the existing implementation and the rules above. Do **not** invent extra candidate/review semantics beyond what is already in code/docs. - -Known from the original sketch: - -```yaml -present_question: - purpose: question heading and body; presentationally looks like normal assistant message - -present_options: - purpose: options, each with content and optional rationale - -present_review_set: - purpose: requirement or criterion nodes proposed as a set - caution: review-set semantics are documented elsewhere; do not make candidate selection into a review-set flow -``` - -Examples to include in README/tests where useful: - -#### `present_question` - -```yaml -present_question: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: "problem-frame" - - tool_meta: - curr: present_question - next: request_answer - - display: - heading: "What problem are we solving first?" - body: "Name the pain, the protagonist, and the constraint that matters most." - preface: "We have the project shape, but not the user-facing pull yet." -``` - -#### `present_options` for single choice - -```yaml -present_options: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - curr: present_options - next: request_choice - - display: - heading: "Which product shape should we optimize for?" - body: "Pick the shape that best matches the POC posture." - - options: - - id: "local-first" - content: "Local-first app" - rationale: "Matches the current single-machine POC constraint." - - id: "cloud-collab" - content: "Cloud collaboration app" - rationale: "Better for teams, but outside the current deployment target." -``` - -#### `present_options` for multiple choices - -```yaml -present_options: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: "open-risks" - - tool_meta: - curr: present_options - next: request_choices - - display: - heading: "Which risks should we keep visible?" - body: "Choose one or more risks to carry into the next slice." - - options: - - id: "transport" - content: "Transport contract" - rationale: "Public RPC behavior is now a product seam." - - id: "chrome" - content: "Chrome recovery" - rationale: "Visual product ownership remains open before FE-744 closes." -``` - -#### `present_review_set` conservative example - -```yaml -present_review_set: - schema: "brunch.structured_exchange.present" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - curr: present_review_set - next: request_review - - display: - heading: "Review proposed requirements" - body: "Approve the set, request changes, or reject it." - - review_set: - proposal_entry_id: "entry-review-proposal-17" -``` - -Do not elaborate `review_set` beyond existing design docs unless the builder first routes back through design/spec. - -## Request layer: exact shapes and rules - -Request details use property presence as the terminal discriminator. Runtime code should check for property presence. - -```yaml -request: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: string - - tool_meta: - prev: present_question | present_options | present_review_set | present_candidates - curr: request_answer | request_choice | request_choices | request_review - next?: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate - - answered: - # variant-specific payload here - cancelled?: - message?: string - unavailable?: - message: string -``` - -But the schema must enforce exactly one of: - -```yaml -- answered -- cancelled -- unavailable -``` - -Pseudo-TypeScript intent: - -```ts -type RequestDetails = RequestBase & - ( - | { answered: AnsweredPayload; cancelled?: never; unavailable?: never } - | { answered?: never; cancelled: { message?: string }; unavailable?: never } - | { answered?: never; cancelled?: never; unavailable: { message: string } } - ) -``` - -### Request examples: every variant and terminal outcome - -#### `request_answer` — answered - -```yaml -request_answer: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "problem-frame" - - tool_meta: - prev: present_question - curr: request_answer - next: capture_answer - - answered: - text: "The hard part is keeping the agent and graph coherent across sessions." -``` - -#### `request_answer` — cancelled - -```yaml -request_answer: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "problem-frame" - - tool_meta: - prev: present_question - curr: request_answer - - cancelled: - message: "User cancelled." -``` - -#### `request_answer` — unavailable - -```yaml -request_answer: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "problem-frame" - - tool_meta: - prev: present_question - curr: request_answer - - unavailable: - message: "request_answer requires interactive UI." -``` - -#### `request_choice` after `present_options` — answered with listed choice - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: present_options - curr: request_choice - next: capture_choice - - answered: - choice: - id: "local-first" - label: "Local-first app" - kind: listed - comment: "This fits the POC constraints." -``` - -#### `request_choice` after `present_options` — answered with other choice - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: present_options - curr: request_choice - next: capture_choice - - answered: - choice: - id: "other" - label: "A local-first app with optional cloud sync later" - kind: other - comment: "The listed local-first option is close, but cloud sync should stay imaginable." -``` - -#### `request_choice` after `present_options` — answered with none choice - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: present_options - curr: request_choice - next: capture_choice - - answered: - choice: - id: "none" - label: "None of these" - kind: none - comment: "All of these assume too much about deployment." -``` - -#### `request_choice` after `present_candidates` — answered - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "candidate-direction" - - tool_meta: - prev: present_candidates - curr: request_choice - next: capture_candidate - - answered: - choice: - id: "candidate-local-workbench" - label: "Local workbench for graph-native specs" - kind: listed - comment: "This matches the product thesis; carry over the chrome/coherence emphasis." -``` - -#### `request_choice` — cancelled - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: present_options - curr: request_choice - - cancelled: - message: "User cancelled." -``` - -#### `request_choice` — unavailable - -```yaml -request_choice: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: present_options - curr: request_choice - - unavailable: - message: "request_choice requires interactive UI." -``` - -#### `request_choices` — answered with listed choices - -```yaml -request_choices: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: present_options - curr: request_choices - next: capture_choices - - answered: - choices: - - id: "transport" - label: "Transport contract" - kind: listed - - id: "chrome" - label: "Chrome recovery" - kind: listed - comment: "These are the ones I care about before graph work." -``` - -#### `request_choices` — answered with listed plus other - -```yaml -request_choices: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: present_options - curr: request_choices - next: capture_choices - - answered: - choices: - - id: "transport" - label: "Transport contract" - kind: listed - - id: "other" - label: "Schema source-of-truth drift" - kind: other - comment: "The schema-library decision could affect both runtime and web client boundaries." -``` - -#### `request_choices` — answered with none - -```yaml -request_choices: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: present_options - curr: request_choices - next: capture_choices - - answered: - choices: - - id: "none" - label: "None of these" - kind: none - comment: "These are not the risks I want to prioritize." -``` - -#### `request_choices` — cancelled - -```yaml -request_choices: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: present_options - curr: request_choices - - cancelled: - message: "User cancelled." -``` - -#### `request_choices` — unavailable - -```yaml -request_choices: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: present_options - curr: request_choices - - unavailable: - message: "request_choices requires interactive UI." -``` - -#### `request_review` — approve - -```yaml -request_review: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: present_review_set - curr: request_review - next: capture_review - - answered: - decision: approve - comment: "This is ready to commit." -``` - -#### `request_review` — request changes - -```yaml -request_review: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: present_review_set - curr: request_review - next: capture_review - - answered: - decision: request_changes - comment: "Regenerate this with clearer non-goals." -``` - -#### `request_review` — reject - -```yaml -request_review: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: present_review_set - curr: request_review - next: capture_review - - answered: - decision: reject - comment: "This is solving the wrong problem." -``` - -#### `request_review` — cancelled - -```yaml -request_review: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: present_review_set - curr: request_review - - cancelled: - message: "User cancelled." -``` - -#### `request_review` — unavailable - -```yaml -request_review: - schema: "brunch.structured_exchange.request" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: present_review_set - curr: request_review - - unavailable: - message: "request_review requires interactive UI." -``` - -Request rules: - -- Use `comment`, not `note`, for user-authored supplementary text. -- `request_choice` can follow `present_options` or `present_candidates`. -- If `request_choice` follows `present_candidates`, the later capture tool is `capture_candidate`. -- `request_choices` follows `present_options`. -- `request_review` follows `present_review_set`. -- `request_review` supports `approve`, `request_changes`, and `reject`; `comment` is required for `request_changes`. -- `other` / `none` choices require a user `comment`. - -## Capture layer: exact decisions and limits - -The thread established a capture layer but did **not** fully design graph payload schemas. Do not invent them. - -Decisions: - -- There will be `capture_` tool entries after `request_` tool results. -- Capture is where semantic/generative work happens after user response. -- For `present_candidates`, graph generation happens **after** the user makes a choice. -- `capture_candidate` draws on: - - the selected candidate’s user-facing description (`user_rubric`), - - the selected candidate’s internal `meta_rubric`, - - the selected candidate’s `graph_refs`, - - the user’s selected choice, - - the user’s `comment`, if any. -- `present_candidates` may capture meta-rubric reasoning trace in `details`; that trace is later input to capture. -- `present_candidates` does **not** generate graph sets directly. -- Do not add ad-hoc observations to present details for later capture. -- All semantic capture happens at `capture_*`. -- Actual graph writes still route through `CommandExecutor`. - -Minimum capture sequence shape discussed: - -```yaml -capture: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: string - - tool_meta: - prev: request_answer | request_choice | request_choices | request_review - curr: capture_answer | capture_choice | capture_choices | capture_review | capture_candidate -``` - -Do **not** add committed graph nodes, graph edges, LSNs, or `CommandExecutor` result fields to capture details in this schema pass unless the user explicitly approves a concrete shape. - -Capture examples for every current permutation: - -```yaml -capture_answer: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: "problem-frame" - - tool_meta: - prev: request_answer - curr: capture_answer -``` - -```yaml -capture_choice: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: "domain-shape" - - tool_meta: - prev: request_choice - curr: capture_choice -``` - -```yaml -capture_choices: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: "open-risks" - - tool_meta: - prev: request_choices - curr: capture_choices -``` - -```yaml -capture_review: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: "review-set-17" - - tool_meta: - prev: request_review - curr: capture_review -``` - -```yaml -capture_candidate: - schema: "brunch.structured_exchange.capture" - v: 1 - exchange_id: "candidate-direction" - - tool_meta: - prev: request_choice - curr: capture_candidate -``` - -`capture_candidate` consumes the selected candidate id from the prior `request_choice`; do not duplicate candidate/user/meta rubric payloads into capture details unless the user approves that change. - -## Prepared scope-card queue - ---- - -## Card 1 — Write schema README from exact captured contract - -**Weight:** full scope card -**Status:** done - -### Target Behavior - -The structured-exchange schema directory contains a README that records the exact naming, layering, validation, export, and semantic-boundary rules captured above. - -### Boundary Crossings - -```text --> memory/CARDS.md exact captured design contract --> src/tui-client/.pi/extensions/structured-exchange/schemas/README.md --> future schema implementation guardrails -``` - -### Risks and Assumptions - -- **RISK:** The README introduces new drift. - -> **MITIGATION:** copy the captured contract faithfully; do not add fields or new vocabulary. -- **RISK:** The README becomes design prose that tests do not enforce. - -> **MITIGATION:** Cards 2+ add tests for machine-checkable rules. - -### Acceptance Criteria - -- [ ] `src/tui-client/.pi/extensions/structured-exchange/schemas/README.md` exists. -- [ ] README captures the exact naming rules, file layout, header, `tool_meta`, `comment`/`message`, present, request, candidate, and capture rules above. -- [ ] README explicitly says not to invent graph payload fields in capture details. - -### Verification Approach - -- **Inner:** Markdown review against this card. -- **Middle:** none unless formatting tools touch markdown. -- **Outer:** none. - -### Cross-cutting obligations - -- Do not implement schemas in Card 1. -- Do not migrate runtime code in Card 1. - ---- - -## Card 2 — Add shared Zod primitives and JSON Schema export convention - -**Weight:** full scope card -**Status:** done - -### Target Behavior - -The schema layer exposes shared Zod primitives for the exact shared vocabulary captured above. - -### Acceptance Criteria - -- [ ] `zod` is added as a dependency. -- [ ] `schemas/shared.ts` defines `z*` source schemas and bare inferred types for the details header, markdown string alias, graph node ref, tool names, and `tool_meta` variants. -- [ ] JSON Schema exports use the `*Schema` suffix only for JSON-Schema-shaped outputs. -- [ ] Tests prove representative shared schemas parse and export via `z.toJSONSchema(..., { unrepresentable: "throw" })`. - -### Verification Approach - -- **Inner:** targeted Vitest parse/export tests. -- **Middle:** `npm run fix`. -- **Gate:** `npm run verify` before commit. - -### Cross-cutting obligations - -- Do not alter existing runtime structured-exchange parsing/projection. - ---- - -## Card 3 — Add present detail Zod schemas - -**Weight:** full scope card -**Status:** done - -### Target Behavior - -The schema layer models the present-side details vocabulary captured above without adding fields. - -### Acceptance Criteria - -- [ ] `schemas/present.ts` defines `zPresentQuestionDetails`, `zPresentOptionsDetails`, `zPresentReviewSetDetails`, `zPresentCandidatesDetails`, and a present union. -- [ ] `zPresentCandidatesDetails` exactly captures the approved `present_candidates` shape. -- [ ] Invalid candidate `graph_refs` with fields other than `node_id` fail validation. -- [ ] No present schema includes `phase`, `status`, `next_required`, `schema_version`, ad-hoc assumptions/caveats/observations, or scalar rating fields. -- [ ] JSON Schema export succeeds. - -### Verification Approach - -- **Inner:** targeted Vitest parse/export tests. -- **Middle:** `npm run fix`. -- **Gate:** `npm run verify` before commit. - -### Cross-cutting obligations - -- Use conservative shapes for `present_question`, `present_options`, and `present_review_set`; do not elaborate beyond existing implementation/docs and captured rules. - ---- - -## Card 4 — Add request detail Zod schemas - -**Weight:** full scope card -**Status:** done - -### Target Behavior - -The schema layer models request-side details as exactly-one property-presence terminal outcome unions. - -### Acceptance Criteria - -- [ ] `schemas/request.ts` defines `zRequestAnswerDetails`, `zRequestChoiceDetails`, `zRequestChoicesDetails`, `zRequestReviewDetails`, and a request union. -- [ ] Request schemas accept exactly one of `answered`, `cancelled`, or `unavailable`. -- [ ] Tests reject multiple outcomes and missing outcome. -- [ ] `comment` appears only in user-authored answered payloads. -- [ ] `message` appears only in `cancelled` / `unavailable` system-authored payloads. -- [ ] `request_choice` supports `prev: present_options | present_candidates`. -- [ ] `request_review` requires `comment` when `decision = request_changes`. -- [ ] JSON Schema export succeeds. - -### Verification Approach - -- **Inner:** targeted Vitest parse/export tests. -- **Middle:** `npm run fix`. -- **Gate:** `npm run verify` before commit. - -### Cross-cutting obligations - -- Do not change public RPC behavior in this card. - ---- - -## Card 5 — Add capture detail Zod schemas at the agreed minimum - -**Weight:** full scope card -**Status:** done - -### Target Behavior - -The schema layer models capture-side details only to the extent explicitly agreed: header plus request-to-capture `tool_meta` sequencing. - -### Acceptance Criteria - -- [ ] `schemas/capture.ts` defines capture tool-name schemas and minimal capture detail schemas for `capture_answer`, `capture_choice`, `capture_choices`, `capture_review`, and `capture_candidate`. -- [ ] Capture schemas include `schema`, `v`, `exchange_id`, and capture `tool_meta`. -- [ ] `capture_candidate` may include `selected_candidate_id` only if implementation keeps it as the selected choice id already recorded by request; do not add graph payloads. -- [ ] Capture schemas do not include committed graph nodes, graph edges, LSNs, `CommandExecutor` results, assumptions, caveats, or observations. -- [ ] JSON Schema export succeeds. - -### Verification Approach - -- **Inner:** targeted Vitest parse/export tests. -- **Middle:** `npm run fix`. -- **Gate:** `npm run verify` before commit. - -### Cross-cutting obligations - -- Capture remains a transcript layer, not graph truth. -- If the builder feels capture needs prose analysis fields, stop and ask; do not invent them. - ---- - -## Card 6 — Consolidate schema exports and gap report - -**Weight:** light scope card -**Status:** queued - -### Objective - -The structured-exchange schema layer exports a coherent public surface and records unresolved gaps before runtime migration begins. - -### Acceptance Criteria - -- [ ] `schemas/index.ts` re-exports the intended schema/type surface. -- [ ] README has a short “Known gaps before runtime migration” section only if implementation reveals gaps. -- [ ] Existing runtime code is not migrated in this queue. - -### Verification Approach - -- **Inner:** import/compile smoke via targeted tests. -- **Middle:** `npm run fix`. -- **Gate:** `npm run verify` before commit. - -### Assumption dependency - -Depends on: D41-L as revised — Zod v4 source schemas are allowed when JSON Schema export is proven. diff --git a/memory/PLAN.md b/memory/PLAN.md index 565854bc..ffea7c36 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -203,7 +203,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, and private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/` have landed. Current missing product seam is visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/`, and the Zod-authored structured-exchange schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` have landed. Current missing product seam is visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. - **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. @@ -211,7 +211,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`: `registerBrunchPrompting` appends deterministic code-composed prompt packs from the explicit shell, and `operational-mode.ts` remains responsible for runtime state/tool policy rather than prompt text duplication; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. +- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. A Zod-authored schema layer now lives under `src/tui-client/.pi/extensions/structured-exchange/schemas/` with JSON Schema export tests for the captured present/request/capture details contract; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`: `registerBrunchPrompting` appends deterministic code-composed prompt packs from the explicit shell, and `operational-mode.ts` remains responsible for runtime state/tool policy rather than prompt text duplication; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. ### flue-pattern-adoption diff --git a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts index c4483d7e..2c4dd09f 100644 --- a/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts +++ b/src/tui-client/.pi/__tests__/structured-exchange-schemas.test.ts @@ -7,32 +7,26 @@ import { zCaptureChoiceDetails, zCaptureChoicesDetails, zCaptureDetails, + zCaptureDetailsHeader, zCaptureReviewDetails, -} from "../extensions/structured-exchange/schemas/capture.js" -import { + zCaptureToolMeta, + zGraphNodeRef, + zMarkdown, zPresentCandidatesDetails, zPresentDetails, + zPresentDetailsHeader, zPresentOptionsDetails, zPresentQuestionDetails, zPresentReviewSetDetails, -} from "../extensions/structured-exchange/schemas/present.js" -import { + zPresentToolMeta, zRequestAnswerDetails, zRequestChoiceDetails, zRequestChoicesDetails, zRequestDetails, - zRequestReviewDetails, -} from "../extensions/structured-exchange/schemas/request.js" -import { - zCaptureDetailsHeader, - zCaptureToolMeta, - zGraphNodeRef, - zMarkdown, - zPresentDetailsHeader, - zPresentToolMeta, zRequestDetailsHeader, + zRequestReviewDetails, zRequestToolMeta, -} from "../extensions/structured-exchange/schemas/shared.js" +} from "../extensions/structured-exchange/schemas/index.js" function expectJsonSchemaExport(schema: z.ZodType) { expect(() => diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md b/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md index dfc2f1d9..55edd97d 100644 --- a/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/README.md @@ -384,3 +384,7 @@ capture_candidate: ``` `capture_candidate` consumes the selected candidate id from the prior `request_choice`; do not duplicate candidate, user-rubric, or meta-rubric payloads into capture details unless a later design approves that change. + +## Known gaps before runtime migration + +No additional schema-contract gaps were found while implementing this schema layer. Runtime tools and projection code still use the existing tuple details model until a later migration slice deliberately rewires them to these exports. diff --git a/src/tui-client/.pi/extensions/structured-exchange/schemas/index.ts b/src/tui-client/.pi/extensions/structured-exchange/schemas/index.ts new file mode 100644 index 00000000..5094d82f --- /dev/null +++ b/src/tui-client/.pi/extensions/structured-exchange/schemas/index.ts @@ -0,0 +1,4 @@ +export * from "./capture.js" +export * from "./present.js" +export * from "./request.js" +export * from "./shared.js" From f6579a7d0df61e7816118e6f6226a53679021ab0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 10:39:57 +0200 Subject: [PATCH 156/164] add pseudo skill + cross-links + living examples in PLAN.md new pseudo skill: minimal notation typology for human-agent design (tree, chain, graph, matrix, state-machine, data-shape, lanes) with shared overlay grammar (#anchor, @owner, +/-/~/!/?, ->/~>/x>) and a routing chain expressed in its own chain notation. cross-link directives in ln-{spec,design,scope,plan,refactor,review, sync,build} using a grep-able **Notation aid.** lead-in. ln-sync directive explicitly preserves existing pseudo artifacts and consolidates qualifying prose into them, so future sync runs do not collapse pseudo back to prose. PLAN.md updates as living examples: - Dependencies block: hand-drawn tree -> pseudo graph with cross-edges (m5 -[optional]-> subagents, graph-plane -[on promotion]-> oracle) visible and horizon items grouped under unconnected - agent-graph-integration acceptance: paragraph -> pseudo tree (obligation decomposition) with each leaf individually testable --- .agents/skills/ln-build/SKILL.md | 2 + .agents/skills/ln-design/SKILL.md | 2 + .agents/skills/ln-plan/SKILL.md | 2 + .agents/skills/ln-refactor/SKILL.md | 2 + .agents/skills/ln-review/SKILL.md | 2 + .agents/skills/ln-scope/SKILL.md | 2 + .agents/skills/ln-spec/SKILL.md | 2 + .agents/skills/ln-sync/SKILL.md | 7 + .agents/skills/pseudo/SKILL.md | 126 +++++++++ .agents/skills/pseudo/references/chain.md | 171 ++++++++++++ .../skills/pseudo/references/data-shape.md | 250 ++++++++++++++++++ .agents/skills/pseudo/references/graph.md | 248 +++++++++++++++++ .agents/skills/pseudo/references/lanes.md | 212 +++++++++++++++ .agents/skills/pseudo/references/matrix.md | 193 ++++++++++++++ .../skills/pseudo/references/state-machine.md | 240 +++++++++++++++++ .agents/skills/pseudo/references/tree.md | 226 ++++++++++++++++ memory/PLAN.md | 101 +++++-- 17 files changed, 1759 insertions(+), 29 deletions(-) create mode 100644 .agents/skills/pseudo/SKILL.md create mode 100644 .agents/skills/pseudo/references/chain.md create mode 100644 .agents/skills/pseudo/references/data-shape.md create mode 100644 .agents/skills/pseudo/references/graph.md create mode 100644 .agents/skills/pseudo/references/lanes.md create mode 100644 .agents/skills/pseudo/references/matrix.md create mode 100644 .agents/skills/pseudo/references/state-machine.md create mode 100644 .agents/skills/pseudo/references/tree.md diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index df012268..93683e9a 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -91,6 +91,8 @@ Run the project's verification harness. All checks must pass. If the card proved After verification, reconcile canonical state every time. The reconciliation may end in a no-op, but skipping it is not allowed. +**Notation aid.** When the reconciliation records slice acceptance breakdowns, module sketches, call/dependency shapes, or schema-shaped invariants into canonical docs, use `pseudo` forms (`tree` for obligation decomposition; `chain` for call graphs; `graph` for cross-module relations; `data-shape` for sketched schemas). Preserve any `pseudo` artifacts already present in SPEC/PLAN — do not collapse them back into prose. + Traceability depth is **conditional**, not automatic. After the build lands and verification passes, ask: diff --git a/.agents/skills/ln-design/SKILL.md b/.agents/skills/ln-design/SKILL.md index e439beb9..1d06c508 100644 --- a/.agents/skills/ln-design/SKILL.md +++ b/.agents/skills/ln-design/SKILL.md @@ -33,6 +33,8 @@ Spawn 3+ sub-agents simultaneously. Each must produce a **radically different** Each agent returns: **interface** (types, methods, params, invariants, ordering constraints, error modes, required configuration, and performance characteristics), **usage example**, **what it hides**, **seam / adapter strategy** where relevant, **trade-offs**, **load-bearing claims** (1–3 falsifiable beliefs the design rests on — for each, note whether it is already covered by `memory/SPEC.md` §Assumptions), and **cheapest tracer bullet** — the thinnest `ln-scope` slice whose landing would light up the seam and break if the claim is wrong. Fall back to `ln-spike` only when no buildable slice could carry the proof. +**Notation aid.** Express each candidate module shape using `pseudo` (`graph` or `tree` for module relations, `data-shape` for interface shapes, `lanes` for cross-actor seams). Side-by-side `pseudo` artifacts make alternatives directly comparable in the same form rather than as divergent prose. + ### 3. Present and compare Show each design sequentially, then compare in prose on: diff --git a/.agents/skills/ln-plan/SKILL.md b/.agents/skills/ln-plan/SKILL.md index 82f5bab2..b1496060 100644 --- a/.agents/skills/ln-plan/SKILL.md +++ b/.agents/skills/ln-plan/SKILL.md @@ -18,6 +18,8 @@ Use **slice** for the buildable scope card produced by `ln-scope` and implemente The vertical-slicing instinct still applies at planning time: frontier items should cut through the relevant concerns of `memory/SPEC.md` instead of becoming layer-by-layer chores. The term "frontier" names their canonical/branch role; the term "slice" remains reserved for scoped execution. +**Notation aid.** Express the `Dependencies` block as `pseudo graph` rather than a hand-drawn tree — cross-edges (optional successors, on-promotion edges) and dependency-edge types (`-[hard]->`, `-[optional]->`, `-[on promotion]->`) stay visible, and horizon items go in an `unconnected` group so they're acknowledged without implying spine relations. See `pseudo references/graph.md` worked example "roadmap dependency graph." + ## Plan document shape Prefer the conflict-resistant mature shape: diff --git a/.agents/skills/ln-refactor/SKILL.md b/.agents/skills/ln-refactor/SKILL.md index 56bc88e4..2374293f 100644 --- a/.agents/skills/ln-refactor/SKILL.md +++ b/.agents/skills/ln-refactor/SKILL.md @@ -36,6 +36,8 @@ What is wrong, from the developer's perspective. The target state, from the developer's perspective. +**Notation aid.** Express the structural delta as paired `pseudo` artifacts — `tree` current → `tree` desired, or `graph` current → `graph` desired — under the Problem Statement / Solution headings. The paired form makes the change concrete before commits begin and gives reviewers a single artifact to diff. + ## Commits Ordered list of tiny commits. Each described in plain English — no file paths or snippets. Each leaves the codebase working. diff --git a/.agents/skills/ln-review/SKILL.md b/.agents/skills/ln-review/SKILL.md index b0e002e0..bb5f869e 100644 --- a/.agents/skills/ln-review/SKILL.md +++ b/.agents/skills/ln-review/SKILL.md @@ -55,6 +55,8 @@ If `memory/SPEC.md` §Oracle Strategy by Loop Tier exists, check whether recent Collect gaps as numbered findings (category: `oracle-coverage`). +**Notation aid.** Map test artifacts against acceptance leaves with `pseudo matrix` (coverage variant): rows = obligation leaves from a `pseudo tree` decomposition of the frontier acceptance, columns = test artifacts. Gaps surface as `.` cells; partial coverage as `~`. Compact, scannable, and the matrix itself becomes a coverage artifact reviewers can re-run. + ### Lexicon alignment (category: `naming`) If `memory/SPEC.md` exists, survey how §Lexicon terms (both method and domain) appear across: diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index c2ebc725..b4c76b8b 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -127,6 +127,8 @@ A tracer bullet should *tell you something*. Build it. ✓ [test name] — [observable assertion] ``` +**Notation aid.** When acceptance is more than a handful of leaves, decompose it with `pseudo tree` (obligation decomposition variant) so each leaf maps to one assertion. Use `pseudo lanes` when the slice crosses actor boundaries; `pseudo state-machine` when it changes a lifecycle. + ### Verification Approach Name the oracle strategy for this slice. diff --git a/.agents/skills/ln-spec/SKILL.md b/.agents/skills/ln-spec/SKILL.md index ce90d706..f4327629 100644 --- a/.agents/skills/ln-spec/SKILL.md +++ b/.agents/skills/ln-spec/SKILL.md @@ -44,6 +44,8 @@ When assigning the numeric part for a new item: If `$HOME` is unavailable or the basename is empty, ask the user for the suffix instead of inventing one. +**Notation aid.** Use `pseudo` when sketching module shapes, decomposing acceptance criteria into individually-testable obligations, or capturing schema-shaped invariants. `pseudo tree` for obligation decomposition; `pseudo data-shape` for module data; `pseudo graph` for cross-module relations. + ### SPEC shape Use the mature SPEC shape unless the existing project clearly predates it and the user only asked for a narrow patch: diff --git a/.agents/skills/ln-sync/SKILL.md b/.agents/skills/ln-sync/SKILL.md index 6f29aff2..d35309d3 100644 --- a/.agents/skills/ln-sync/SKILL.md +++ b/.agents/skills/ln-sync/SKILL.md @@ -31,6 +31,13 @@ Prefer `ln-sync` at these moments: | `memory/CARDS.md` | derivative execution queue | only unfinished prepared scope cards inside one frontier item | | `memory/REFACTOR.md` | derivative temporary execution plan | only unfinished refactor steps | +**Notation aid.** When refreshing SPEC or PLAN: + +- **Preserve existing `pseudo` artifacts** (tree, chain, graph, matrix, state-machine, data-shape, lanes). Do not collapse a `pseudo` form back into prose — these are denser, more diffable, and more agent-navigable than equivalent text. +- **Consolidate prose into `pseudo` forms** when prose has grown that meets the routing criteria (see `pseudo` SKILL routing chain) — paragraph-length acceptance criteria → `tree`, hand-drawn dependency tree with cross-edges hiding in prose → `graph`, scattered comparison bullets → `matrix`. +- **Apply smell-to-switch rules** when reshaping. An artifact may have outgrown its current family (e.g. a tree whose siblings now interact → graph). +- A change that *replaces* prose with an equivalent `pseudo` artifact counts as a sync improvement, not a content edit; surface it as such in the change summary. + ## Procedure ### 1. Read the current docs diff --git a/.agents/skills/pseudo/SKILL.md b/.agents/skills/pseudo/SKILL.md new file mode 100644 index 00000000..939e44c3 --- /dev/null +++ b/.agents/skills/pseudo/SKILL.md @@ -0,0 +1,126 @@ +--- +name: pseudo +description: "Sketch structures, flows, schemas, and decisions in minimal ASCII/YAML notation that humans and agents can co-edit. Use when planning before code — describing hierarchies, call flows, graphs, decision tables, state machines, data shapes, or actor sequences — and when a shared diagram would carry the design discussion better than prose." +argument-hint: "[family name (tree|chain|graph|matrix|state-machine|data-shape|lanes) or free-form relation to capture]" +--- + +# Pseudo + +A small typology of minimal notations for **shared design between humans and agents**. Each family captures one kind of structural relation, in a form that is cheap to type, cheap to diff, and cheap for either party to mutate. Pseudo is a *notation primitive* — `ln-spec`, `ln-design`, `ln-scope`, `ln-refactor`, and `ln-review` should reach for it whenever a sketch would beat prose. + +## Input + +Family name or free-form relation to capture: $ARGUMENTS + +## Family map + +| Family | Captures | Reference | +|---|---|---| +| **tree** | containment, hierarchy, decomposition | [references/tree.md](references/tree.md) | +| **chain** | linear flow, call stack, mainline reasoning | [references/chain.md](references/chain.md) | +| **graph** | fan-in / fan-out, cycles, dependencies | [references/graph.md](references/graph.md) | +| **matrix** | n×m comparison, decision tables, responsibility | [references/matrix.md](references/matrix.md) | +| **state-machine** | durable states + transitions | [references/state-machine.md](references/state-machine.md) | +| **data-shape** | schemas, types, instance shape | [references/data-shape.md](references/data-shape.md) | +| **lanes** | actors over time, request/response, async handoff | [references/lanes.md](references/lanes.md) | + +## Routing + +Three modes by `$ARGUMENTS`: + +1. **Empty** — emit the family map plus a one-line "what relation are you capturing?" prompt; route from the answer. +2. **Named family** (`tree`, `graph`, …) — load that reference and apply it to current context. +3. **Free-form intent** (`"before/after of the auth flow"`, `"how the worker retries"`) — route from intent → family using the chain below, then load the reference. + +### Routing chain (from intent to family) + +``` +What relation am I capturing? + -> "containment, hierarchy, decomposition (incl. obligations)" + -> tree + -> "linear flow with at most shallow branching" + -> chain + -> "actors over time, request/response, handoff" + -> lanes + -> "fan-in, fan-out, cycles, typed or static dependencies" + -> graph + -> "n×m comparison, conditions → actions, responsibility, coverage" + -> matrix + -> "durable named states with transitions between them" + -> state-machine + -> "schema, type, instance shape" + -> data-shape + x> none fit cleanly + -> ask the user; the relation may need two paired artifacts (e.g. graph + state-machine) +``` + +If two families both fit, prefer the one with the smaller artifact. If the artifact then strains, the smell-to-switch rules below catch it. + +## Shared overlay grammar + +Every family inherits the same small sigil set. Do not invent per-family alternatives. + +``` ++ - ~ added / removed / changed +? uncertain / proposal / needs confirmation (line-marker only) +! risk / blocker / hotspot +#id stable anchor — cross-references between artifacts +@owner owner / reviewer +[tags] compact metadata +-> ~> x> sync edge / async edge / error or fallback edge +<- return / response +... elided region (give a count if known) +``` + +**`?` collision rule.** In `data-shape`, suffix `?` means *optional type* (`avatarUrl: string?`). In every other family, `?` is the line-marker for *uncertain / proposal*. Never both in one block. + +## Authoring discipline + +- **One semantic fact per line** in source form. Comma-compressed versions are display sugar, not the canonical artifact. +- **ASCII is canonical; Unicode is rendering.** `->` and `└──` are aliases for `→` and `└──`; pick one per artifact and stay consistent. +- **Indentation = structure only.** Do not let column alignment carry meaning — alignment may aid reading, but the artifact must survive reformatting. +- **Anchors `#id` link artifacts.** A tree node can reference a graph edge can reference a matrix rule can reference a state transition. +- **Legend at top** when sigils multiply past ~5. Cheaper than glossing each use. + +## Cross-cutting moves + +These work across families. Reach for them before adding new notation: + +- **Before/after pairing** — two blocks under `## Current` / `## Desired`. The single most generic design move. +- **Delta inline** — `+`/`-`/`~` markers inside one block when the diff is small enough that two blocks would be wasteful. +- **Annotation column** — whitespace-aligned `[tag]` to the right of each line. +- **Anchor-attachment-after** — sketch the structure first; attach file/function/test anchors only once shape is settled. +- **Focus + elision** — show the touched area plus parent context, not the whole artifact. Use `... (N omitted)`. +- **`notes:` / `open:` footer keyed by anchors** — the non-YAML equivalent of "comments as channel." Keeps the left side stable and diffable. +- **Promote repeated notes into rules** — if the same annotation appears 3+ times, add a `legend:` or `_rules:` block. +- **Pairing variations** — current/desired, prod/test, happy/adversarial, steady-state/failure, schema/instance, rules/worked-examples. + +## Smell-to-switch rules + +When a sketch starts working against you, the family is usually wrong. Each reference includes a fuller list; the universal version: + +> **If a side note changes control flow, concurrency, ownership, or error semantics, it is not a footnote anymore — switch families.** + +Quick map of common smells: + +- Same conceptual child under two parents (tree) → **graph** +- Step has >1 meaningful branch, or branches rejoin (chain) → **graph** or **lanes** +- More than one actor matters, or request/response order is the point (chain or graph) → **lanes** +- Cells need sentences, not tokens (matrix) → prose, or split into smaller matrices +- "State" names are actually actions (`submit`, `approve`) (state-machine) → **chain** or **lanes** +- Cross-field rules dominate the schema (data-shape) → add `_rules:` block, or split into shape + state-machine +- Spatial layout itself carries meaning (graph) → escape to Mermaid + +## Escape hatches + +- **Mermaid / rendered diagram** — only when spatial layout itself carries meaning, or edge crossings actively slow reading. Try ASCII first; if it fights you, offer the user a Mermaid version or ask whether to start there. +- **Prose** — when the relation is genuinely narrative (rationale, motivation, decision history). Don't force structure on it. +- **Multiple small artifacts linked by `#id`** — beats one overloaded artifact almost every time. + +## Procedure when invoked + +1. Resolve the family (from `$ARGUMENTS` or by routing from intent). +2. Load the corresponding `references/<family>.md`. +3. Apply its canonical form first; reach for variants only when canonical fails. +4. Use overlay grammar consistently with the rest of the document. +5. If a smell-to-switch tripwire fires, surface it explicitly and propose the new family rather than silently mutating the form. diff --git a/.agents/skills/pseudo/references/chain.md b/.agents/skills/pseudo/references/chain.md new file mode 100644 index 00000000..985a2e1a --- /dev/null +++ b/.agents/skills/pseudo/references/chain.md @@ -0,0 +1,171 @@ +# Pseudo: Chain + +Captures **linear flow, call stack, mainline reasoning** — one thing leading to the next, with shallow branching for fallbacks, errors, and fire-and-forget side effects. Top-down indented with arrows as the line prefix. + +## When to use + +- Call graph from a single entry point. +- Mainline happy-path with a few exceptions or async side effects. +- Step-by-step pseudocode or reasoning. +- Layered composition (handler → service → repo → driver). +- Paired flows (production vs test, happy vs error) — same form, two named blocks. + +## When NOT to use + +- Multiple actors hand off requests/responses → **lanes**. +- More than one meaningful branch that rejoins → **graph**. +- Order between independent steps doesn't matter → **graph**. +- Persistent state with named modes → **state-machine**. + +## Canonical form + +Top-down, two-space indent per level, edge marker as the line prefix. + +``` +HTTP handler + -> AuthService.verify + -> TokenStore.lookup + -> Redis GET + x> Postgres SELECT # fallback on miss + -> User.load + <- 200 SessionDTO +``` + +Top-down is canonical. Left-to-right chains are harder to edit, harder to wrap, and don't compose with nesting; treat them as a rendering, not a source form. + +## Variants + +### Guarded chain (conditional branches) + +``` +POST /login + -> validate + x> 400 invalid # error branch terminates + -> Auth.verify + -> Token.issue + <- 200 SessionDTO +``` + +### Async side-effect chain + +`~>` for fire-and-forget side effects that don't block the mainline. + +``` +POST /order + -> Order.create + ~> Analytics.track # async + ~> Mailer.confirmation # async + <- 202 accepted +``` + +### Fork/join-lite (mainline + parallel side-work) + +If the join semantics matter (waiting for all branches), switch to **graph** or **lanes**. + +``` +Process request + -> Order.save + ~> Email.send + ~> Analytics.emit + <- 202 accepted # no join — fire-and-forget +``` + +### Input/output chain (shape transforms) + +When types change through the chain and the transformation is the point: + +``` +req: LoginRequest + -> validate + -> normalizeEmail + -> Auth.verify + <- SessionDTO +``` + +### Paired chains (prod / test, happy / error) + +Two named blocks under separate headings, same form. The pattern from your Final call graph example. + +``` +### Production +HTTP handler + -> LinkCatalog.layerDurableObject + -> Effect RPC over Durable Object fetch + -> LinkCatalog.layer + -> LinkCatalogCoordinator + -> LinkCatalogStore + -> LinkCatalogSqlExecutor + -> PublicRedirectIndexService + +### Tests +HTTP handler + -> linkCatalogMemoryLayer + -> LinkCatalog.layer + -> LinkCatalogCoordinator + -> LinkCatalogStore.layerMemory + -> PublicRedirectIndexService.layerMemory +``` + +## Annotation patterns + +- **`#id` on a step** makes it linkable: `-> Auth.verify #verify` → referenced from a graph or matrix as `#verify`. +- **`[tag]` column** for compact metadata aligned to the right. +- **Trailing `# note`** for local context; promote into structure if it changes control flow. +- **Diff markers `+` / `-` / `~`** as line prefix or marker before the arrow. +- **`?`** for uncertain step, **`!`** for risky/hotspot. +- **`@owner`** when ownership shifts between steps. +- **`notes:` / `open:` footer** keyed by anchors. + +## Smell-to-switch tripwires + +The universal rule applies sharpest here: + +> **If a side note changes control flow, concurrency, ownership, or error semantics, it is not a footnote anymore — switch families.** + +Concrete tripwires: + +- **More than one meaningful branch at a step.** → **graph** or **lanes**. +- **Branches rejoin** (you need a join node). → **graph**. +- **"Who does this step?" becomes interesting.** → **lanes**. +- **Timeouts, retries, backpressure become first-class.** → **graph** with typed nodes, or **state-machine**. +- **You start writing parenthetical "(also calls X)" notes.** → branching has exceeded chain capacity. +- **Sync/async distinction matters and you keep forgetting which is which.** → use `~>` consistently, or escape to **lanes**. + +## Anti-patterns + +- **Mixing sync and async edges with the same arrow.** Use `->` / `~>` / `x>` consistently or readers infer wrong defaults. +- **Implicit fan-out via parentheticals.** The "(also calls X)" smell — that's the family-switch trigger. +- **Left-to-right diagrams that wrap.** Unreadable; not editable. +- **Chains longer than a screen.** Split by anchor reference; link the sub-chains. +- **Mixing levels of abstraction** in one chain (HTTP handler → SQL query → byte buffer). Pick one level per chain. + +## Escape hatches + +- **Graph** when branching exceeds chain capacity (>1 meaningful branch, joins matter). +- **Lanes** when more than one actor is in play. +- **State-machine** when the chain is really a transition with retries/timeouts. +- **Split + anchor** when length exceeds a screen. + +## Worked example: HTTP login with guards and async logging + +``` +POST /login #login + -> validate body + x> 400 invalid_request + -> Auth.verify + -> TokenStore.lookup + -> Redis GET + x> Postgres SELECT # fallback on cache miss + -> User.load + x> 401 invalid_credentials + -> Session.issue + ~> Audit.log # async, fire-and-forget + <- 200 SessionDTO + +open: + - #login: should Audit.log failure surface as 500 or stay silent? +``` + +## Worked example: production / test pairing + +See the Paired chains variant above (Final call graph example). Same form, two named blocks. The shared steps in both blocks are the contract; the divergence is the layer substitution under test. diff --git a/.agents/skills/pseudo/references/data-shape.md b/.agents/skills/pseudo/references/data-shape.md new file mode 100644 index 00000000..0da93179 --- /dev/null +++ b/.agents/skills/pseudo/references/data-shape.md @@ -0,0 +1,250 @@ +# Pseudo: Data-shape + +Captures **schemas, types, and instance shape** — what fields a thing has, what types they take, what's optional, what variants exist. Uses **valid YAML** so that comments survive copy-paste, sub-trees can be snippeted in chat, and yamllint/parsers keep working. + +## When to use + +- Sketching a schema before translating to Zod, TypeScript, JSON Schema, SQL, etc. +- Negotiating field shape with the user via comments and snippeted sub-trees. +- Documenting the shape of an instance for examples or fixtures. +- Capturing a discriminated union or variant set. + +## When NOT to use + +- Cross-field rules dominate the shape → keep the shape but move rules into `_rules:` block, or split shape + state-machine. +- The "shape" is really a workflow → **state-machine** or **chain**. +- Multiple shapes related by composition or inheritance → multiple data-shape blocks linked by `#id`. + +## Canonical form + +Each line is `key: type` plus optional trailing comment. Nesting uses YAML indentation. Optional fields take suffix `?`. + +```yaml +user: + id: string # uuid + email: string # unique, lowercased + tier: enum # free | paid | trial + createdAt: datetime + profile: + name: string + avatarUrl: string? # optional +``` + +## YAML-validity rule (and what it costs) + +Data-shape stays valid YAML so that comments survive copy-paste, sub-trees can be snippeted, and yamllint/parsers keep working. This costs one thing: **the overlay grammar's line-position markers (`+`, `-`, `~`, `!`, `?`) cannot appear at the key position — they must live inside comments.** Every other family allows them on the line itself. + +| Marker | Other families (line position) | Data-shape (comment position) | +|---|---|---| +| added | `+ trialEndsAt: datetime?` | `# + trialEndsAt: datetime?` | +| removed | `- legacyPlan: string` | `# - legacyPlan: string` | +| changed | `~ tier: enum` | `# ~ tier: enum (was string)` | +| risk | `! amount: number` | `amount: number # ! check rounding` | +| proposal | `? trialEndsAt: datetime?` | `# ? trialEndsAt: datetime?` | + +## The `?` collision rule + +Suffix `?` on a type means *optional field* — it's part of the type vocabulary (`string?`, `datetime?`). + +Proposal `?` lives only in the comment-prefix form `# ?`. The two never collide because one is *after* the type and one is *before* a commented-out line. + +```yaml +user: + tier: enum # free | paid | trial + # ? trialEndsAt: datetime? # proposal: required when tier=trial +``` + +The `# ?` prefix marks proposal; the trailing `?` on `datetime?` marks optional type. + +## Type vocabulary (small and stable) + +Stay close to this set to keep translation to Zod/TS/SQL mechanical: + +``` +string number boolean datetime date duration +enum # values in trailing comment +literal "value" # for discriminator keys +T? # optional T +T[] # array of T +map<K, V> # keyed map +ref<T> # foreign reference +oneOf: # discriminated union +allOf: # intersection +``` + +Annotate constraints in trailing comments (`# unique`, `# >= 0`, `# regex /.../`) rather than inventing type syntax. + +## Variants + +### Discriminated union + +```yaml +event: + oneOf: + - kind: literal "login" + userId: ref<User> + ip: string + - kind: literal "logout" + userId: ref<User> + - kind: literal "purchase" + userId: ref<User> + sku: string + amount: number # cents +``` + +Each variant gets a discriminator field (`kind:` here) with a `literal` type. + +### Defaults, computed, readonly + +Mark in trailing comments using a small vocabulary so translation stays mechanical: + +```yaml +order: + id: string # readonly, uuid + createdAt: datetime # default: now() + slug: string # computed from title + status: enum # default: "draft" | submitted | active +``` + +### `_rules:` block (cross-field invariants) + +When the same constraint touches multiple fields, promote it. Comments become normative only when they live here. + +```yaml +user: + email: string + tier: enum # free | paid | trial + trialEndsAt: datetime? + avatarUrl: string? + +_rules: + - email is unique + - trialEndsAt required when tier = "trial" + - avatarUrl must start with "https://" +``` + +If `_rules:` grows past ~7 entries, split the shape or escape to a state-machine. + +### Refs and collections + +```yaml +team: + id: string + ownerId: ref<User> + memberIds: ref<User>[] + roles: map<ref<User>, enum> # role values in trailing _rules +``` + +### Instance / example + +Same syntax, but values replace types. Mark the block so readers don't confuse it with a schema. + +```yaml +# example: user +user: + id: "01HXYZ..." + email: "luke@example.com" + tier: "trial" + createdAt: "2026-05-30T12:00:00Z" +``` + +Pair schema + instance under separate headings (`## Shape` / `## Example`) when both are useful. + +## Annotation patterns + +- **Top-level keys ARE anchors.** `session:` is referenced from other artifacts as `#session`. No inline anchor syntax — `session: #session` would parse as `session: null` with a stray comment. +- **Diff markers live in comments** (per the YAML-validity rule). Inline before/after: + + ```yaml + user: + id: string + email: string + tier: enum # ~ was: string — restricted to enum + # + trialEndsAt: datetime? + # - legacyPlan: string + ``` + + Two conventions for inline diff: `# ~` / `# +` / `# -` as *comment prefix* for new/removed lines; `# ~ note` as *trailing comment* on a line whose type changed in place. + +- **Risk marker** as trailing comment: `amount: number # ! check rounding`. +- **Collaborative-edit comments** — the killer move. Both parties edit; file still parses. + + ```yaml + user: + email: string + # luke: lowercase on read or on write? + tier: enum # free | paid | trial + # agent: trial needs an expiry. proposal: + # + trialEndsAt: datetime? + ``` + +## Smell-to-switch tripwires + +- **Comments carry business logic.** Promote into `_rules:` or split into shape + state-machine. +- **More than ~3 `oneOf` variants with overlapping fields.** Consider an `allOf:` base plus distinct extensions. +- **Cross-field rules outnumber fields.** The model is really a state-machine or a graph; the shape is just its persistence projection. +- **Mixing schema and instance in one block.** Split into two blocks under separate headings. +- **The same nested structure appears in three+ places.** Extract a named shape with `#id` and reference it. + +## Anti-patterns + +- **Inventing type syntax** (`string<lowercase>`, `int(>=0)`). Use the small type vocabulary plus trailing-comment constraints. +- **Re-using `?` for both optional and uncertain in the same block.** Always ambiguous. Use the `# ?` comment-prefix convention for proposals. +- **Trailing comments doing real work.** If the comment changes whether code is correct, it's a rule, not a comment. +- **One mega-shape.** If a shape needs more than ~15 fields, it's probably two shapes plus a reference. +- **Mixing styles between siblings.** `userId: string` in one place and `user_id: String` in another. Pick one casing per artifact. + +## Escape hatches + +- **Multiple shapes linked by `#id`** — almost always beats one large shape. +- **Real schema language** (Zod, TS interface, JSON Schema) — once the shape stabilizes, translate. Pseudo data-shape is a *negotiation form*, not a production artifact. +- **Pair shape + state-machine + `_rules:`** when the data model is genuinely stateful; don't try to encode lifecycle in optionality. + +## Worked example: full negotiation cycle + +Starting sketch by the user: + +```yaml +session: + id: string + userId: string + createdAt: datetime + expiresAt: datetime +``` + +Agent's response — proposed changes inline, valid YAML preserved: + +```yaml +session: + id: string # uuid + userId: ref<User> # was: string + createdAt: datetime # default: now() + expiresAt: datetime + # agent: tier-dependent expiry. proposal: + # + tier: enum # free | paid (frozen at session create) + # + revokedAt: datetime? # nullable; set on logout + # rules? + # _rules: + # - expiresAt > createdAt + # - revokedAt > createdAt when set + +# ? should sessions know about tier, or look up via userId? +``` + +Agreed final form: + +```yaml +session: + id: string # uuid, readonly + userId: ref<User> + createdAt: datetime # default: now() + expiresAt: datetime + revokedAt: datetime? # set on logout + +_rules: + - expiresAt > createdAt + - revokedAt > createdAt when set + - lookup tier via User.tier (not denormalized here) +``` + +The top-level key `session:` is its own anchor. A state-machine can reference it as `#session` (`active -[expire]-> expired # of #session`) and a graph can place it as `#session` (`auth -> #session -> store`). diff --git a/.agents/skills/pseudo/references/graph.md b/.agents/skills/pseudo/references/graph.md new file mode 100644 index 00000000..38f7c5a2 --- /dev/null +++ b/.agents/skills/pseudo/references/graph.md @@ -0,0 +1,248 @@ +# Pseudo: Graph + +Captures **fan-in, fan-out, cycles, and typed dependencies** between nodes. Use when the relations between things are the point — not their containment (`tree`), linear order (`chain`), or interaction across actors (`lanes`). + +## When to use + +- Multiple inputs feed one node, or one node feeds multiple outputs. +- Cycles or feedback edges exist. +- Edge labels carry meaning (guards, conditions, retry semantics). +- Node *types* matter (service vs queue vs store vs trigger). + +## When NOT to use + +- The relations are strictly hierarchical → **tree**. +- One actor does one thing at a time in order → **chain**. +- Multiple actors hand off requests/responses → **lanes**. +- Edges are dense enough that adjacency would be clearer → **matrix** (adjacency variant). + +## Canonical form + +**Paired node-list + edge-list.** ASCII boxes are a rendering; lists are the source. Boxes don't diff cleanly and one rename breaks the layout — lists diff line-by-line and grow gracefully. + +This is a bespoke line-grammar, not YAML. Node lines are `name [: type] [[tag]] [#anchor]`. Edge lines are `src <edge> dst [# note]`. Multi-source or multi-target shorthand: `a, b -> c` or `a -> b, c` (commas separate node names; node names are bare words). + +When all nodes share the same kind (a dependency graph of frontier items; a tree of React components; a set of lifecycle hooks), **omit the type entirely** — `name` or `name [tag]` is enough. Type is for when node *classes* carry meaning and the reader needs to keep them separate. + +``` +nodes: + http: trigger + cron: trigger + proc: handler + log: sink + cache: store + notify: worker + done: terminal + +edges: + http -> proc + cron -> proc + proc -> log + proc ~> cache # async + proc ~> notify # async + log, cache, notify -> done +``` + +Column alignment within `nodes:` is reader-friendly but not load-bearing — the colon is the separator. + +## Variants + +### Typed-node graph + +When node classes matter, declare them explicitly so the reader doesn't conflate a service with a state with a table. The type slot is free text — pick a small vocabulary per artifact (`service`, `queue`, `store`, `job`, `trigger`, `sink`, `terminal` is a reasonable starter set). + +``` +nodes: + api: service + queue: queue + worker: job + db: store +``` + +### Labeled / guarded edges + +``` +proc -[if cached]-> done +proc -[if miss]-> fetch +fetch x[on error]-> retry +``` + +`-[label]->` for conditions and guards; `x[label]->` for error or fallback transitions. The bracketed form is parallel for both; the leading `x` flags the error semantic. + +### Subgraph / cluster + +Group nodes when topology has obvious regions. Top-level nodes and groups live under separate keys; edges still live in the flat list (groups are organizational, not semantic). + +``` +nodes: + proc: handler + done: terminal + +groups: + ingest: + http: trigger + cron: trigger + outputs: + log: sink + cache: store + notify: worker +``` + +### Multiplicity + +``` +client[*] -> api +proc -> notify[*] +``` + +### Cycles / feedback + +Allowed in `graph`. Mark explicitly with a label so readers don't read it as a typo. + +``` +queue ~> worker +worker -[retry on fail]-> queue +``` + +### Dependency-graph edges (static, not runtime) + +The default edge vocabulary (`->` sync, `~>` async, `x>` error) is tuned for **runtime flow**. For **static dependencies** — "X must precede Y," "Y is an optional successor of X" — use the labeled form so the dependency type stays explicit: + +``` +edges: + pi-ui-extension-patterns -[hard]-> sealed-pi-profile-runtime-state + sealed-pi-profile-runtime-state -[hard]-> graph-data-plane + agent-graph-integration -[optional]-> subagents-for-proposal-diversity + graph-data-plane -[on promotion]-> oracle-design-plan-graphs +``` + +A small starter vocabulary: `-[hard]->`, `-[optional]->`, `-[on promotion]->`, `-[blocking]->`. Pick the smallest set the artifact needs and declare it in a legend if it grows past ~4. + +Don't overload `~>` for "soft dependency" — it means *async runtime edge* everywhere else and the collision is confusing. + +### Adjacency matrix (dense graphs) + +When edge-list grows past ~30 edges and the graph is densely connected, escape to **matrix** with rows = source, cols = target. Keep the node list intact above the matrix. + +## Annotation patterns + +- `#id` on a node makes it linkable from other artifacts: `proc: handler #proc` so a state-machine transition can say `pending -[handled by #proc]-> active`. Anchor follows the type (or the name, when type is omitted). +- Right-gutter `# note` for local context. Promote into the edge label if the note changes routing. +- **Unconnected nodes** (no edges in or out) belong in a named group so readers don't search for missing edges. Common names: `unconnected`, `for-acknowledgment`, `horizon`. Use when a node exists in the picture for completeness — e.g. horizon items in a roadmap dependency graph — but doesn't participate in the active relations. +- `?`, `!`, `+`, `-`, `~` markers on node or edge lines work the same as everywhere else (uncertain, risk, added, removed, changed). +- `notes:` / `open:` footer keyed by anchors for discussion that doesn't belong on the line itself: + + ``` + open: + - #notify: confirm delivery guarantee — at-least-once or exactly-once? + - #cache: TTL still TBD + ``` + +## Smell-to-switch tripwires + +- **Node types mixed arbitrarily.** A service, a state, a DB table, and a file path in one untyped graph — picture looks coherent, isn't. Fix: type the nodes, or split into multiple typed graphs. +- **"When" matters more than "what depends on what."** You keep wanting to say "first this, then that, then later that." → **chain** or **lanes**. +- **Edge labels carry most of the meaning.** The graph degenerates into a wiring diagram for the labels. → **state-machine** or **matrix**. +- **More than one actor.** "API calls worker calls DB" — ownership boundary matters. → **lanes**. +- **The graph keeps gaining edges with new prose.** → split into subgraphs linked by `#id`, or escape to Mermaid. + +## Anti-patterns + +- **Drawing boxes by hand for >~5 nodes.** Source becomes unmaintainable; one rename breaks the whole layout. Use node/edge lists. +- **Mixing `dag` and graph semantics.** This skill family is `graph` and cycles are allowed. If you mean strictly acyclic, say so in a comment (`# acyclic`); don't rely on ASCII layout to imply it. +- **Implicit edge types.** `->` everywhere when half should be `~>` (async) or `x>` (error). The reader will infer wrong defaults. +- **One mega-graph.** If the legend grows past ~7 node types, you have two graphs, not one. + +## Escape hatches + +- **Mermaid** — when spatial layout itself carries meaning (e.g. swim arrangement, geographic clustering) or when readers struggle to trace edges in text. Offer the user the option; don't decide unilaterally. +- **Tree + cross-refs** — when 80% of the relations are containment and 20% are non-tree. A tree with a few `->#anchor` cross-refs is easier to read than a graph. +- **Multiple small graphs** linked by `#id` beats one large one almost always. + +## Worked example: trigger-and-fanout pipeline + +``` +nodes: + http: trigger + cron: trigger + proc: handler #proc + log: sink + cache: store + notify: worker + done: terminal + +edges: + http, cron -> proc + proc -> log + proc ~> cache # async, fire-and-forget + proc ~> notify # async, at-least-once + log, cache, notify -> done + +open: + - #proc: idempotency key shape still TBD +``` + +## Worked example: roadmap dependency graph (untyped nodes, static edges) + +Exercises three patterns: type omitted (all nodes are frontier items), dependency-graph edge labels, and the `unconnected` group for horizon items. + +``` +nodes: + pi-ui-extension-patterns [in-progress] + sealed-pi-profile-runtime-state [not-started] + graph-data-plane [paused] + agent-graph-integration [not-started] + subagents-for-proposal-diversity [deferred] + authority-model [not-started] + turn-boundary-reconciliation [not-started] + coherence-first-class [not-started] + compaction-and-conflict-widening [not-started] + probes-and-transcripts-evolution [continuous, parallel] + +edges: + pi-ui-extension-patterns -[hard]-> sealed-pi-profile-runtime-state + sealed-pi-profile-runtime-state -[hard]-> graph-data-plane + graph-data-plane -[hard]-> agent-graph-integration + agent-graph-integration -[hard]-> authority-model + agent-graph-integration -[hard]-> turn-boundary-reconciliation + agent-graph-integration -[optional]-> subagents-for-proposal-diversity + turn-boundary-reconciliation -[hard]-> coherence-first-class + coherence-first-class -[hard]-> compaction-and-conflict-widening + graph-data-plane -[on promotion]-> oracle-design-plan-graphs + +groups: + unconnected: + flue-pattern-adoption + oracle-design-plan-graphs + framework-direction-stubs + geolog-and-petri-execution + +notes: + - probes-and-transcripts-evolution runs in parallel across all frontiers; not a spine edge. + - unconnected items are surfaced for acknowledgment, not active dependency. + +open: + - confirm whether sealed-pi-profile-runtime-state -[optional]-> subagents matters + (sandbox sealing precedes subprocess fan-out, even if m5 is the primary gate). +``` + +## Worked example: retry loop with typed nodes + +``` +nodes: + api: service + queue: queue #q + worker: job + dlq: queue + store: store + +edges: + api -> queue + queue ~> worker + worker -> store + worker x[after 3 retries]-> dlq + worker -[on retry]-> queue # explicit cycle + +notes: + - #q: visibility timeout = 30s +``` diff --git a/.agents/skills/pseudo/references/lanes.md b/.agents/skills/pseudo/references/lanes.md new file mode 100644 index 00000000..64de35f4 --- /dev/null +++ b/.agents/skills/pseudo/references/lanes.md @@ -0,0 +1,212 @@ +# Pseudo: Lanes + +Captures **actors over time** — request/response, async handoff between services, conversational protocols, ownership boundaries. The family for the relation `chain` and `graph` can't cleanly express: *who* does *what*, *when*, *to whom*. + +## When to use + +- More than one actor or system in the flow. +- Request/response order matters. +- Async handoff with timing implications. +- Conversational protocol (client ↔ server, multi-party). +- Chain is collapsing because lane boundaries matter ("who owns this step?"). + +## When NOT to use + +- Single-actor sequence → **chain**. +- Pure dependency structure, time doesn't matter → **graph**. +- Lifecycle of a single resource (its named modes) → **state-machine** on that actor. + +## Canonical form + +One message per line, in time order. Each line is `sender <edge> recipient: message [#anchor]`. Actor names are bare words. + +``` +client -> api: POST /login #S1 +api -> db: SELECT user #S2 +api -> hasher: verify(password) #S3 +api ~> mailer: send notification #S4 # async +api <- client: 200 SessionDTO #S5 +``` + +Edge types (same set as elsewhere): + +- `->` synchronous request +- `~>` async / fire-and-forget +- `<-` response (sender on left = responder) +- `x>` error / rejection / timeout + +## Variants + +### Numbered messages + +`#Sn` step anchors let other artifacts reference specific messages. Use when cross-referencing matters — e.g. a state-machine transition triggered by `#S3`, or a chain that handles the `#S2` response. + +### With actor declarations + +Declare actors when type matters or names need disambiguation. Same pattern as `graph` node typing. + +``` +actors: + client: browser + api: service + worker: job + db: store + mailer: external + +messages: + client -> api: POST /login #S1 + api -> db: SELECT user #S2 + api -> client: 202 accepted #S3 + api ~> worker: enqueue(notify) #S4 + worker ~> mailer: send #S5 +``` + +### Parallel / async fork + +Async messages on the same originating step don't need to wait. Stack them without indenting. + +``` +api -> db: INSERT order #S1 +api ~> mailer: confirmation #S2 # parallel async +api ~> ledger: record #S3 # parallel async +api <- client: 202 accepted #S4 +``` + +If join semantics matter (waiting for both async results), escape to **graph** with explicit join. + +### Loops and conditional blocks + +Group messages under a labeled block. Indent the block body by two spaces. + +``` +client -> api: POST /upload #S1 + +loop while not complete: + client -> api: PUT chunk N #S2 + api -> client: 200 ack #S3 + +client -> api: POST /commit #S4 +api -> client: 201 created #S5 +``` + +``` +client -> api: POST /login #S1 + +alt success: + api -> client: 200 SessionDTO #S2a +alt invalid: + api -> client: 401 unauthorized #S2b +alt locked: + api -> client: 423 locked #S2c +``` + +### Time annotations + +When relative or absolute time matters, use trailing comments: + +``` +client -> api: POST /charge #S1 +api -> psp: capture #S2 # blocking, ≤2s budget +api ~> ledger: record #S3 # async +api -> client: 200 ok #S4 # T+~300ms typical +``` + +## Annotation patterns + +- **`#Sn` step anchors** for cross-reference from other artifacts. +- **`actors:` block** for typed actor declarations when needed. +- **`# note`** trailing comments for local context. +- **Diff markers `+` / `-` / `~`** as line prefix when comparing versions of a protocol. +- **`?` for uncertain timing/order, `!` for risk** (e.g. "this leaks PII over the wire"). +- **`notes:` / `open:` footer** keyed by step anchors. + +``` +notes: + - #S2: PSP capture is the only blocking dependency in the hot path + - #S3: ledger write is eventually consistent; reconcile via separate job + +open: + - confirm idempotency key flows through #S2 → PSP +``` + +## Smell-to-switch tripwires + +- **Single actor** — only one column has any messages → **chain**. +- **Time genuinely doesn't matter**, only dependency → **graph**. +- **An actor has rich named modes** (its behavior depends on its state) → **state-machine** on that actor, referenced from lanes. +- **Many actors with similar roles** (e.g. `worker1`, `worker2`, `worker3`) → typed **graph** with multiplicity, not lanes. +- **Sequence becomes a tangle of arrows crossing back and forth** → escape to Mermaid `sequenceDiagram` or split scenarios. + +## Anti-patterns + +- **Sync and async with the same arrow.** Use `->` / `~>` / `x>` consistently. +- **Omitting the response** for synchronous calls. Reader can't tell if it's fire-and-forget. +- **Mixing logical actors and physical processes** in one diagram (`Frontend` next to `nginx worker #3`). Pick one level. +- **Too-fine granularity** — every internal function call as a message. Lanes are for cross-actor messages; intra-actor calls belong in a **chain**. +- **Too-coarse granularity** — `client -> server: do the thing`. Useless. Name the message. + +## Escape hatches + +- **Mermaid `sequenceDiagram`** when actor count > 5 or message count > 20, or when activation/lifeline rendering would actively help. +- **Multiple lanes diagrams** for distinct scenarios (login vs renewal vs error-recovery). Linked by anchor. +- **State-machine on one actor** + lanes around it, when that actor's modes drive the protocol. +- **Graph + lanes pair** — the graph shows static dependencies; lanes show the runtime conversation across the same actors. + +## Worked example: OAuth login + +``` +actors: + user: human + app: service # our service + provider: external # OAuth provider + db: store + +messages: + user -> app: click "Login with Provider" #S1 + app -> user: 302 to provider/authorize #S2 + user -> provider: GET authorize?... #S3 + provider -> user: consent screen #S4 + user -> provider: approve #S5 + provider -> user: 302 to app/callback?code=... #S6 + user -> app: GET /callback?code=... #S7 + app -> provider: POST token (exchange code) #S8 + app <- provider: { access_token, id_token } #S9 + app -> db: upsert user #S10 + app ~> ledger: log auth event #S11 # async + app -> user: 302 to /home + session cookie #S12 + +notes: + - #S8: server-to-server; never expose code or secret to the browser. + - #S11: failure here does not block login. + +open: + - PKCE flow variant — separate diagram? +``` + +## Worked example: retry with backoff between services + +``` +actors: + api: service + worker: job + db: store + dlq: queue + +messages: + api -> worker: enqueue(task #t42) #S1 + worker -> db: UPDATE task running #S2 + worker x> db: transient error #S3 # attempt 1 + worker -> worker: backoff 1s #S4 + worker x> db: transient error #S5 # attempt 2 + worker -> worker: backoff 2s #S6 + worker -> db: UPDATE task success #S7 # attempt 3 ok + worker ~> api: callback complete #S8 + +# Failure path (alternative scenario, ≥3 attempts fail): + worker x> db: transient error #S5' + worker -> dlq: push task #t42 #S6' + worker ~> api: callback failed #S7' + +notes: + - retry counts and backoff schedule live in the worker config, not in this diagram +``` diff --git a/.agents/skills/pseudo/references/matrix.md b/.agents/skills/pseudo/references/matrix.md new file mode 100644 index 00000000..c58e4801 --- /dev/null +++ b/.agents/skills/pseudo/references/matrix.md @@ -0,0 +1,193 @@ +# Pseudo: Matrix + +Captures **n×m relationships in a compact grid** — options × criteria, conditions × actions, responsibilities × steps, scenarios × subsystems, source × target. The same pipe-delimited form, but the *semantics* vary by sub-form. Be explicit about which sub-form you're using. + +## When to use + +- Comparing options against a fixed set of criteria. +- Specifying rules as conditions → actions. +- Capturing who-does-what across steps (responsibility). +- Tracking coverage of scenarios across subsystems. +- Adjacency for dense graphs. + +## When NOT to use + +- Cells need sentences, not tokens → split into smaller matrices, or escape to prose. +- Row order secretly encodes time or priority → **chain** or **state-machine**. +- Rules nest with conditional sub-branches → decision tree (not in this typology — escape to prose or split into multiple matrices). + +## Canonical form + +Pipe-delimited grid: header row, separator row, body rows. Cell values are tokens, not sentences. + +``` + | tree | chain | graph | matrix +------------------|------|-------|-------|-------- +hierarchy | + | . | ~ | . +linear flow | . | + | ~ | . +fan-in / fan-out | . | . | + | . +n×m comparison | . | . | . | + +diff-friendly | + | + | - | + +``` + +ASCII-friendly cell vocabulary: + +``` ++ strong fit / yes / required +~ partial / depends / optional +- poor fit / no / forbidden +? unknown / TBD +. intentionally blank (vs typo or unconsidered) +``` + +The `.` is doing real work: it distinguishes *"considered and not applicable"* from *"haven't thought about it"* (truly blank). Use it. + +## Sub-forms (semantically distinct) + +### Comparison grid (options × criteria) + +Rows are options; columns are criteria; cells are fit values. No ordering implied; no execution semantics. The canonical-form example above is a comparison grid. + +### Decision table (conditions → actions) + +Rows are rules; left columns are conditions; columns prefixed with `→` are actions/outputs. **Always declare the match policy** above the table: + +``` +policy: first-match + +rule | tier | age | → action | → notify +-----|-------|--------|-------------------|---------- +R1 | free | <30d | allow | none +R2 | free | ≥30d | prompt-upgrade | banner +R3 | trial | <14d | allow | countdown +R4 | trial | ≥14d | block | email +R5 | paid | any | allow | none +``` + +Match policies: + +- **first-match** — rows in order; first matching row wins. Gives priority semantics. +- **exclusive** — at most one rule matches; overlapping rules are a bug. +- **cumulative** — all matching rules apply; effects compose. + +Without `policy:`, readers can't tell whether you mean priority, partitioning, or composition. + +Use `rule | ...` first column with `#R1`-style IDs so transitions in a state-machine or steps in a chain can reference rules by anchor. + +### Responsibility matrix (steps × actors) + +Steps as rows; actors as columns. Standard RACI vocabulary (`R` responsible, `A` accountable, `C` consulted, `I` informed), or simpler `R` / `.` if only ownership matters. + +``` +step | api | worker | ops +--------------|-----|--------|----- +enqueue | R | . | . +process | . | R | C +retry | . | R | A +dead-letter | . | . | R +``` + +### Coverage matrix (scenarios × subsystems) + +Scenarios as rows; subsystems as columns; `+` marks involvement. Used for test coverage planning, change-impact analysis, regression scope. + +``` +scenario | auth | billing | email +----------------|------|---------|------ +signup | + | . | + +renewal | . | + | + +password reset | + | . | + +``` + +### Adjacency matrix (dense graphs) + +Escape hatch from **graph** when edge-list exceeds ~30 edges in a densely connected graph. Rows = source, columns = target, cells = edge type (`+` / `~>` / `x>` / `.`). + +``` + | http | proc | log | cache | done +-------|------|------|-----|-------|------ +http | . | + | . | . | . +proc | . | . | + | ~> | . +log | . | . | . | . | + +cache | . | . | . | . | + +``` + +Keep the node-list (from `graph`) above the matrix so node types stay visible. + +## Annotation patterns + +- **`→` prefix on column headers** marks outputs (decision tables) vs inputs. +- **`rules:` / `examples:` label** at the top of the table to mark normative vs illustrative. Without this, implementers fill gaps as "don't care." +- **`policy:` above decision tables** (mandatory). +- **`#Rn` row anchors** in the first column for cross-reference from other artifacts. +- **`notes:` / `open:` footer** keyed by row anchors for per-row prose. +- **Diff markers `+` / `-` / `~` and risk `!`, uncertain `?`** can prefix the row identifier or sit in cells (where they don't collide with cell vocabulary — be careful here). + +``` +notes: + - #R4: should "block" also revoke active sessions, or only block new ones? + +open: + - confirm trial transitions are pure age-based (no grace period) +``` + +## Smell-to-switch tripwires + +- **Row order secretly encodes time** (rows are really steps) → **chain** or **state-machine**. +- **Cells need sentences, not tokens** → split into smaller matrices, or escape to prose. +- **Footnotes proliferate** to handle exceptions → the rule set is wrong; restructure. +- **Reader needs to know *why* a row matched**, not just *that* it did → use anchors + a `notes:` block, or escape to prose. +- **Decision table without policy** → ambiguous semantics; either declare policy or switch to prose. + +## Anti-patterns + +- **Decision table without `policy:`** — readers infer wrong defaults. +- **Missing `rules:` / `examples:` label** — gaps get implemented as "don't care." +- **Sentences in cells** — split or escape. +- **Mega-matrix** (>10 columns or >20 rows) — split by dimension. +- **Using blank cells and `.` interchangeably** — loses the "considered" vs "unconsidered" distinction. +- **Mixed sub-forms in one table** (comparison values mingled with decision actions). Pick one. + +## Escape hatches + +- **Decision tree** when rules nest beyond what columns can carry. (Not in this typology; render as a **chain** with guards or as prose.) +- **State-machine** when rules are stateful. +- **Prose** when narrative dominates ("under these special conditions, …"). +- **Multiple smaller matrices** linked by anchor — almost always better than one mega-matrix. + +## Worked example: decision table with first-match policy + +``` +policy: first-match +context: signup eligibility + +rule | tier | age | region | → action | → notify +-----|-------|--------|--------|-------------------|---------- +R1 | - | - | EU | require-consent | inline +R2 | trial | ≥14d | any | block | email +R3 | trial | <14d | any | allow | countdown +R4 | free | <30d | any | allow | none +R5 | free | ≥30d | any | prompt-upgrade | banner +R6 | paid | any | any | allow | none + +notes: + - #R1: applies regardless of tier; EU consent is the first gate. + - #R2-R3: trial transitions are pure age-based — confirm with product. + +open: + - what about trial users in EU on day 14? R1 should win under first-match. +``` + +## Worked example: responsibility matrix + +``` +context: production deploy steps + +step | dev | release-eng | ops | exec +------------------|-----|-------------|-----|------ +write changelog | R | . | . | . +cut release | C | R | . | I +canary deploy | C | R | A | . +full deploy | . | C | R | I +incident response | C | . | R | A +``` diff --git a/.agents/skills/pseudo/references/state-machine.md b/.agents/skills/pseudo/references/state-machine.md new file mode 100644 index 00000000..e7f2bd2d --- /dev/null +++ b/.agents/skills/pseudo/references/state-machine.md @@ -0,0 +1,240 @@ +# Pseudo: State-machine + +Captures **durable states and the transitions between them** — workflow phases, lifecycle, protocol modes. Use only when the system *stores* the named states; if it doesn't, you have a chain or a sequence pretending to be a machine. + +## When to use + +- Persistent lifecycle (`draft → pending → active → archived`). +- Protocol modes (`disconnected → connecting → connected → reconnecting`). +- Resource status that drives behavior elsewhere. +- Workflows where transitions have guards, effects, or both. + +## When NOT to use + +- "States" are actually actions (`submit`, `approve`, `notify`) → **chain**. +- Process steps that don't persist anywhere → **chain** or **lanes**. +- Multiple independent state dimensions → **multiple machines**, not one flattened machine. +- Guards dominate every transition → **matrix** decision table referenced from the machine. + +## Canonical form + +Two shapes are canonical; pick by transition complexity. + +### Arrow form (≲8 transitions, few guards) + +``` +draft -[submit]-> pending +pending -[approve]-> active +pending -[reject]-> draft +active -[archive]-> archived +active -[expire after 90d]-> expired +expired -[renew (if tier=paid)]-> active +``` + +`-[label]->` for normal transitions; `x[label]->` for failure/rejection transitions (parallels the **graph** edge syntax). + +### Table form (guards or effects multiply) + +``` +states: draft, pending, active, archived, expired + +transitions: + +from | event | to | guard | effect +---------|----------|-----------|----------------|------------------ +draft | submit | pending | | notify reviewer +pending | approve | active | hasReviewer | issue token +pending | reject | draft | | clear submission +active | archive | archived | | +active | expire | expired | age ≥ 90d | revoke token +expired | renew | active | tier = paid | reissue token +``` + +Either form is canonical; the table form scales better past ~8 transitions. + +## Variants + +### States block + transitions block (structure first) + +Declare the state set explicitly when the machine has more than ~5 states or composite states. Then transitions reference them: + +``` +states: + draft + pending + active + archived + expired + +transitions: + draft -[submit]-> pending + ... +``` + +Avoids surprise states that exist only as a typo in the transitions block. + +### Composite / nested states + +Don't flatten a hierarchy that has meaning. Declare nesting: + +``` +states: + draft + pending + active/ + normal + suspended + archived + +transitions: + active.normal -[suspend]-> active.suspended + active.suspended -[resume]-> active.normal + active -[archive]-> archived # from any sub-state +``` + +Transitions on the parent (`active`) apply from any sub-state. + +### Entry / exit / invariant hooks + +When effects always run on entering or leaving a state, hoist them out of every transition: + +``` +state: active + on-enter: issueToken + on-exit: revokeToken + invariant: hasReviewer +``` + +Then individual transitions don't need to repeat `issue token` / `revoke token`. + +### Wildcard / default transitions + +Use sparingly — `*` matches any source state: + +``` +* -[timeout]-> expired # any state can expire on timeout +* -[purge]-> archived # admin override from anywhere +``` + +### Orthogonal dimensions (multiple machines) + +When state has independent dimensions, do **not** flatten them. Define separate machines: + +``` +## connection machine +disconnected -[connect]-> connecting +connecting -[ok]-> connected +connecting x[fail]-> disconnected +connected -[drop]-> reconnecting +reconnecting -[ok]-> connected + +## auth machine +unauthenticated -[login]-> authenticated +authenticated -[logout]-> unauthenticated +authenticated -[expire]-> unauthenticated + +## sync machine +clean -[edit]-> dirty +dirty -[push]-> syncing +syncing -[ok]-> clean +syncing x[fail]-> dirty +``` + +Cross-machine guards reference other machines by name: `guard: connection.connected`. + +## Annotation patterns + +- **`#id` on states or transitions** for cross-reference. Transition IDs (`#T7`) let a matrix or chain say "see transition #T7." +- **`[tag]` column** on transition rows (table form) for metadata. +- **Diff markers `+` / `-` / `~`** as line prefix on transition lines. +- **`?` for uncertain transitions, `!` for risky/hotspot transitions.** +- **`@owner`** when ownership of a state's invariants varies. +- **`notes:` / `open:` footer** keyed by anchors. + +``` +transitions: + pending -[approve]-> active #T7 ! requires audit log + active -[expire]-> expired #T8 + +notes: + - #T7: who issues the audit entry — the API or the worker? +``` + +## Smell-to-switch tripwires + +- **"Where does this state live?"** — can't answer → it's a **chain** or **lanes**, not a state-machine. Good test. +- **State names are verbs / actions** (`submit`, `approve`, `notify`) → **chain**. +- **Orthogonal dimensions multiply states** into combos like `review_pending_billing_active_sync_dirty` → split into multiple machines. +- **Every transition has a long guard expression** → the guards belong in a **matrix** decision table; the machine references it. +- **Transitions are really "calls"** to other components → **chain** or **graph**. +- **You can't list the state set explicitly** without scanning all transitions → declare states first. + +## Anti-patterns + +- **Flattening orthogonal dimensions** into one Cartesian-product state set. Always wrong. +- **Modeling a workflow as states when the system doesn't store them.** The reader will look for the state column in the DB and find nothing. +- **Mixing event names and condition expressions** in the event column. Events name *what happened*; conditions go in `guard`. +- **Forgetting effects.** State changed AND something else happened (token issued, email sent). Capture both; effects are first-class. +- **Wildcard `*` everywhere.** If most transitions are wildcards, the structure isn't pulling its weight. + +## Escape hatches + +- **Multiple state machines** for orthogonal dimensions — almost always. +- **Matrix decision table** for guard-heavy transitions; the machine row references rule IDs. +- **Lanes** when the "states" are really protocol modes between two actors. +- **Chain** when the "states" are really sequential steps. + +## Worked example: subscription lifecycle + +``` +states: + trial + active + past_due + canceled + expired + +transitions: + +from | event | to | guard | effect +---------|-----------------|-----------|-----------------|--------------------- +trial | upgrade | active | payment ok | provision; bill +trial | expire | expired | age ≥ 14d | suspend access +active | payment_fail | past_due | | notify; retry sched +past_due | payment_ok | active | | clear flag +past_due | exhaust_retries | canceled | retries ≥ 3 | revoke access +active | cancel_user | canceled | | end of period +canceled | resubscribe | active | within 30d | reactivate; bill +canceled | purge | expired | age ≥ 30d | delete data + +notes: + - guard `within 30d`: measured from canceled-at timestamp on the row. + - `expired` is terminal in this machine; renewal goes through a fresh trial. +``` + +## Worked example: orthogonal dimensions + +``` +## sync machine (per document) + +states: clean, dirty, syncing, conflict + +clean -[local-edit]-> dirty +dirty -[push]-> syncing +syncing -[ok]-> clean +syncing x[remote-change]-> conflict +conflict -[resolve]-> dirty + +## presence machine (per user) + +states: away, active, focused + +away -[input]-> active +active -[focus-app]-> focused +focused -[blur]-> active +active -[idle 5m]-> away +focused -[idle 5m]-> away + +# These two machines are independent. Don't flatten into +# { clean+away, clean+active, ..., conflict+focused } — 12 combos no one wants. +``` diff --git a/.agents/skills/pseudo/references/tree.md b/.agents/skills/pseudo/references/tree.md new file mode 100644 index 00000000..8ca6426f --- /dev/null +++ b/.agents/skills/pseudo/references/tree.md @@ -0,0 +1,226 @@ +# Pseudo: Tree + +Captures **containment, hierarchy, decomposition** — parent/child relations where each child belongs to exactly one parent. Files in folders, components in components, sections in documents, decompositions of a problem space. + +## When to use + +- Pure containment: child cannot exist outside its parent. +- Decomposition: breaking one thing into ordered or unordered parts. +- Outline of a document, codebase, UI, decision space. +- **Obligation decomposition** — breaking a paragraph-length contract or acceptance criterion into categories with individually testable leaves. +- Before/after of any of the above (the most common pairing). + +## When NOT to use + +- Same conceptual child appears under two parents → **graph**. +- Sibling order or sibling interaction is the point → ordered-tree variant if order only; **chain** / **lanes** / **graph** if interaction. +- Parent/child looks like flow, not containment → **chain** or **graph**. + +## Canonical form + +ASCII box-drawing (or Unicode equivalent — pick one per artifact). Indentation is structural; box characters are visual aids that survive copy/paste. + +``` +auth/ +├── login.ts +├── session.ts +└── providers/ + ├── oauth.ts + └── credentials.ts +``` + +Rule of thumb: if you can answer *"can this node exist without its parent?"* with **yes**, you have the wrong family — it's probably a graph. + +## Variants + +### Annotated tree (metadata column) + +``` +auth/ +├── login.ts [entry; rate-limited] @auth-team +├── session.ts [token mint + verify] +└── providers/ + ├── oauth.ts [external; cached 5m] @auth-team + └── credentials.ts [bcrypt; pepper from env] @auth-team +``` + +Column alignment is reader-friendly but not load-bearing. + +### Delta tree (inline diff) + +`+`/`-`/`~` line markers when before/after is small enough to fit in one block — often cheaper than two stacked blocks. + +``` +auth/ + ├── login.ts [split handler + service] ~ + ├── session.ts + └── providers/ + ├── oauth.ts + ├── credentials.ts + └── magic-link.ts + +risk.ts + +legacy-auth.ts - +``` + +### Ordered tree (sibling order matters) + +Number the children explicitly: + +``` +pipeline/ +├── 1_parse +├── 2_normalize +├── 3_validate +└── 4_emit +``` + +### Cross-ref tree (mostly hierarchy, a few non-tree edges) + +When 80% of relations are containment and 20% are references, use `->#anchor` on the lines that need it. Beats switching to graph for one or two edges. + +``` +auth/ #auth +├── login.ts +└── session.ts -> #token-store + +cache/ #token-store +└── redis.ts +``` + +### Cardinality / multiplicity + +``` +providers/ [1..n] +sessions/ [0..*] per user +``` + +### Focused tree with elision + +Show the touched area plus parent context, not the whole tree. + +``` +auth/ +└── providers/ + ├── oauth.ts + └── ... (3 omitted) +``` + +## Annotation patterns + +- **`[tag]` column** for compact metadata, whitespace-aligned. +- **`#id` anchors** on nodes for cross-references from other artifacts. +- **`@owner`** for ownership when it varies across the tree. +- **`notes:` / `open:` footer** keyed by anchors for discussion that doesn't belong inline. +- **Diff markers `+` / `-` / `~`** as line prefixes or trailing markers. +- **`?` line-marker** for uncertain branches, **`!`** for risky/hotspot nodes. + +``` +notes: + - #token-store: Redis or in-memory for tests? + - #auth: all rate limits enforced at this boundary +``` + +## Smell-to-switch tripwires + +- **The same conceptual child appears under two parents.** Tree is lying. → **graph**. +- **Sibling A depends on sibling B.** → **graph** for the dependency, or **chain** if linear. +- **Order or timing between siblings matters beyond "list order."** → ordered tree if order only; **chain** / **state-machine** if timing. +- **Parent/child relation reads as "calls" or "becomes" rather than "contains."** → wrong family entirely. +- **You start drawing extra connectors between nodes at the same level.** → **graph**. + +## Anti-patterns + +- **Tree-shaped diagram of a flow.** Tree looks clean even when lying. Reach for chain or graph instead. +- **Mixing containment levels** (file → function → variable in one tree). Pick one level per tree; link levels via anchors. +- **Inventing extra connectors** between sibling or distant nodes. That's the smell; switch family. +- **One mega-tree.** Split by subsystem; link via `#id`. +- **Comments doing what the structure should** (the same "see X" note attached to many nodes). Use a `_rules:`-style footer or split the tree. + +## Escape hatches + +- **Tree + cross-refs** for 1-2 non-tree edges. +- **Multiple small trees linked by `#id`** beats one large tree almost always. +- **Graph** when containment isn't the primary relation. +- **Stacked before/after blocks** when delta-tree gets unreadable (more than ~5 markers). + +## Worked example: current vs desired UX flow + +The pattern your UX Flow Plan codifies — two trees under `## Current` and `## Desired`, with anchors attached *after* the structure is settled. + +``` +## Current + +User action: click "Forgot password" +└── System behavior: redirect to /reset + └── Existing layer: pages/reset.tsx + └── Anchor: components/ResetForm.tsx +``` + +``` +## Desired + +User action: click "Forgot password" +└── System behavior: modal opens in place + ├── New layer: useResetFlow hook + │ └── Anchor: hooks/useResetFlow.ts + └── Reused layer: components/Modal.tsx + └── Anchor: components/ResetForm.tsx (extracted as ResetModalContent) +``` + +## Worked example: obligation decomposition + +A high-value use of `tree` in this codebase is breaking a paragraph-length acceptance criterion or contract obligation into a scannable hierarchy. The tree captures *categories of what must hold*, with each leaf an individually testable obligation. + +Source: a single sentence with ~12 semicolon-separated acceptance clauses for a milestone integration. + +``` +agent-graph-integration acceptance: +├── command-routing +│ ├── agent CRUD → CommandExecutor +│ ├── elicitor capture → CommandExecutor +│ ├── reviewer writes → CommandExecutor (target: #reconciliation_need only) +│ └── acceptReviewSet: batch atomic (one LSN, one change-log entry) +├── exchange entries (custom) +│ ├── brunch.establishment_offer [must carry: lens] +│ └── brunch.elicitor_intent_hint [must carry: lens] +├── capture rules +│ ├── high-confidence extractive facts → commit +│ ├── readiness/posture updates → commit +│ └── low-confidence implications → stay in preface +├── proposal rules +│ ├── carry support/grounding coverage +│ ├── carry epistemic_status +│ └── only dry-run-valid → reviewable review-set +├── reviewer policy +│ ├── advisory only (writes only #reconciliation_need) +│ └── initial POC trigger/scope recorded in docs/tests (not implicit) +├── architectural invariants +│ ├── no direct DB access +│ ├── no caller-side authority bypass outside command layer +│ └── reviewer write-target boundary enforced +├── cross-surface +│ └── same change observed across TUI and web +└── async substrate (conditional) + └── if observer/auditor queues land → backstops only, not primary capture +``` + +Each leaf is small enough to become one test or one assertion. The categories let a reviewer scan for missing dimensions (e.g. "did we cover the cross-surface obligation?") rather than re-parsing a sentence. + +## Worked example: delta tree for a small refactor + +``` +auth/ + ├── login.ts ~ # split into handler + service + │ ├── handler.ts + + │ └── service.ts + + ├── session.ts + ├── providers/ + │ ├── oauth.ts + │ └── credentials.ts + └── magic-link.ts + + +legacy-auth.ts - + +open: + - confirm session.ts stays a single module after split +``` diff --git a/memory/PLAN.md b/memory/PLAN.md index ffea7c36..34b19870 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,7 +14,7 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved both the raw Pi RPC editor fallback for structured exchanges and the public Brunch JSON-RPC assistant-first structured-exchange permutation parity run. The remaining FE-744 seams are web real-time observation of structured exchanges and branded/themed chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved raw Pi RPC editor fallback, public Brunch JSON-RPC assistant-first structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private Brunch prompt-pack topology, and the Zod-authored structured-exchange details schema layer. The remaining FE-744 seam is branded/themed visual chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure @@ -113,7 +113,38 @@ The POC should maximize assumption falsification rather than merely implement mi - **Kind:** structural - **Status:** not-started - **Objective:** Brunch installs graph tools through pi's extension seams; agent graph operations, elicitor post-exchange capture writes, reviewer-attributed advisory writes, review-set batch acceptances, spec readiness grade/posture updates, and the transcript-native establishment/intent-hint surfaces all route exclusively through the Brunch-owned command layer and shared event substrate; web, TUI, and agent all observe the same changes. -- **Acceptance:** Agent can create / update / link intent-plane nodes via Brunch tools that call the `CommandExecutor`; elicitor turns emit `brunch.establishment_offer` and `brunch.elicitor_intent_hint` entries with the lens/routing metadata needed by downstream consumers; post-exchange capture can process a projected elicitation exchange synchronously, commit high-confidence extractive facts/readiness updates, and keep low-confidence implications in structured-exchange preface/question material; batch proposals and commitment review sets carry explicit support/grounding coverage plus `epistemic_status`, and only dry-run-valid proposals surface as reviewable review sets; a reviewer job can process an accepted review set and surface advisory `reconciliation_need` findings (only) via the same executor; the `acceptReviewSet` command commits a cohesive batch atomically as one LSN and one change-log entry; the initial POC reviewer trigger/scope policy is recorded in implementation docs/tests rather than left implicit; an architectural test or lint rule prevents direct DB access, caller-side authority bypass outside the command layer, and reviewer-attributed writes to anything other than `reconciliation_need`; the same change observed across TUI and web client; if async observer/auditor queues land, they are backstops rather than the primary capture freshness path. +- **Acceptance:** + + ```text + acceptance: + ├── command-routing through CommandExecutor + │ ├── agent CRUD on intent-plane nodes (create / update / link via Brunch tools) + │ ├── elicitor capture (post-exchange, synchronous) + │ ├── reviewer writes (target restricted to `reconciliation_need`) + │ └── acceptReviewSet commits batch atomically (one LSN, one change-log entry) + ├── exchange entries (custom) + │ ├── brunch.establishment_offer [must carry: lens/routing metadata] + │ └── brunch.elicitor_intent_hint [must carry: lens/routing metadata] + ├── capture rules (post-exchange, synchronous) + │ ├── high-confidence extractive facts → commit + │ ├── readiness updates → commit + │ └── low-confidence implications → stay in structured-exchange preface/question material + ├── proposal rules + │ ├── carry explicit support/grounding coverage + │ ├── carry epistemic_status + │ └── only dry-run-valid proposals surface as reviewable review sets + ├── reviewer policy + │ ├── advisory only (writes only `reconciliation_need`) + │ └── initial POC reviewer trigger/scope policy recorded in implementation docs/tests (not implicit) + ├── architectural invariants (lint or test) + │ ├── no direct DB access + │ ├── no caller-side authority bypass outside command layer + │ └── reviewer cannot write anything other than `reconciliation_need` + ├── cross-surface observability + │ └── same change observed across TUI and web client + └── async substrate (conditional) + └── if observer/auditor queues land → backstops only, not primary capture freshness path + ``` - **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing through targeted probe runs; first batch-proposal probe (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in probe metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem probe scenarios exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L, D50-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L, I33-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L @@ -203,15 +234,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection notes, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/`, and the Zod-authored structured-exchange schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` have landed. Current missing product seam is visual chrome recovery.) +- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection comments, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/`, and the Zod-authored structured-exchange schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` have landed. Current missing product seam is visual chrome recovery.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/note/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/comment/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. -- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user notes for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). +- **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user `comment` fields for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D41-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I26-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** Public RPC structured-exchange parity now speaks tuple-shaped transcript truth rather than the retired lightweight `brunch.elicitation_prompt` / `brunch.elicitation_response` loop: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; and `src/probes/public-rpc-parity-proof.ts` drives the current deterministic structured-exchange permutation set from a fresh cwd through public Brunch JSON-RPC only. The hardened proof checks each tuple instance's present-before-request ordering, rejects repeated deterministic prompts, closes matching `cancelled` and `unavailable` request tuples as terminal, preserves option `content` plus optional `rationale` through pending/proof projections, and can persist a review bundle under `.fixtures/runs/public-rpc-parity/<run-id>/` containing `session.jsonl`, rendered `transcript.md`, and `report.json` (the committed seed run is `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/`). The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are now specified as transcript-native ANALYSIS toolResults that are transcript-visible and TUI-hidden/collapsed. A Zod-authored schema layer now lives under `src/tui-client/.pi/extensions/structured-exchange/schemas/` with JSON Schema export tests for the captured present/request/capture details contract; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The same-assistant-message `present_options → request_choice` ordering proof has landed: a real Pi RPC run with sequential tools proves present result before request UI and present JSONL toolResult before request JSONL toolResult, with the caveat that RPC may emit the request UI before `request_choice` `tool_execution_start`. The Brunch extension shell is explicit again: production wiring now uses a statically ordered registry in `src/tui-client/pi-extension-shell.ts`, with filesystem discovery / local metadata / `loadOrder` retired while default extension exports remain for dev `/reload` iteration. Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`: `registerBrunchPrompting` appends deterministic code-composed prompt packs from the explicit shell, and `operational-mode.ts` remains responsible for runtime state/tool policy rather than prompt text duplication; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: harden the parity artifact witness/report envelope, then harden the transcript renderer's default Brunch-semantic view (skipping generic tool results unless raw/debug is requested). Run a separate `ln-design` pass before implementing `capture_analysis` details schema or shared transcript component subparts. Then return to branded chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. +- **Current execution pointer:** FE-744 has landed the public-RPC structured-exchange parity spine and its hardening: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; `src/probes/public-rpc-parity-proof.ts` drives the deterministic permutation set through public Brunch JSON-RPC only; and the committed run under `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` carries `session.jsonl`, rendered `transcript.md`, and `report.json`. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are specified as transcript-native ANALYSIS toolResults. The Zod-authored schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` now captures the target present/request/capture details contract (`schema` + `v`, `exchange_id`, `tool_meta`, `comment`/`message`, candidate rubrics/graph refs, and minimal no-graph capture details) with parse and JSON Schema export tests; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The Brunch extension shell is explicit again, and Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: return to branded/themed chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. Run a separate `ln-design` pass before expanding capture-analysis payloads, shared transcript component subparts, or the runtime migration from tuple details to the new Zod exports. ### flue-pattern-adoption @@ -271,26 +302,38 @@ Older history: `docs/archive/PLAN_HISTORY.md` ## Dependencies ```text -pi-ui-extension-patterns (active FE-744) - │ - └── sealed-pi-profile-runtime-state - │ - ├── graph-data-plane - │ │ - │ ├── agent-graph-integration - │ │ │ - │ │ ├── authority-model - │ │ │ - │ │ └── turn-boundary-reconciliation - │ │ │ - │ │ └── coherence-first-class - │ │ │ - │ │ └── compaction-and-conflict-widening - │ │ - │ └── (oracle-design-plan-graphs — horizon) - │ - └── subagents-for-proposal-diversity (optional after M5 pressure) - -probes-and-transcripts-evolution remains parallel/continuous. -flue-pattern-adoption, framework-direction-stubs, and geolog-and-petri-execution are horizon items, not on the active dependency spine. +nodes: + pi-ui-extension-patterns [in-progress] + sealed-pi-profile-runtime-state [not-started] + graph-data-plane [paused] + agent-graph-integration [not-started] + subagents-for-proposal-diversity [deferred] + authority-model [not-started] + turn-boundary-reconciliation [not-started] + coherence-first-class [not-started] + compaction-and-conflict-widening [not-started] + probes-and-transcripts-evolution [continuous, parallel] + +edges: + pi-ui-extension-patterns -[hard]-> sealed-pi-profile-runtime-state + sealed-pi-profile-runtime-state -[hard]-> graph-data-plane + graph-data-plane -[hard]-> agent-graph-integration + agent-graph-integration -[hard]-> authority-model + agent-graph-integration -[hard]-> turn-boundary-reconciliation + agent-graph-integration -[optional]-> subagents-for-proposal-diversity + turn-boundary-reconciliation -[hard]-> coherence-first-class + coherence-first-class -[hard]-> compaction-and-conflict-widening + graph-data-plane -[on promotion]-> oracle-design-plan-graphs + +groups: + unconnected: + flue-pattern-adoption + oracle-design-plan-graphs + framework-direction-stubs + geolog-and-petri-execution + +notes: + - probes-and-transcripts-evolution runs in parallel across all frontiers; not a spine edge. + - unconnected items are horizon work; surfaced for acknowledgment, not active dependency. + - the m5 -> subagents edge is `optional` — subagents is never a blocker for the spine. ``` From b99d94eb75e86ae8158085c2f321bdab78b83a14 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 10:43:37 +0200 Subject: [PATCH 157/164] ln-judo-review: cross-link to pseudo for before/after artifacts of code-judo moves Amp-Thread-ID: https://ampcode.com/threads/T-019e77c1-cc1c-73db-a4ab-d1e9bec3be5e Co-authored-by: Amp <amp@ampcode.com> --- .agents/skills/ln-judo-review/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/ln-judo-review/SKILL.md b/.agents/skills/ln-judo-review/SKILL.md index 689db0f5..c6800522 100644 --- a/.agents/skills/ln-judo-review/SKILL.md +++ b/.agents/skills/ln-judo-review/SKILL.md @@ -44,6 +44,8 @@ Functional core / imperative shell (Gary Bernhardt): when independent work is ne If yes, name it. Do not settle for a cleaner version of the same messy idea when a much simpler idea is plausible. +**Notation aid.** When proposing a code-judo move, express it as paired `pseudo` artifacts — current shape (`tree` for module structure, `graph` for control/dependency, `chain` for call flow) → desired shape with the deleted branches, helpers, modes, or layers visibly absent. A concrete before/after pair shows whether complexity actually *vanishes* rather than relocates — which is the whole point of judo over rearrangement. Node/edge counts before vs after are honest metrics: a desired-state graph with fewer nodes and fewer edges than the current one is the artifact form of "deletions over rearrangements." + ## Tone Direct, serious, demanding. Not rude. Do not soften major maintainability issues into mild suggestions. Worked examples of the register: From 7ec027b9725aec97651a6b8dac571b7ce62edc1b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 11:04:47 +0200 Subject: [PATCH 158/164] post-skill sync, after pseudo stuff added --- docs/architecture/pi-ui-extension-patterns.md | 18 +- memory/CARDS.md | 218 ++++++++++++++++++ memory/PLAN.md | 14 +- memory/SPEC.md | 49 ++-- 4 files changed, 258 insertions(+), 41 deletions(-) create mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 7d32b69c..02495002 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -11,10 +11,10 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Branch-flow effect blocking (`/fork`, `/clone`, `/tree`) | proven for lifecycle/API effect cancellation; residual pre-cancel UI exposure remains | required for I19-L and already partly used by Brunch | source audit + raw RPC probe | | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | -| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | +| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting; branded/themed full-Brunch visual closeout still active | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | | Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `src/probes/scripts/verify-startup-no-resume.sh` pty probe oracle | | In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | -| Structured-exchange response loop | partially proven; product relay pending | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests | +| Structured-exchange response loop | proven for current deterministic public-RPC permutations and web observation; review/candidate/capture runtime migrations deferred | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests + public-RPC parity artifacts + web live-update tests | ## Evidence inventory @@ -25,6 +25,8 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-exchange RPC oracle:** `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. +- **Public Brunch RPC parity oracle:** `src/probes/public-rpc-parity-proof.ts` now drives the deterministic structured-exchange permutation set through Brunch JSON-RPC only (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.respond`) and persists `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/{session.jsonl,transcript.md,report.json}` as reviewable tuple-parity evidence. +- **Web observation oracle:** WebSocket subscription tests now prove RPC-originated structured-exchange mutations notify browser clients, which then refetch canonical projection handlers rather than reading a parallel view store. ## Command inventory and containment matrix @@ -130,7 +132,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/tui-cl - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. - `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/tui-client/.pi/components/*`. -- `structured-exchange/` owns the remodeled present/request structured-exchange tool family; the active registry currently exposes `present_question`, `present_options`, `request_answer`, and `request_choice`, while review/candidate/multi-choice modules are stubs until their product flows land. +- `structured-exchange/` owns the remodeled present/request structured-exchange tool family; the active registry currently exposes `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`, while review/candidate modules are stubs until their product flows land. `renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: @@ -227,9 +229,9 @@ allowedBuiltInCommands: ["compact", "reload", "quit"] The policy must run before interactive-mode built-in dispatch and before autocomplete construction. Ideally it should also expose a keybinding-action policy for `app.model.*` and `app.session.*` actions so keyboard paths cannot bypass slash visibility. -## Structured-exchange / RPC-relay gap +## Structured-exchange product relay status -The remaining live FE-744 gap is not generic UI polish. Brunch has now proven the private adapter/projection parts of the loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining gaps are web observation and chrome recovery. +The remaining live FE-744 gap is not generic structured-exchange relay work. Brunch has now proven the private adapter/projection parts of the loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, web clients observe RPC-originated structured-exchange updates through the product invalidation/refetch path, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining FE-744 gap is branded/themed chrome recovery. Pi source/docs already give strong evidence for the primitive: @@ -240,11 +242,11 @@ Pi source/docs already give strong evidence for the primitive: - `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch has now proven is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from a CLI probe over Brunch RPC → durable present/request tool results in Pi JSONL → response-side exchange projection. The next proof applies the same relay to the browser observer path: web clients should see selected session/exchange state update in real time when TUI or RPC interactions append tuple-shaped transcript truth. +The seam Brunch has now proven is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from a CLI probe over Brunch RPC → durable present/request tool results in Pi JSONL → response-side exchange projection → browser observer invalidation/refetch from canonical projection handlers. TUI-originated observation remains acceptable only if it reuses the same product invalidation path rather than inventing a parallel browser view store. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | -| Elicitation-first session loop | Proven for deterministic public RPC parity; web observation still pending. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, cancelled, marked unavailable, or explicitly display-only. | +| Elicitation-first session loop | Proven for deterministic public RPC parity plus browser observation of RPC-originated updates. | A session can begin from a system/assistant question or offer without ambient user chat; unresolved interactions own the response surface until answered, cancelled, marked unavailable, or explicitly display-only. | | Registered structured-exchange tool seam | Brunch present/request tests cover markdown `toolResult.content`, self-contained `toolResult.details`, non-semantic `renderCall`, unmatched-present recovery, `request_choices` JSON-editor fallback, terminal cancelled/unavailable closure, option content/rationale parity, and a real Pi RPC same-assistant-message sequential ordering proof for `present_options → request_choice`. | Continue classifying by typed details, not tool name, so unrelated tool results remain prompt-side; RPC consumers should not require `request_*` `tool_execution_start` before extension UI because the UI request can arrive first. | | TUI input replacement | Brunch adapter tests prove `ctx.ui.custom()` collection for freeform and listed-choice responses; multi-choice now has an RPC-compatible `request_choices` path, while review/candidate tools remain named stubs until their product flows land. | Keep UX refinements separate from the proof seam; future richer surfaces should reuse the same terminal-result discipline. | | JSON-editor RPC fallback | Brunch helper tests and `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` prove schema-tagged JSON over Pi RPC `ctx.ui.editor` at the adapter level; the public product relay now exercises the same multi-choice semantics through Brunch RPC. | Treat JSON-over-editor as a Pi adapter behind Brunch public RPC, not as a second product API or raw UX contract. | @@ -269,4 +271,4 @@ The seam Brunch has now proven is the product relay and parity loop around that - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. - The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. - The in-session `/brunch` menu and workspace/session action are unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. -- Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; a full Brunch-host manual walkthrough remains useful before product signoff because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data. +- Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; FE-744's remaining active closeout is a full Brunch-host branded/themed visual walkthrough because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data or the final product visual treatment. diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..44f2cc7b --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,218 @@ +<!-- CARDS.md — temporary scope-card queue for one frontier item. + Created by ln-scope. Delete or overwrite when exhausted/superseded. + Containing frontier: pi-ui-extension-patterns (FE-744). --> + +# Scope Cards — FE-744 branded/themed chrome recovery + +## Orientation + +- **Containing seam:** Pi UI extension affordances, specifically Brunch-owned TUI chrome and workspace dialog visual identity under `src/tui-client/.pi/*`. +- **Frontier item:** `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these cards are slices inside that one frontier and do **not** imply new Linear issues or branches. +- **Volatile state:** no `HANDOFF.md`, prior `memory/CARDS.md`, or `memory/REFACTOR.md` was present when scoped. Recent sync edits in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md` retire A10-L/A23-L and narrow FE-744 to visual chrome closeout + A18-L containment residue. +- **Main open risk:** a full Brunch-host visual proof is host/PTY-sensitive; keep deterministic assertions to textual/ANSI-stripped brand markers and record any irreducible manual visual judgment explicitly. + +## Frontier-level obligations carried by every card + +- Preserve `I19-L`: no branch creation/navigation, no mid-turn mutation, no parallel chat/turn store. +- Preserve `I22-L`: no prior transcript rendering or agent loop before explicit spec/session activation; the picker remains decision-only and the coordinator owns activation/binding. +- Preserve `D35-L`: Brunch chrome goes through `renderBrunchChrome` or its successor; do not scatter raw `ctx.ui.*` calls; do not publish a `brunch.chrome` status key. +- Preserve the public-RPC/product boundary: RPC fixtures may assert widget/title/notification events that Pi actually emits, not TUI-only header/footer internals. +- Keep product visual assets private to Brunch code; do not expose Brunch prompt packs, themes, or assets through ambient Pi resource discovery. +- Keep strict built-in command suppression out of these slices; A18-L remains an accepted/residual Pi API risk unless separately scoped. + +## Queue + +| Order | Card | Weight | Status | +| --- | --- | --- | --- | +| 1 | Shared Brunch TUI identity primitives | Full | next | +| 2 | Persistent chrome uses Brunch identity | Light | queued | +| 3 | Brunch-host chrome visual evidence | Light | queued | +| 4 | FE-744 closeout reconciliation | Light | queued | + +--- + +## Card 1 — Shared Brunch TUI identity primitives + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +The startup dialog's Brunch visual identity is provided by a reusable TUI identity module. + +### Boundary Crossings + +```text +→ src/tui-client/.pi/components/workspace-dialog/component.ts inline logo / wordmark / palette helpers +→ src/tui-client/.pi/components/<identity module> reusable Brunch visual primitives +→ workspace-dialog render output and build asset packaging +``` + +### Risks and Assumptions + +- RISK: moving or sharing logo helpers can break asset resolution under `dist/` because the current reader resolves assets relative to the workspace-dialog component module. + → MITIGATION: either keep assets in their current directory and pass the asset URL into the reusable helper, or update `build:pi-assets` with a test/build proof. +- RISK: visual helpers can accidentally shell out to Chafa or depend on ambient terminal state in tests. + → MITIGATION: keep generated ANSI assets as static inputs; make truecolor/240/plain fallback choices injectable or directly unit-testable. +- ASSUMPTION: a small Brunch-owned component helper is enough for product branding without using Pi theme/resource discovery. + → IMPACT IF FALSE: FE-744 would need a sealed-profile/theme slice before chrome closeout, delaying `sealed-pi-profile-runtime-state` and `graph-data-plane`. + → VALIDATE: unit-test the helper with dark/light/no-color projections and prove workspace-dialog still renders through product imports only. + +### Tracer-bullet check + +- **Invariants:** locates the private visual-identity boundary so future chrome and dialog code do not duplicate branding logic. +- **Proof of life:** workspace-dialog still renders its existing branded startup surface from the extracted helper. + +### Acceptance Criteria + +✓ `workspace-dialog` tests — the rendered picker still contains Brunch product copy, version/Pi lines, and no user-created workspace wording. +✓ new identity-helper tests — compact wordmark/logo fallback, dark/light palette wrapping, and no-color/plain fallback are deterministic without invoking runtime Chafa. +✓ `npm run build` or a targeted build-assets assertion — required static logo assets are present wherever the shared helper resolves them at runtime. + +### Verification Approach + +- Inner: unit tests — prove the identity module and workspace-dialog integration are deterministic. +- Middle: build asset check — prove compiled/runtime asset layout still supports the identity module. +- Outer: none for this card; visual judgment lands in Card 3. + +### Cross-cutting obligations + +- Do not expose Brunch visual identity as ambient Pi themes/resources. +- Do not persist startup/logo visuals into Pi JSONL. +- Preserve the picker as pure decision rendering; coordinator activation remains outside visual components. + +--- + +## Card 2 — Persistent chrome uses Brunch identity + +**Status:** queued after Card 1 +**Weight:** light scope card + +### Objective + +Make `renderBrunchChrome` present compact Brunch-branded chrome through the shared identity module. + +### Acceptance Criteria + +✓ `chrome.test.ts` — header/footer/widget snapshots include the compact Brunch identity plus activated spec/session/runtime facts without fabricating missing fields. +✓ wrapper-call test — `renderBrunchChrome` still calls only `setHeader`, `setFooter`, `setWidget`, and `setTitle`; it never calls `setStatus`. +✓ RPC-compatible projection assertion — diagnostic `setWidget` and `setTitle` remain plain/product-shaped enough for RPC observers while header/footer stay TUI-only. + +### Verification Approach + +- Inner: chrome unit tests and typecheck — prove product-state projection and Pi UI call shape. +- Middle: none; Card 3 supplies host-level evidence. + +### Cross-cutting obligations + +- Preserve `D35-L`: chrome remains one stateless projection wrapper over a supplied product-state snapshot. +- Preserve status-key discipline: do not publish or echo `brunch.chrome` as a footer status contribution. +- Do not use Pi theme discovery or ambient `.pi` resources for Brunch branding. + +### Assumption dependency + +None — A10-L has been retired into `D35-L` / `I22-L`; A18-L command containment is deliberately out of scope. + +### Promotion checklist + +- [x] Does this change a requirement? **No**. +- [x] Does this create, retire, or invalidate an assumption? **No**. +- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. +- [x] Does this make or reverse a non-trivial design decision? **No**; it applies the identity boundary from Card 1. +- [x] Does this establish a new seam-level invariant? **No**. +- [x] Does this change a frontier-level obligation or verification layer? **No**. +- [x] Does it cross more than two major seams? **No**. +- [x] Is this the first touch in an unfamiliar seam? **No** after Card 1. +- [x] Can you not name the containing seam/current rationale? **No**; `D35-L` and FE-744 govern it. + +--- + +## Card 3 — Brunch-host chrome visual evidence + +**Status:** queued after Card 2 +**Weight:** light scope card + +### Objective + +Capture Brunch-host evidence that the final chrome reads as Brunch-branded in a real TUI launch. + +### Acceptance Criteria + +✓ pty/script oracle — a host-sensitive probe captures a Brunch TUI startup screen and asserts ANSI-stripped brand markers, version/Pi line, spec/session selection copy, and absence of stale transcript text. +✓ activated-chrome evidence path — either the probe drives explicit activation to capture persistent chrome markers or `docs/architecture/pi-ui-extension-patterns.md` records why that step remains manual on this host. +✓ manual checklist — the documented walkthrough names the exact observations required for full-screen startup feel, persistent header/footer feel, active session id/label, and no Pi-branded primary surface leakage. +✓ no CI overreach — any host-sensitive pty probe stays outside `npm run verify` unless it is stable enough for ordinary local/CI execution. + +### Verification Approach + +- Inner: probe script unit/source checks where practical — prove assertions target product-shaped markers. +- Middle: pty/script probe — prove durable textual markers for the startup surface and, if feasible, activated chrome. +- Outer: manual TUI checklist — prove qualitative visual recovery that text oracles cannot fully encode. + +### Cross-cutting obligations + +- Preserve `I22-L`: startup proof must still show no stale transcript before explicit activation. +- Do not assert TUI-only header/footer through RPC fixtures; RPC proof is widget/title only. +- Keep manual evidence in `docs/architecture/pi-ui-extension-patterns.md`, not in ad hoc scratch reports. + +### Assumption dependency + +None — this card verifies a frontier closeout condition rather than building against a live SPEC assumption. + +### Promotion checklist + +- [x] Does this change a requirement? **No**. +- [x] Does this create, retire, or invalidate an assumption? **No**. +- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. +- [x] Does this make or reverse a non-trivial design decision? **No**. +- [x] Does this establish a new seam-level invariant? **No**. +- [x] Does this change a frontier-level obligation or verification layer? **No**; it instantiates the existing probe-oracle layer. +- [x] Does it cross more than two major seams? **No**; TUI host/probe/docs only. +- [x] Is this the first touch in an unfamiliar seam? **No** after Cards 1–2. +- [x] Can you not name the containing seam/current rationale? **No**; FE-744 visual closeout governs it. + +--- + +## Card 4 — FE-744 closeout reconciliation + +**Status:** queued after Card 3 +**Weight:** light scope card + +### Objective + +Reconcile FE-744 visual evidence into the live docs and retire stale provisional planning residue. + +### Acceptance Criteria + +✓ `docs/architecture/pi-ui-extension-patterns.md` — Current verdicts, evidence inventory, open evidence gaps, and downstream posture reflect final branded/themed chrome evidence. +✓ `memory/PLAN.md` — `pi-ui-extension-patterns` status/current execution pointer no longer says chrome recovery is missing; any remaining A18-L command-containment residue is explicitly accepted, deferred, or routed to a future frontier. +✓ `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` — deleted if all durable structured-exchange facts are already reconciled into SPEC/PLAN/evidence docs, or trimmed only if a still-live residue remains. +✓ `memory/CARDS.md` — this queue is marked done or deleted once exhausted. + +### Verification Approach + +- Inner: markdown/document consistency review plus `npm run fix` for repository convention. +- Middle: grep/reference audit — no stale claims that structured-exchange public relay or web observation are pending; no stale claim that chrome recovery is the current missing seam after closeout. +- Outer: none beyond Card 3 visual evidence. + +### Cross-cutting obligations + +- Do not archive handoffs/scope queues; delete exhausted derivative artifacts rather than preserving them as history. +- Keep PLAN at frontier granularity; do not turn these slices into new frontier items. +- Preserve cross-skill truth from `ln-design`/`ln-oracles`: public-RPC parity, probe artifact path, structured-exchange schema split, and chrome wrapper obligations must remain visible in SPEC/PLAN/evidence docs. + +### Assumption dependency + +None — this is canonical reconciliation and garbage collection after the scoped evidence lands. + +### Promotion checklist + +- [x] Does this change a requirement? **No**. +- [x] Does this create, retire, or invalidate an assumption? **No**; assumption retirement already occurred in sync. +- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. +- [x] Does this make or reverse a non-trivial design decision? **No**. +- [x] Does this establish a new seam-level invariant? **No**. +- [x] Does this change a frontier-level obligation or verification layer? **No**. +- [x] Does it cross more than two major seams? **No**; docs/queue cleanup only. +- [x] Is this the first touch in an unfamiliar seam? **No**. +- [x] Can you not name the containing seam/current rationale? **No**; FE-744 closeout governs it. diff --git a/memory/PLAN.md b/memory/PLAN.md index 34b19870..6545ce51 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,11 +14,11 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The active risk is still Pi wrapping: FE-744 has now proved raw Pi RPC editor fallback, public Brunch JSON-RPC assistant-first structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private Brunch prompt-pack topology, and the Zod-authored structured-exchange details schema layer. The remaining FE-744 seam is branded/themed visual chrome recovery. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The remaining FE-744 work is Pi-wrapping closeout, not public-RPC substrate doubt: raw Pi RPC editor fallback, public Brunch JSON-RPC assistant-first structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private Brunch prompt-pack topology, and the Zod-authored structured-exchange details schema layer have landed. The remaining FE-744 seam is branded/themed visual chrome recovery, with strict command containment still recorded as an A18-L residual Pi API risk. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure -The POC should maximize assumption falsification rather than merely implement milestone labels. Treat the table below as the live consequence map from SPEC assumptions to frontier pressure; when scoping a frontier, prefer the thinnest slice that can validate or falsify its assigned assumptions. +The POC should maximize assumption falsification rather than merely implement milestone labels. Treat the table below as the live consequence map from SPEC assumptions to frontier pressure; when scoping a frontier, prefer the thinnest slice that can validate or falsify its assigned assumptions. Retired/validated assumptions A10-L and A23-L are now carried by the SPEC decision/invariant residues for chrome, structured exchange, and public RPC parity; FE-744's live closeout is visual chrome recovery plus the still-open A18-L containment residue. | Assumption | Pressure / what could falsify it | Plan consequence | | --- | --- | --- | @@ -30,7 +30,6 @@ The POC should maximize assumption falsification rather than merely implement mi | A7-L `framing_as` modality | Product framings need relation policies that base kinds cannot express. | M4 schema plus targeted probe scenarios exercise framing; promote only if probe evidence demands it. | | A8-L reconciliation substrate | Gaps, contradictions, process debt, and conflicts need separate substrates immediately. | `graph-data-plane` builds the shared substrate; `coherence-first-class` and known-bad briefs test subtype pressure. | | A9-L mention ledger granularity | Session-scoped snapshots miss necessary staleness or create noisy hints. | Defer until `turn-boundary-reconciliation`, after graph ids/LSNs exist. | -| A10-L TUI chrome seam | Branded persistent chrome cannot be recovered through Pi UI primitives. | FE-744 must re-prove chrome visually/thematically, not just semantically, before closeout. | | A11-L next-turn delivery | Side-task/reviewer results require mid-turn delivery or another event plane. | Keep deferred until M5/M7 side-task/reviewer paths exist; test at turn-boundary rendezvous. | | A13-L deferred observer/auditor queue | Async audit/backfill needs canonical chat/turn tables or privileged writes. | Not load-bearing after D18-L; defer until a backstop queue is actually introduced. | | A14-L review-set structural legality | LLMs cannot produce dry-run-valid entity/edge drafts reliably enough. | M5 must measure structural-legality rate and retry/fallback behavior before depending on proposal-heavy UX. | @@ -42,7 +41,6 @@ The POC should maximize assumption falsification rather than merely implement mi | A20-L Drizzle 1.0 beta | Beta blocks migrations, SQLite fidelity, or TypeBox derivation. | `graph-data-plane` starts with a version/schema spike before broad imports. | | A21-L bounded coherence | Contradiction/gap verdicts cannot represent useful coherence without broader judgment. | Keep implementation late (M8), but design known-bad probe scenarios earlier so the rubric is falsifiable. | | A22-L synchronous elicitor capture | Elicitor over-captures, misses obvious facts, or cannot use preface to resolve uncertainty. | `agent-graph-integration` needs targeted capture probe runs before async observer backstops are reconsidered. | -| A23-L public RPC elicitation parity | A public Brunch RPC client cannot discover methods, activate workspace/spec/session, drive assistant-first pending exchanges, or produce TUI-comparable JSONL without speaking raw Pi RPC or adding a parallel turn store. | Validated by FE-744 public-RPC tuple parity and hardening commits; remaining FE-744 work observes the same session/exchange state from web and recovers branded chrome. | ## Sequencing @@ -54,7 +52,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Next 1. `sealed-pi-profile-runtime-state` — Seal Brunch's embedded Pi profile and transcript-backed runtime-bundle state before future agent-loop work depends on ambient-safe settings, prompt composition, or tool gating. -2. `graph-data-plane` — M4 remains structurally next after the offer-first UI seam is proven; do not return to it until FE-744 has a credible elicitation input loop for POC sessions and the sealed-profile/runtime-state follow-up is scoped. +2. `graph-data-plane` — M4 remains structurally next after FE-744 chrome closeout and the sealed-profile/runtime-state follow-up are scoped; the public-RPC elicitation input loop is no longer the blocker. 3. `agent-graph-integration` — M5. Graph tools, synchronous elicitor capture, review-set acceptance, and reviewer advisory writes through pi extension seams; all writes via the shared command layer. ### Parallel / Low-conflict @@ -96,7 +94,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-741](https://linear.app/hash/issue/FE-741/graph-data-plane-intent-first-workspace-graph-ready-m4) - **Branch:** `ln/fe-741-graph-data-plane` (stacked on `ln/fe-737-web-shell`) - **Kind:** structural -- **Status:** next / paused until FE-744 product relay/chrome recovery closes and the sealed-profile/runtime-state follow-up is scoped +- **Status:** next / paused until FE-744 chrome recovery closes and the sealed-profile/runtime-state follow-up is scoped - **Objective:** Stand up SQLite-backed graph persistence; durable intent-plane nodes and edges; a single global LSN per commit; the change log; the reconciliation-need substrate; named homes for coherence state (verdicts and violations) — all forward-compatible with oracle, design, and plan planes. - **Why now / unlocks:** Pins I1-L, I6-L. Unlocks all agent ↔ graph work (M5+) and lets oracle / design / plan planes be added later without re-foundation. - **Acceptance:** Graph CRUD + change-log replay tests pass through the `CommandExecutor` public mutation boundary; command results already include success, `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` shapes even if pre-M6 policy classification is minimal; reconciliation-need substrate accepts inserts/updates/resolutions with LSN invariants enforced; oracle-plane stub tables exist (Check, Validation Method, Evidence, Obligation) even if unused; the persistence layer proves the one-transaction protocol that couples authority/result classification, version checks, structural validation, LSN allocation, change-log append, and any coherence updates. @@ -240,9 +238,9 @@ The POC should maximize assumption falsification rather than merely implement mi - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user `comment` fields for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. -- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D41-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I26-L, I32-L, I33-L / A10-L, A14-L, A17-L, A18-L, A19-L, A23-L +- **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D41-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I26-L, I32-L, I33-L / A14-L, A17-L, A18-L, A19-L - **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** FE-744 has landed the public-RPC structured-exchange parity spine and its hardening: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; `src/probes/public-rpc-parity-proof.ts` drives the deterministic permutation set through public Brunch JSON-RPC only; and the committed run under `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` carries `session.jsonl`, rendered `transcript.md`, and `report.json`. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are specified as transcript-native ANALYSIS toolResults. The Zod-authored schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` now captures the target present/request/capture details contract (`schema` + `v`, `exchange_id`, `tool_meta`, `comment`/`message`, candidate rubrics/graph refs, and minimal no-graph capture details) with parse and JSON Schema export tests; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The Brunch extension shell is explicit again, and Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: return to branded/themed chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes the remaining active A10-L/A18-L risk. Run a separate `ln-design` pass before expanding capture-analysis payloads, shared transcript component subparts, or the runtime migration from tuple details to the new Zod exports. +- **Current execution pointer:** FE-744 has landed the public-RPC structured-exchange parity spine and its hardening: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; `src/probes/public-rpc-parity-proof.ts` drives the deterministic permutation set through public Brunch JSON-RPC only; and the committed run under `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` carries `session.jsonl`, rendered `transcript.md`, and `report.json`. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are specified as transcript-native ANALYSIS toolResults. The Zod-authored schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` now captures the target present/request/capture details contract (`schema` + `v`, `exchange_id`, `tool_meta`, `comment`/`message`, candidate rubrics/graph refs, and minimal no-graph capture details) with parse and JSON Schema export tests; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The Brunch extension shell is explicit again, and Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: return to branded/themed chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes FE-744's visual closeout and the A18-L command-containment residue is explicitly accepted or scoped. Run a separate `ln-design` pass before expanding capture-analysis payloads, shared transcript component subparts, or the runtime migration from tuple details to the new Zod exports. ### flue-pattern-adoption diff --git a/memory/SPEC.md b/memory/SPEC.md index 16eba423..263978a1 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -73,7 +73,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), freeform, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user note as additional context separate from custom/Other answers. Multi-question/questionnaire surfaces are deferred; when a complex shape is needed before a bespoke UI lands, Brunch may use just-in-time schema-tagged JSON over `ctx.ui.editor` or an equivalent product relay. In TUI mode a pending response request may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), freeform, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user-authored `comment` as additional context separate from custom/Other answers. Multi-question/questionnaire surfaces are deferred; when a complex shape is needed before a bespoke UI lands, Brunch may use just-in-time schema-tagged JSON over `ctx.ui.editor` or an equivalent product relay. In TUI mode a pending response request may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native establishment offers; lens metadata is carried on elicitor-emitted custom entries for downstream routing. @@ -95,7 +95,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Open Assumptions -<!-- Retired during sync: A2-L and A12-L were validated by M2 and promoted into R8, D6-L, D13-L, D24-L, I3-L, I10-L, and I19-L. --> +<!-- Retired during sync: A2-L and A12-L were validated by M2 and promoted into R8, D6-L, D13-L, D24-L, I3-L, I10-L, and I19-L. A10-L and A23-L were validated by FE-744/M0-M3 and promoted into R4, R16, R24, R27, R28, D22-L, D35-L, D37-L, D48-L, D49-L, I22-L, I23-L, and I32-L. --> | # | Assumption | Confidence | Status | Depends on | Validation approach | | --- | --- | --- | --- | --- | --- | @@ -107,7 +107,6 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A7-L | `framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology (problem, persona, JTBD, etc.) and does not need to become first-class node kinds in the POC. | medium | open | D7-L | Targeted probe runs that exercise framing pressure: if a framing repeatedly demands unique relation policy, promote per the seam-extensions Open Question #8. | | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | -| A10-L | A persistent TUI chrome region showing cwd / spec / phase / runtime bundle can be added on top of `pi-tui`'s root layout without modifying pi. | high | validated | D2-L, D35-L | M0 mounted initial chrome through the widget seam; `pi-ui-extension-patterns` Card 2 proved header/footer/status/widget dynamic chrome through a Brunch wrapper plus raw TUI transcript evidence. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | | A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and elicitation-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | | A14-L | LLM elicitor agents can reliably produce graph-structurally-legal review-set proposals (well-formed entity drafts and semantic edges that pass `CommandExecutor` structural validation). | medium | open | D27-L | Probe runs that exercise batch-proposal and commitment review-set flows; dry-run `CommandExecutor` validation at proposal time before user review. Fallback (constrained generation, retry-with-feedback, or NL-parse-at-accept) preserves the user-facing review-cycle if reliability is insufficient. | @@ -119,7 +118,6 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A20-L | The Drizzle 1.0 beta line (specifically `drizzle-orm@^1.0.0-beta.15` or later, with the built-in `drizzle-orm/typebox` path that consumes the new `typebox` package) is stable enough for Brunch to depend on for M4 graph persistence and beyond. | medium | open | D16-L, D41-L | M4 scoping spike: round-trip `drizzle-orm@1.0.0-beta.*` + `drizzle-orm/typebox` + `better-sqlite3` + Pi `registerTool` over a representative intent-plane table; if beta blocks land (migrations, SQLite type fidelity, or schema-derivation bugs), fall back to Drizzle 0.x + standalone `drizzle-typebox` + `drizzle-orm/typebox-legacy` and re-evaluate per release. | | A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | | A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness/posture updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | open | D18-L, D26-L, D45-L, I30-L | M5 agent-graph-integration fixtures and review: compare elicitor-captured graph updates against transcript evidence; track over-capture, missed obvious facts, and whether preface-led disambiguation resolves low-confidence material without an async observer owning primary extraction. | -| A23-L | Public Brunch JSON-RPC plus a private Pi adapter can drive assistant-first structured exchanges without exposing raw Pi RPC to the client or introducing a parallel prompt/turn store. | high | validated | D5-L, D12-L, D33-L, D48-L, D49-L, I32-L | FE-744 public RPC structured-exchange parity proof landed: method discovery, workspace/spec/session activation, deterministic start/resume/pending/respond lifecycle, current structured-exchange permutation coverage, terminal non-answered status handling, option artifact parity, and Pi JSONL/projection comparison. Coherent ten-turn elicitation progress remains an outer-loop generative probe concern. | ### Active Decisions @@ -130,7 +128,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/tui-client/pi-extension-shell.ts`) rather than ambient discovery; *explicit* means the shell statically imports its product extensions and registers them from a fixed ordered list — it must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the shell wires those dependencies at the call site; the `default` exports under `src/tui-client/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/tui-client/.pi/extensions/*`, and reusable Pi TUI components live under `src/tui-client/.pi/components/*`, so they can also be iterated by launching Pi from `src/tui-client` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; TUI-client extension/component tests live under `src/tui-client/.pi/__tests__/`. The remaining weak point is settings leakage through `SettingsManager.create(cwd, agentDir)`, currently only overriding quiet startup; Brunch must audit and either override/seal settings that affect product behavior (shell path/prefix, compaction/retry, image handling, keybindings if exposed) or request a narrow Pi seam. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/tui-client/.pi/extensions/brunch/`, or replacing the explicit static shell list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. - **D40-L — Runtime posture is a transcript-backed Brunch state machine, not hidden extension memory.** Brunch distinguishes operational modes (`elicit`, future `execute`) from agent roles (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor roles) and from strategies/lenses. The active top-level role is selected through a role preset/runtime bundle that derives model, thinking level, prompt packs, allowed strategies/lenses, and tool policy rather than storing each knob independently. Brunch runtime helpers append full selected-state product custom entries under `brunch.agent_runtime_state` with `reason: "init" | "switch"`; turn preparation projects the latest valid linear transcript snapshot into prompt and tool posture. Brunch product prompt packs are private code-composed assets under `src/tui-client/.pi/context/prompt-packs/*`, composed only through `src/tui-client/.pi/context/compose-brunch-prompt.ts` and appended by the explicit `src/tui-client/.pi/extensions/prompting.ts` product extension; they are not Pi prompt templates, skills, context-file discovery, or user-invoked slash-command resources. The current `elicit` tool policy is a denylist over side-effecting tools (`bash`, `edit`, `write`) plus user-shell interception, so new safe Brunch extension tools are not hidden by a stale allowlist. The Pi extension module that owns tool policy is `src/tui-client/.pi/extensions/operational-mode.ts`, while product prompting is owned separately by `src/tui-client/.pi/extensions/prompting.ts`; neither should duplicate the other's control-plane responsibility. Depends on: D17-L, D23-L, D25-L, D39-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority for agent behavior, modeling read-only posture as a volatile allowlist of every safe tool, or exposing Brunch prompt packs through Pi resource discovery. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/tui-client/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A10-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. #### Data model & vocabulary @@ -194,7 +192,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ``` - **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are JSON-Schema-shaped per D41-L, regardless of whether their source authoring library is Zod or TypeBox; discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. -- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: A23-L, D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. +- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The first product lifecycle is intentionally small: `session.startElicitation` starts or resumes the assistant-first elicitation loop for the activated spec/session; `session.pendingExchange` returns the current pending structured exchange or idle/completed status; `elicitation.respond` submits the terminal response for one pending exchange; `session.transcriptDisplay` and `session.elicitationExchanges` remain read projections over transcript truth. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but the client contract is Brunch JSON-RPC. Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly. #### Persistence @@ -209,15 +207,15 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Schema & validation -- **D41-L — Boundary schemas are runtime-validated and JSON-Schema-exportable; Zod v4 may be the product/protocol schema source.** Brunch boundary shapes must have one runtime schema source of truth, derived static TypeScript types, and JSON Schema output wherever a public protocol or Pi tool boundary needs discoverability. Zod v4 is permitted — and preferred for structured-exchange product/protocol schemas — when the schema stays inside Zod's JSON-representable subset and tests prove `z.toJSONSchema(..., { unrepresentable: "throw" })` succeeds for the exported boundary. TypeBox remains valid for Pi tool parameter objects, small config/frontmatter contracts, and any seam where the direct JSON-Schema-shaped authoring style is cheaper. Do not hand-author parallel Zod and TypeBox definitions for the same boundary; if a Pi API requires a JSON-Schema-shaped object, generate or adapt it from the chosen source schema and test the adapter. Drizzle table definitions remain canonical for persisted shapes; row/insert/update validation must be derived from Drizzle through a single adapter path (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent selected during A20-L) rather than hand-authored alongside the table. Boundary Zod schemas must avoid transforms, custom validators, `Date`, `Map`, `Set`, `bigint`, and other unrepresentable constructs unless an explicit adapter owns the input/output split and JSON Schema export tests cover it. Static TS types come from the schema source (`z.infer<typeof Schema>` for Zod, `Static<typeof Schema>` for TypeBox); runtime parsing uses the matching library (`Schema.parse`/`safeParse` for Zod, `Value.Parse`/`Value.Check` for TypeBox). Depends on: D4-L, D5-L, D16-L. Supersedes: TypeBox as Brunch's single runtime schema vocabulary, the ban on Zod outside downstream adapters, and an implicit "any runtime schema library is fine" posture. +- **D41-L — Boundary schemas are runtime-validated and JSON-Schema-exportable; Zod v4 may be the product/protocol schema source.** Brunch boundary shapes must have one runtime schema source of truth, derived static TypeScript types, and JSON Schema output wherever a public protocol or Pi tool boundary needs discoverability. Zod v4 is permitted — and preferred for structured-exchange product/protocol schemas — when the schema stays inside Zod's JSON-representable subset and tests prove `z.toJSONSchema(..., { unrepresentable: "throw" })` succeeds for the exported boundary. TypeBox remains valid for Pi tool parameter objects, small config/frontmatter contracts, and any seam where the direct JSON-Schema-shaped authoring style is cheaper. Do not hand-author parallel Zod and TypeBox definitions for the same boundary; if a Pi API requires a JSON-Schema-shaped object, generate or adapt it from the chosen source schema and test the adapter. Drizzle table definitions remain canonical for persisted shapes; row/insert/update validation must be derived from Drizzle through a single adapter path (`drizzle-zod`, `drizzle-orm/typebox`, or equivalent selected during A20-L) rather than hand-authored alongside the table. Boundary Zod schemas must avoid transforms, `Date`, `Map`, `Set`, `bigint`, and other unrepresentable constructs unless an explicit adapter owns the input/output split and JSON Schema export tests cover it; refinements are allowed only for runtime constraints that stay inside JSON-representable input/output shapes and are covered by parse tests plus export tests. Static TS types come from the schema source (`z.infer<typeof zExample>` for Zod, `Static<typeof TypeBoxExample>` for TypeBox); runtime parsing uses the matching library (`zExample.parse`/`safeParse` for Zod, `Value.Parse`/`Value.Check` for TypeBox). Depends on: D4-L, D5-L, D16-L. Supersedes: TypeBox as Brunch's single runtime schema vocabulary, the ban on Zod outside downstream adapters, and an implicit "any runtime schema library is fine" posture. #### Interaction & UI shape -- **D11-L — Workspace state hierarchy `workspace(cwd) → spec → session`, with spec and session selection gated before any agent loop.** A Brunch workspace is the single cwd where the CLI is invoked; it is not a user-created container and there is only one per launch context. The cwd's human-readable label may be derived by `src/project-identity.ts` from shallow project manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) or directory basename, but that label is presentation metadata, not a second selectable container. The first durable choice is the spec: create a new spec, or resume an existing spec. Within an existing spec, the second durable choice is the session: create a new session or resume an existing session. Creating a new spec implicitly creates its first session. Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: A10-L. Supersedes: treating “workspace” as the user-created product object in the boot dialog. +- **D11-L — Workspace state hierarchy `workspace(cwd) → spec → session`, with spec and session selection gated before any agent loop.** A Brunch workspace is the single cwd where the CLI is invoked; it is not a user-created container and there is only one per launch context. The cwd's human-readable label may be derived by `src/project-identity.ts` from shallow project manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) or directory basename, but that label is presentation metadata, not a second selectable container. The first durable choice is the spec: create a new spec, or resume an existing spec. Within an existing spec, the second durable choice is the session: create a new session or resume an existing session. Creating a new spec implicitly creates its first session. Spec selection is durable across `/new` and persisted in `.brunch/state.json`. Each Pi session is bound to exactly one spec by a `brunch.session_binding` custom entry at session start; switching specs selects or creates another session rather than mutating the spec of the current session. Depends on: D6-L. Supersedes: treating “workspace” as the user-created product object in the boot dialog. - **D21-L — Workspace session coordination is the spec/session boot seam.** Brunch owns a narrow `WorkspaceSessionCoordinator` for boot, spec inventory, spec/session selection, selected-session reopening, and `/new` session creation. It is the only product module allowed to create or open Pi sessions for Brunch user flows and the only module allowed to write `brunch.session_binding`; callers inspect workspace inventory and activate a product decision rather than mutating a session's bound spec directly. The coordinator hides `SessionManager.create/open/continueRecent(cwd, ".brunch/sessions/")`, internal session-start binding for pi-created replacement sessions, `.brunch/state.json` current-spec and current-session-file acceleration, binding validation, and chrome-state derivation. Because pi defers appending session JSONL until an assistant message exists, the coordinator flushes Brunch's binding when it is created, refreshes it at `before_agent_start`, and performs the final pre-assistant flush from Brunch's internal assistant `message_start` hook after pi has persisted the user message but before assistant persistence; each flush reloads the session file so pi's next assistant append does not duplicate the already-written prefix. Depends on: D6-L, D11-L. Supersedes: the loose `SpecRegistry` + caller-orchestrated session-binding mental model, and treating `.brunch/state.json` as an implicit instruction to resume without user-visible Brunch flow. -- **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: A10-L, D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. +- **D22-L — TUI boot is Brunch-owned before Pi interactive runtime begins.** Brunch's TUI mode may use `@earendil-works/pi-tui` directly for a pre-Pi startup gate that selects or creates the active spec/session before `InteractiveMode.run()`. After activation, persistent chrome is mounted by an internal Brunch extension through Pi's public UI seams. Brunch does not fork pi, monkeypatch `InteractiveMode`, or expose generic pi extension configuration to users for product boot/chrome. Depends on: D2-L, D21-L, D36-L. Supersedes: private-header/monkeypatch approaches for M0 chrome and raw readline-only spec selection as the durable TUI product flow. - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces may be represented by Brunch custom entries when needed, but there is no DB-owned prompt/response entity; at idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L. Supersedes: —. -- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. `present_*` details include an `exchangeId` and expected next `request_*` tool so incomplete tuples can be recovered by transcript scan. `request_*` details reference the present entry by `exchangeId`/present tool and should not repeat the presented markdown unless a runtime proof forces that fallback. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives the current deterministic tuple-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, or modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`. +- **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction may be represented through the thinnest Pi-supported transcript seam for its shape. The current preferred seam for Brunch structured exchanges is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. The landed Zod-authored target details model under `src/tui-client/.pi/extensions/structured-exchange/schemas/` uses checked `schema` + `v` discriminants, `exchange_id`, compact `tool_meta` sequence/sibling metadata, exactly-one request outcome presence (`answered` | `cancelled` | `unavailable`), user-authored `comment` versus runtime-authored `message`, strict `present_candidates` rubrics/`graph_refs`, and intentionally minimal no-graph `capture_*` details; runtime tools/projections still use the existing tuple details model until a deliberate migration slice rewires them to these exports. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof now drives the current deterministic tuple-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Brunch custom messages/entries remain valid for establishment offers, review-set proposals, annotations, and future product-native displays, but they are not mandatory for every structured exchange. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L, D41-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`, or treating the retired scope-card contract as canonical after the schema README and tests have landed. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. - **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text, linked structured response entries, and/or terminal structured-exchange toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. @@ -229,7 +227,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D45-L — Spec readiness is stored as grade/posture fields, not as session-local phase or workflow location.** The spec row owns two semi-independent control fields: `readiness_grade = grounding_onboarding | elicitation_ready | commitments_ready | planning_ready` and `elicitation_posture = gathering | refining | pinning`. Grade is a forward gate: it unlocks later strategies, commitment review sets, and eventual export/plan/execute operational modes, but it never forbids returning to earlier gathering/refinement when new ambiguity appears. Posture is the current dominant stance inside `elicit`. An optional `commitment_focus = design | oracle` may be added only if active review-set state and missing-commitment analysis cannot make the focus obvious; it is not required as canonical state now. Grade/posture changes route through `CommandExecutor`, carry provenance/rationale in the change log (and/or spec row metadata when M4 schema lands), and use hybrid transition authority: elicitor may advance low-risk gates with evidence, validators enforce hard prerequisites where known, and user-visible confirmation is required before entering commitment pinning. Depends on: D18-L, D20-L, D30-L. Supersedes: treating “phase” as a user-facing location/stepper or hidden session memory. - **D46-L — Commitment posture pins projected claims through cohesive review sets.** Design and oracle lenses may create accepted graph material before commitment posture, but pinning is a separate projection step. In `pinning` posture, design-oriented commitments default first: Brunch projects requirement/invariant-like intent claims from the current intent/design/oracle graph plus support/provenance edges. Oracle-oriented commitments default second: Brunch projects criterion/check-obligation/example-like verification claims plus support/provenance edges to the pinned commitments and oracle material. Review sets are focus-primary rather than globally homogeneous: a design commitment set primarily pins requirement/invariant-like claims with support edges; an oracle commitment set primarily pins criteria/check/example-like claims with support edges. Approval accepts the cohesive batch as a whole through `acceptReviewSet`; request-changes regenerates a successor set; partial approval and accept-with-edits remain unrepresentable. Depends on: D27-L, D28-L, D45-L. Supersedes: per-item requirement/criterion confirmation and treating design/oracle commitment phases as first permission to discuss design/oracle topics. - **D47-L — Structured-exchange `preface` is the near-term carrier for non-committed elicitor interpretation.** The structured-exchange payload's plain prose `preface` summarizes working context before the next question: exploratory file-reading/tool-use findings, implied graph candidates, low-confidence edges, and the rationale for what is being asked next. Preface text is transcript truth and user-visible orientation, but it is not graph truth, not candidate-artefact schema, and not a hidden side store. High-confidence facts still commit through `CommandExecutor`; low-confidence implications stay in preface/question material until clarified, accepted, or escalated to reconciliation needs. Future `capture_*` analysis entries provide a separate post-exchange/review evidence surface for candidate semantic changes; they do not replace preface as next-question orientation and do not become graph truth. Structured candidate metadata is deferred until fixtures/projections prove plain prose is insufficient. Depends on: D12-L, D18-L, D37-L, D50-L. Supersedes: inventing a candidate-artefact substrate merely to carry ordinary next-question disambiguation material. -- **D50-L — `capture_*` tools persist transcript-native ANALYSIS, not graph mutations.** Brunch may add a third structured-exchange tool family such as `capture_analysis` alongside `present_*` and `request_*`. A `capture_*` tool returns a normal persisted Pi `toolResult` with Brunch details and markdown content describing likely graph/node/edge changes, grouped into high-confidence candidates that could be committed later and low-confidence candidates that should drive clarification. `capture_*` output is transcript-visible evidence for Markdown/ASCII review and later graph-mutation cross-checking, but it is not graph truth and never bypasses the `CommandExecutor`. Product UI should hide capture analysis entirely if Pi exposes a supported hide seam; otherwise `renderResult` should be maximally collapsed/minimal while preserving full persisted `toolResult.content`/`details` for transcript renderers. The exact details schema and shared component subparts (`Preface`, prompt body, option list, answer summary, capture analysis) require a later `ln-design` pass before implementation. Depends on: D12-L, D17-L, D18-L, D37-L, D41-L, D47-L. Supersedes: using ad hoc hidden custom entries, probe-only side files, or graph writes as the first carrier for pre-graph analysis. +- **D50-L — `capture_*` tools persist transcript-native ANALYSIS, not graph mutations.** Brunch may add a third structured-exchange tool family such as `capture_analysis` alongside `present_*` and `request_*`. A `capture_*` tool returns a normal persisted Pi `toolResult` with Brunch details and markdown content describing likely graph/node/edge changes, grouped into high-confidence candidates that could be committed later and low-confidence candidates that should drive clarification. `capture_*` output is transcript-visible evidence for Markdown/ASCII review and later graph-mutation cross-checking, but it is not graph truth and never bypasses the `CommandExecutor`. Product UI should hide capture analysis entirely if Pi exposes a supported hide seam; otherwise `renderResult` should be maximally collapsed/minimal while preserving full persisted `toolResult.content`/`details` for transcript renderers. The current schema layer deliberately defines only minimum capture details (`schema`, `v`, `exchange_id`, `tool_meta`) and rejects graph payloads; richer analysis payloads and shared component subparts (`Preface`, prompt body, option list, answer summary, capture analysis) require a later `ln-design` pass before implementation. Depends on: D12-L, D17-L, D18-L, D37-L, D41-L, D47-L. Supersedes: using ad hoc hidden custom entries, probe-only side files, or graph writes as the first carrier for pre-graph analysis. - **D44-L — Subagents are main-agent-invoked, blocking Pi tool calls that gather data and propose variants for candidate-proposal generation.** Brunch may register a single `subagent` Pi tool whose parameters are `{ agent, task }` or `{ tasks: [] }` (parallel). Each invocation runs as an isolated `pi --mode json -p --no-session --no-skills --no-extensions` subprocess inheriting Brunch's sealed Pi Profile (D39-L); the subagent has no inherited conversation context so the task string must carry everything it needs. Agent definitions are declarative markdown files under `src/tui-client/.pi/extensions/subagents/agents/*.md` with TypeBox-validated frontmatter (`name`, `description`, `tools`, `model`) plus a system-prompt body. Concurrency cap lives in an externalized [src/tui-client/.pi/extensions/subagents/config.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/subagents/config.json) (default 4) so it can be reviewed and updated without SPEC churn. The subagent's result text is returned directly to the main agent as tool result content; subagents do not append custom messages to the session log on their own behalf, do not invoke the `CommandExecutor`, and do not gain access to the parent's Brunch RPC handlers. POC starter agents split into two families: - **Data gatherers** — read-only context fetchers whose output grounds proposals: **scout** (codebase recon: `read`, `grep`, `find`, `ls`), **researcher** (web research: `web_search`, `web_fetch`), and **graph-reader** (read-only Brunch graph projection tools). - **Variant proposer** — **proposer** (no tools): given a grounding bundle plus a batch-proposal lens frame, emits exactly one well-formed variant of a candidate proposal. The main agent achieves diversity by issuing parallel `tasks: []` invocations of `proposer` with intentionally distinct framings — the subagent realization of the "design it twice" pattern from `ln-design` and the parallel fan-out anticipated by `ln-oracles`. Each `proposer` invocation runs in its own isolated context so variants don't cross-contaminate; the main agent collects N outputs and composes the comparison via the D31-L meta-rubric (and/or project-specific axes) before writing a `brunch.review_set_proposal` entry through the elicitor flow. `proposer` is system-prompt-only by design: its grounding inputs come entirely through the task string the main agent assembles from preceding `scout` / `researcher` / `graph-reader` calls. @@ -263,17 +261,17 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I20-L | Every user-reviewable review-set proposal has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface as reviewable review sets. | planned (M5+ proposal-validation contract + differential tests) | D27-L; A14-L | | I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | -| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result (`status: presented`, `exchangeId`, expected `request_*`) and, when required, exactly one matching terminal `request_*` result (`answered`, `cancelled`, or `unavailable`) before the next agent turn consumes it. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L | +| I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result and, when required, exactly one matching terminal `request_*` result before the next agent turn consumes it. The target details model is checked by `schema` + `v`, `exchange_id`, and `tool_meta`; request outcomes are an exactly-one property-presence union; user-authored text is `comment` and runtime-authored text is `message`; present-side status/kind/expected-request aliases and capture graph payloads are invalid in the Zod-authored schema layer. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. The Zod-authored schema layer is covered by JSON Schema export and drift-rejection tests for present/request/capture details; runtime tools still need a deliberate migration to those exports. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L, D41-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | planned (sealed-profile audit and resource/settings isolation tests) | D2-L, D39-L | | I25-L | The active operational mode, role preset/runtime bundle, strategy, and lens are reconstructable from linear transcript entries at turn start; tool gating follows the reconstructed operational mode so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted by the bundle. | planned (runtime-state projection tests plus before-agent-start/tool-policy contract tests) | D17-L, D23-L, D40-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | -| I26-L | No source module under `src/` imports a runtime schema library other than `typebox` (and `drizzle-orm/typebox` once M4 lands); `zod`, `@sinclair/typebox`, `valibot`, `arktype`, and `effect/schema` do not appear as direct imports in `src/` except behind a deliberately-scoped third-party adapter that the SPEC has acknowledged. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | planned (grep-based architectural test landing with M4; manual code review until then) | D41-L | +| I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/tui-client/.pi/extensions/structured-exchange/schemas/`; TypeBox remains valid for Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | partially covered (structured-exchange schema tests prove Zod parse/export for the acknowledged seam; a grep-based architectural test should still land with M4 for broader schema import boundaries and Drizzle derivation) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec grade/posture updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade/posture mutations route through `CommandExecutor` and carry provenance. | planned (M4 schema/command tests for spec row updates; M5 prompt/tool-policy tests for grade-gated availability) | D20-L, D45-L | -| I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and the deterministic permutation run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/note artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L; A23-L | -| I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | planned (future capture-analysis schema/rendering tests plus transcript renderer fixture; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | +| I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `elicitation.respond`, and the deterministic permutation run produces linear Pi JSONL whose transcript display and elicitation-exchange projections preserve the same prompt/answer/status/comment artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity (`rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/projection oracle in `src/probes/public-rpc-parity-proof.ts`) | R11, R16, R17, R24, R27, R28; D5-L, D12-L, D37-L, D48-L, D49-L | +| I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | partially covered (minimum capture details schemas parse/export and reject graph payload fields; future runtime capture-analysis schema/rendering tests plus transcript renderer fixtures still need to prove persisted result rendering and TUI hide/collapse behavior; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | ## Future Direction Register @@ -394,11 +392,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | **RPC structured-exchange parity proof** | The FE-744 product proof that a public Brunch RPC agent-as-user can complete the current deterministic structured-exchange permutations and leave Pi JSONL plus Brunch projections comparable in semantic kind and quality to a TUI-driven session. Contrasts with future generative elicitation-quality probes and with the raw Pi RPC structured-exchange editor fallback proof, which is supporting evidence only. | | **Structured-exchange preface** | Plain prose in a structured-exchange payload that summarizes non-committed working interpretation before asking the next question. It may mention exploratory tool findings or implied graph candidates, but it is not graph truth. | | **Structured exchange tool** | A registered Pi tool in the `present_*` / `request_*` / future `capture_*` families. `present_*` tools persist assistant-originated offer/question/proposal markdown; `request_*` tools collect and persist the user's response; `capture_*` tools persist assistant analysis of likely semantic changes without mutating graph truth. Durable UI after reload/resume is rebuilt from toolResult `content`/`details` through `renderResult`, not from `renderCall` or live UI state. | -| **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. Its details include `exchangeId`, `presentTool`, `kind`, `status: presented`, `expectedRequest`, and `createdAtToolCallId`. | -| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, future `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. It references the present half by `exchangeId` and present tool rather than repeating the presented markdown. | +| **Present tool** | A `present_*` structured exchange tool (`present_question`, `present_options`, future `present_review_set`, `present_candidates`) whose toolResult markdown is the durable assistant-originated half of the exchange. The target details model identifies present rows with `schema: "brunch.structured_exchange.present"`, `v`, `exchange_id`, and `tool_meta.curr` / `tool_meta.next`; a present-side `status: presented` field is not needed because a persisted present result is already presented. | +| **Request tool** | A `request_*` structured exchange tool (`request_answer`, `request_choice`, `request_choices`, future `request_review`) whose live UI collects the user response and whose toolResult markdown/details are the durable response half. The target details model references sequence through `exchange_id` plus `tool_meta.prev`/`curr`/optional `next`, and encodes terminal outcome as exactly one of `answered`, `cancelled`, or `unavailable`. | | **Capture tool** | A future `capture_*` structured-exchange tool (for example `capture_analysis`) whose normal persisted `toolResult` records ANALYSIS: high-confidence candidate graph mutations and low-confidence clarification candidates grounded in transcript evidence. It is transcript-visible but UI-hidden when possible, otherwise maximally collapsed; it is never a graph mutation. | | **ANALYSIS transcript section** | Human-reviewable transcript rendering of `capture_*` tool results. ANALYSIS explains candidate node/edge changes and uncertainties before graph persistence or before comparing later graph mutations to the transcript; it is evidence, not authority. | -| **Structured exchange result details** | The structured payload in a structured-exchange toolResult. Present details support tuple recovery; request details carry terminal status (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review/comment data; capture details carry analysis candidates and grounding. Brunch projection should not need render lifecycle state to rebuild the exchange. | +| **Structured exchange result details** | The structured payload in a structured-exchange toolResult. The target Zod-authored model uses checked `schema` + `v`, `exchange_id`, and `tool_meta`; request details use property presence (`answered`, `cancelled`, or `unavailable`) plus typed answer/choice/review `comment` data; `message` is reserved for runtime-authored cancellation/unavailable explanations; minimal capture details carry sequence identity only until a later design approves richer analysis payloads. Brunch projection should not need render lifecycle state to rebuild the exchange. | | **Offer response** | The terminal structured answer to a structured offer, represented either as a linked Brunch custom entry or as self-contained `request_*` toolResult details. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-elicitation events/methods, then translates product responses back into Pi `extension_ui_response` messages. | @@ -492,17 +490,17 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Loop | Oracle family | Proves | Primary claims | | --- | --- | --- | --- | | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | -| Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, probe report metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L. | -| Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L; A10-L. | +| Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, Zod-authored structured-exchange present/request/capture details with JSON Schema export, probe report metadata, graph exports, `brunch.review_set_proposal` / `brunch.establishment_offer` / `brunch.elicitor_intent_hint` custom-entry payloads (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L, I23-L, I26-L. | +| Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer entry. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L. | | Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | | Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; pending-exchange start/read/respond handlers preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness grade/posture mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Probe transcript replay and property assertions | Probe runs preserve transcript evidence that can be replayed, rendered, and compared against current Brunch projections. Future brief-driven sessions, if revived, must produce the same probe-run artifact shape. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in probe metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives the current structured-exchange permutations through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, rejects repeated deterministic prompts, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L, A23-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives the current structured-exchange permutations through Brunch JSON-RPC only, compares Pi JSONL plus `session.transcriptDisplay` / `session.elicitationExchanges` projections against TUI-shaped structured-exchange expectations, rejects repeated deterministic prompts, and can persist a `.fixtures/runs/public-rpc-parity/<run-id>/` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. | A5-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Middle | Capture-analysis transcript oracle | Future `capture_*` probes persist ANALYSIS as normal Brunch toolResults, assert no graph writes occur, render full analysis in Markdown/ASCII transcripts, and assert the TUI path hides or collapses the same result without losing persisted content/details. | D17-L, D18-L, D37-L, D47-L, D50-L; I23-L, I30-L, I33-L. | -| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A10-L, A17-L; R4, R14, R16, R20, R21. | +| Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer entries; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative probe runs | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, and reviewer-finding precision through small targeted probe scenarios (brief-shaped inputs are allowed, but the probe run and transcript artifacts are canonical). POC scope remains one or two known-bad scenarios per relevant invariant, not exhaustive coverage. | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | ### Probe Oracle Design @@ -542,21 +540,22 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | -| I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; elicitation-exchange projection pairs the prompt-side present/custom entry with the terminal request result. | +| I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details or linked custom entries; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; elicitation-exchange projection pairs the prompt-side present/custom entry with the terminal request result. Structured-exchange schema tests cover the landed target details model: checked `schema`/`v`, `tool_meta`, candidate rubric/graph-ref drift rejection, exactly-one request outcomes, comment/message placement, minimal capture details, and JSON Schema export. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active operational mode/role preset/strategy/lens, and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | +| I26-L | Structured-exchange schema tests prove the acknowledged Zod seam parses and exports JSON Schema; future M4 architectural tests should grep/import-audit schema libraries and Drizzle row-schema derivation boundaries. | | I28-L | Inner — TypeBox schema validation of [src/tui-client/.pi/extensions/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/tui-client/.pi/extensions/auto-compaction-anchors.json) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/tui-client/.pi/extensions/subagents/agents/*.md` frontmatter and `src/tui-client/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | M4/M5 spec-row command tests for grade/posture updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. | | I32-L | FE-744 public-RPC structured-exchange parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic permutation run over Brunch JSON-RPC only, no repeated deterministic prompts, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | -| I33-L | Future capture-analysis tests: `capture_*` result schema/rendering, no graph-write side effects, Brunch-semantic transcript inclusion, and hidden/collapsed TUI rendering fallback. | +| I33-L | Current schema tests cover minimum no-graph `capture_*` details and reject graph payload fields. Future capture-analysis runtime tests must still cover persisted result rendering, no graph-write side effects, Brunch-semantic transcript inclusion, and hidden/collapsed TUI rendering fallback. | ### Design Notes - **Deterministic before generative.** Probe runs should prefer deterministic or tightly scripted paths before relying on LLM persona variance. Generative/adversarial probes come after the transcript substrate is trusted. Retired M1 scripted captures proved the early transport/projection substrate on then-current terms, but tuple-shaped FE-744 public-RPC probes are the current evidence path. - **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, pending/respond lifecycle, current structured-exchange permutations, JSONL/projection parity, and reviewable probe artifacts. LLM elicitation quality and coherent ten-turn progress remain outer-loop generative fixture concerns after the transport/turn substrate is trustworthy. -- **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The schema/component shape should be designed separately before implementation; the durable commitment now is only the toolResult-family carrier and visibility policy. +- **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The landed schema layer defines only the checked minimum capture details and rejects graph payloads; richer analysis payloads and shared rendering components still require a separate design pass before runtime implementation. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/state.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. - **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates by invalidating/refetching canonical projection handlers rather than introducing a view store. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. From 10f926f444e82b968a66aece737617435ed627d5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 11:07:29 +0200 Subject: [PATCH 159/164] FE-744: Extract Brunch TUI identity primitives --- memory/CARDS.md | 8 +- .../.pi/__tests__/workspace-dialog.test.ts | 47 ++++++ .../.pi/components/brunch-identity.ts | 141 ++++++++++++++++++ .../components/workspace-dialog/component.ts | 105 ++----------- 4 files changed, 206 insertions(+), 95 deletions(-) create mode 100644 src/tui-client/.pi/components/brunch-identity.ts diff --git a/memory/CARDS.md b/memory/CARDS.md index 44f2cc7b..ccb41109 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -24,8 +24,8 @@ | Order | Card | Weight | Status | | --- | --- | --- | --- | -| 1 | Shared Brunch TUI identity primitives | Full | next | -| 2 | Persistent chrome uses Brunch identity | Light | queued | +| 1 | Shared Brunch TUI identity primitives | Full | done | +| 2 | Persistent chrome uses Brunch identity | Light | next | | 3 | Brunch-host chrome visual evidence | Light | queued | | 4 | FE-744 closeout reconciliation | Light | queued | @@ -33,7 +33,7 @@ ## Card 1 — Shared Brunch TUI identity primitives -**Status:** next +**Status:** done **Weight:** full scope card ### Target Behavior @@ -85,7 +85,7 @@ The startup dialog's Brunch visual identity is provided by a reusable TUI identi ## Card 2 — Persistent chrome uses Brunch identity -**Status:** queued after Card 1 +**Status:** next **Weight:** light scope card ### Objective diff --git a/src/tui-client/.pi/__tests__/workspace-dialog.test.ts b/src/tui-client/.pi/__tests__/workspace-dialog.test.ts index 47ce3227..48e825cd 100644 --- a/src/tui-client/.pi/__tests__/workspace-dialog.test.ts +++ b/src/tui-client/.pi/__tests__/workspace-dialog.test.ts @@ -10,6 +10,10 @@ import { selectWorkspaceSelectionOption, runWorkspaceDialogPreflight, } from "../components/workspace-dialog/index.js" +import { + formatBrunchProductIdentity, + readBrunchAnsiLogo, +} from "../components/brunch-identity.js" import type { WorkspaceLaunchInventory } from "../../../workspace-session-coordinator.js" describe("spec/session picker", () => { @@ -304,6 +308,49 @@ describe("spec/session picker", () => { expect(lines.some((line) => line.includes("built on Pi v"))).toBe(true) }) + it("provides deterministic shared Brunch identity primitives", async () => { + const assetUrl = new URL( + "../components/workspace-dialog/assets/", + import.meta.url, + ) + + expect( + readBrunchAnsiLogo({ assetUrl, truecolor: false }).join("\n"), + ).toContain("\x1B[") + expect( + formatBrunchProductIdentity({ + logoLines: [], + colorMode: "plain", + version: { version: "v-test", dev: null }, + piVersion: "test-pi", + }), + ).toEqual([ + "█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", + "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", + "", + "brunch v-test", + "built on Pi vtest-pi", + ]) + expect( + formatBrunchProductIdentity({ + logoLines: ["logo"], + colorMode: "dark", + version: { version: "v-test", dev: "(dev abc)" }, + theme: { fg: (color, text) => `[${color}]${text}[/${color}]` }, + piVersion: "test-pi", + }), + ).toEqual([ + "logo", + "", + "[muted]█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █[/muted]", + "[muted]█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█[/muted]", + "", + "[accent]brunch v-test[/accent]", + "[success](dev abc)[/success]", + "[dim]built on Pi vtest-pi[/dim]", + ]) + }) + it("keeps logo assets colocated with the private picker component", async () => { const source = await readFile( new URL( diff --git a/src/tui-client/.pi/components/brunch-identity.ts b/src/tui-client/.pi/components/brunch-identity.ts new file mode 100644 index 00000000..725fe892 --- /dev/null +++ b/src/tui-client/.pi/components/brunch-identity.ts @@ -0,0 +1,141 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" +import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent" + +const ESC = String.fromCharCode(27) +const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) +const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") +const LOGO_TRUECOLOR = "brunch-logo-quad-56x18.ansi" +const LOGO_240 = "brunch-logo-quad-56x18-240.ansi" + +// Letterform copied from: cfonts "brunch" -f tiny -c candy. +export const BRUNCH_COMPACT_WORDMARK = [ + "█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", + "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", +] as const + +export type BrunchIdentityColorMode = "dark" | "light" | "plain" +export type BrunchIdentityTheme = Pick<Theme, "fg"> + +export interface BrunchVersionInfo { + version: string + dev: string | null +} + +export interface BrunchLogoReadOptions { + assetUrl: URL + truecolor: boolean +} + +export interface BrunchProductIdentityOptions { + logoLines?: readonly string[] + version: BrunchVersionInfo + theme?: BrunchIdentityTheme + colorMode?: BrunchIdentityColorMode + piVersion?: string +} + +export function readBrunchAnsiLogo(options: BrunchLogoReadOptions): string[] { + const asset = options.truecolor ? LOGO_TRUECOLOR : LOGO_240 + try { + return cropLogo( + readFileSync(fileURLToPath(new URL(asset, options.assetUrl)), "utf8") + .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") + .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") + .split("\n"), + ) + } catch { + return [] + } +} + +export function formatBrunchProductIdentity( + options: BrunchProductIdentityOptions, +): string[] { + const logo = [...(options.logoLines ?? [])] + const wordmark = BRUNCH_COMPACT_WORDMARK.map((line) => + identityStyle(options, "muted", line), + ) + const versionLine = identityStyle( + options, + "accent", + `brunch ${options.version.version}`, + ) + const devLine = options.version.dev + ? [identityStyle(options, "success", options.version.dev)] + : [] + const piLine = identityStyle( + options, + "dim", + `built on Pi v${options.piVersion ?? PI_VERSION}`, + ) + + return [ + ...logo, + ...(logo.length > 0 ? [""] : []), + ...wordmark, + "", + versionLine, + ...devLine, + piLine, + ] +} + +function identityStyle( + options: BrunchProductIdentityOptions, + color: ThemeColor, + text: string, +): string { + if (options.colorMode === "plain") return text + return options.theme ? options.theme.fg(color, text) : text +} + +function cropLogo(lines: string[]): string[] { + const cropped = [...lines] + while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) + cropped.shift() + while ( + cropped.length > 0 && + stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 + ) + cropped.pop() + if (cropped.length === 0) return [] + + const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) + return cropped.map((line) => removeVisibleColumns(line, commonLeft)) +} + +function stripAnsi(text: string): string { + return text.replace(ANSI_SEQUENCE_GLOBAL, "") +} + +function visibleLeadingSpaces(line: string): number { + const match = stripAnsi(line).match(/^ */) + return match?.[0].length ?? 0 +} + +function removeVisibleColumns(line: string, columns: number): string { + if (columns <= 0) return line + + let output = "" + let removed = 0 + for (let index = 0; index < line.length; index += 1) { + if (line[index] === ESC) { + const match = line.slice(index).match(ANSI_SEQUENCE) + if (match) { + output += match[0] + index += match[0].length - 1 + continue + } + } + + if (removed < columns) { + removed += 1 + continue + } + output += line[index]! + } + return output +} diff --git a/src/tui-client/.pi/components/workspace-dialog/component.ts b/src/tui-client/.pi/components/workspace-dialog/component.ts index 9fb52202..a407a92c 100644 --- a/src/tui-client/.pi/components/workspace-dialog/component.ts +++ b/src/tui-client/.pi/components/workspace-dialog/component.ts @@ -2,7 +2,6 @@ import { execSync } from "node:child_process" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" -import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent" import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent" import { Key, @@ -16,6 +15,11 @@ import type { WorkspaceLaunchInventory, SpecSessionActivationDecision, } from "../../../../workspace-session-coordinator.js" +import { + formatBrunchProductIdentity, + readBrunchAnsiLogo, + type BrunchVersionInfo, +} from "../brunch-identity.js" import { buildWorkspaceSelectionView, selectWorkspaceSelectionOption, @@ -24,17 +28,11 @@ import { } from "./model.js" export const WORKSPACE_DIALOG_WIDTH = 80 -const ESC = String.fromCharCode(27) const CTRL_C = "\x03" -const ANSI_SEQUENCE = new RegExp(`^${ESC}\\[[0-9;?]*[ -/]*[@-~]`) -const ANSI_SEQUENCE_GLOBAL = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g") const ASSET_DIR = new URL("./assets/", import.meta.url) const PACKAGE_JSON_URL = new URL("../../../../../package.json", import.meta.url) const LOCAL_BUILD_TIME = formatBuildTime(new Date()) -// Letterform copied from: cfonts "brunch" -f tiny -c candy -const BRUNCH_WORDMARK = ["█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"] - export type WorkspaceDialogTheme = Pick<Theme, "fg"> export interface WorkspaceDialogComponentOptions { @@ -116,25 +114,12 @@ class WorkspaceDialogComponent implements Component { "dim", "Choose or create the spec/session before the agent loop runs.", ) - const logo = readLogo() - const version = brunchVersion() - const versionLine = style( - this.#theme, - "accent", - `brunch ${version.version}`, - ) - const devLine = version.dev - ? style(this.#theme, "success", version.dev) - : null - const piLine = style(this.#theme, "dim", `built on Pi v${PI_VERSION}`) const lines = [ - ...logo, - ...(logo.length > 0 ? [""] : []), - ...BRUNCH_WORDMARK.map((line) => style(this.#theme, "muted", line)), - "", - versionLine, - ...(devLine ? [devLine] : []), - piLine, + ...formatBrunchProductIdentity({ + logoLines: readLogo(), + version: brunchVersion(), + theme: this.#theme, + }), "", title, subtitle, @@ -249,11 +234,6 @@ interface PackageJson { private?: unknown } -interface BrunchVersionInfo { - version: string - dev: string | null -} - function formatBuildTime(date: Date): string { return date .toISOString() @@ -333,19 +313,10 @@ function bottomBorderLine( } function readLogo(): string[] { - const asset = supportsTruecolor() - ? "brunch-logo-quad-56x18.ansi" - : "brunch-logo-quad-56x18-240.ansi" - try { - return cropLogo( - readFileSync(fileURLToPath(new URL(asset, ASSET_DIR)), "utf8") - .replace(new RegExp(`${ESC}\\[\\?25[lh]`, "g"), "") - .replace(new RegExp(`${ESC}\\[0m$`, "g"), "") - .split("\n"), - ) - } catch { - return [] - } + return readBrunchAnsiLogo({ + assetUrl: ASSET_DIR, + truecolor: supportsTruecolor(), + }) } function supportsTruecolor(): boolean { @@ -358,54 +329,6 @@ function supportsTruecolor(): boolean { ) } -function cropLogo(lines: string[]): string[] { - const cropped = [...lines] - while (cropped.length > 0 && stripAnsi(cropped[0]!).trim().length === 0) - cropped.shift() - while ( - cropped.length > 0 && - stripAnsi(cropped[cropped.length - 1]!).trim().length === 0 - ) - cropped.pop() - if (cropped.length === 0) return [] - - const commonLeft = Math.min(...cropped.map(visibleLeadingSpaces)) - return cropped.map((line) => removeVisibleColumns(line, commonLeft)) -} - -function stripAnsi(text: string): string { - return text.replace(ANSI_SEQUENCE_GLOBAL, "") -} - -function visibleLeadingSpaces(line: string): number { - const match = stripAnsi(line).match(/^ */) - return match?.[0].length ?? 0 -} - -function removeVisibleColumns(line: string, columns: number): string { - if (columns <= 0) return line - - let output = "" - let removed = 0 - for (let index = 0; index < line.length; index += 1) { - if (line[index] === ESC) { - const match = line.slice(index).match(ANSI_SEQUENCE) - if (match) { - output += match[0] - index += match[0].length - 1 - continue - } - } - - if (removed < columns) { - removed += 1 - continue - } - output += line[index]! - } - return output -} - function style( theme: WorkspaceDialogTheme | undefined, color: ThemeColor, From e18750a668bdf787425840f3fd9eefcaccaa9f38 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 11:08:56 +0200 Subject: [PATCH 160/164] FE-744: Brand persistent TUI chrome --- memory/CARDS.md | 8 ++++---- src/tui-client/.pi/__tests__/chrome.test.ts | 12 ++++++++---- src/tui-client/.pi/extensions/chrome.ts | 10 ++++++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/memory/CARDS.md b/memory/CARDS.md index ccb41109..a93653f4 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -25,8 +25,8 @@ | Order | Card | Weight | Status | | --- | --- | --- | --- | | 1 | Shared Brunch TUI identity primitives | Full | done | -| 2 | Persistent chrome uses Brunch identity | Light | next | -| 3 | Brunch-host chrome visual evidence | Light | queued | +| 2 | Persistent chrome uses Brunch identity | Light | done | +| 3 | Brunch-host chrome visual evidence | Light | next | | 4 | FE-744 closeout reconciliation | Light | queued | --- @@ -85,7 +85,7 @@ The startup dialog's Brunch visual identity is provided by a reusable TUI identi ## Card 2 — Persistent chrome uses Brunch identity -**Status:** next +**Status:** done **Weight:** light scope card ### Objective @@ -129,7 +129,7 @@ None — A10-L has been retired into `D35-L` / `I22-L`; A18-L command containmen ## Card 3 — Brunch-host chrome visual evidence -**Status:** queued after Card 2 +**Status:** next **Weight:** light scope card ### Objective diff --git a/src/tui-client/.pi/__tests__/chrome.test.ts b/src/tui-client/.pi/__tests__/chrome.test.ts index 618af69f..40cdbefc 100644 --- a/src/tui-client/.pi/__tests__/chrome.test.ts +++ b/src/tui-client/.pi/__tests__/chrome.test.ts @@ -39,7 +39,8 @@ describe("Brunch chrome projection", () => { } expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch", + "█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", + "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step", "spec: Spec One · session: Interview #1 · phase: elicitation", ]) @@ -55,18 +56,20 @@ describe("Brunch chrome projection", () => { } expect(formatBrunchChromeHeaderLines(state)).toEqual([ - "brunch", + "█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █", + "█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", "runtime: not reported", "spec: Spec One · session: Interview #1 · phase: elicitation", ]) expect(projectBrunchChromeFooterLines(state)).toEqual([ - "runtime: not reported · build: not reported", + "brunch · runtime: not reported · build: not reported", "context: not reported", "state: responding-to-elicitation · coherence: unknown · worker: not reported", "spec: Spec One · session: Interview #1", "", ]) expect(formatChromeWidgetLines(state)).toEqual([ + "brunch: █▄▄ █▀█ █ █ █▄ █ █▀▀ █ █ / █▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", "cwd: /tmp/project", "spec: Spec One", "session: Interview #1", @@ -97,7 +100,7 @@ describe("Brunch chrome projection", () => { } expect(projectBrunchChromeFooterLines(state)).toEqual([ - "runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", + "brunch · runtime: elicit-default · role elicitor · claude-sonnet · thinking medium · lens step-by-step · build: v0.0.0 dev abc123", "context: [█████░░░░░] 1,024/2,048 tokens (50%)", "state: responding-to-elicitation · coherence: needs_review · worker: observer-review/queued", "spec: Spec One · session: Interview #1", @@ -182,6 +185,7 @@ describe("Brunch chrome projection", () => { expect(calls.find((call) => call.method === "setWidget")?.args).toEqual([ "brunch.chrome", [ + "brunch: █▄▄ █▀█ █ █ █▄ █ █▀▀ █ █ / █▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█", "cwd: /tmp/project", "spec: Spec One", "session: session-1", diff --git a/src/tui-client/.pi/extensions/chrome.ts b/src/tui-client/.pi/extensions/chrome.ts index 7e451504..7764dd7b 100644 --- a/src/tui-client/.pi/extensions/chrome.ts +++ b/src/tui-client/.pi/extensions/chrome.ts @@ -4,6 +4,7 @@ import type { } from "@earendil-works/pi-coding-agent" import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui" +import { BRUNCH_COMPACT_WORDMARK } from "../components/brunch-identity.js" import type { WorkspaceSessionChromeState, WorkspaceSessionReadyState, @@ -57,7 +58,7 @@ export function formatBrunchChromeHeaderLines( chrome: BrunchChromeState, ): string[] { return [ - "brunch", + ...BRUNCH_COMPACT_WORDMARK, `runtime: ${formatRuntime(chrome)}`, `${formatChromeIdentity(chrome)} · phase: ${chrome.phase}`, ] @@ -73,7 +74,7 @@ export function projectBrunchChromeFooterLines( const identity = `${formatChromeIdentity(chrome)}${ branch ? ` · branch: ${branch}` : "" }` - const runtime = `runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}` + const runtime = `brunch · runtime: ${formatRuntime(chrome)} · build: ${formatBuild(chrome)}` const context = `context: ${formatContextUsage(chrome.contextUsage)}` return [ width === undefined ? runtime : alignChromeColumns(runtime, context, width), @@ -86,6 +87,7 @@ export function projectBrunchChromeFooterLines( export function formatChromeWidgetLines(chrome: BrunchChromeState): string[] { return [ + `brunch: ${formatCompactWordmark()}`, `cwd: ${chrome.cwd}`, `spec: ${formatSpec(chrome)}`, `session: ${formatSession(chrome)}`, @@ -99,6 +101,10 @@ function formatChromeIdentity(chrome: BrunchChromeState): string { return `spec: ${formatSpec(chrome)} · session: ${formatSession(chrome)}` } +function formatCompactWordmark(): string { + return BRUNCH_COMPACT_WORDMARK.join(" / ") +} + function sanitizeChromeStatuses( statuses: ReadonlyMap<string, string> | undefined, ): string[] { From ab7d07fa72b03a6e13f7e8c6a89f01549254a21b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 11:12:54 +0200 Subject: [PATCH 161/164] FE-744: Capture branded startup chrome evidence --- docs/architecture/pi-ui-extension-patterns.md | 8 +++-- memory/CARDS.md | 8 ++--- .../scripts/verify-startup-no-resume.sh | 34 +++++++++++++++---- src/probes/startup-oracle-script.test.ts | 18 ++++++++++ .../components/workspace-dialog/component.ts | 2 +- 5 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 src/probes/startup-oracle-script.test.ts diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 02495002..a58ae84c 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -141,7 +141,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/tui-cl - widget: cwd, spec, session, runtime, context, and chat-mode diagnostics; - title: compact Brunch-owned terminal title derived from activated workspace state. -The wrapper uses plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; widget/title provide deterministic state strings for tests and RPC-compatible clients. `ctx.ui.setStatus(key, text)` remains available as a lateral contribution channel for other extensions and future dynamic Brunch state; the chrome wrapper does not publish a `brunch.chrome` status key and filters that key if a stale producer contributes it. The wrapper deliberately does not fabricate build version, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses the shared compact Brunch wordmark plus plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; widget/title provide deterministic state strings for tests and RPC-compatible clients. `ctx.ui.setStatus(key, text)` remains available as a lateral contribution channel for other extensions and future dynamic Brunch state; the chrome wrapper does not publish a `brunch.chrome` status key and filters that key if a stale producer contributes it. The wrapper deliberately does not fabricate build version, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. Observed behavior: @@ -180,7 +180,9 @@ Runtime should **not** invoke Chafa on startup. The logo should be deterministic Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure spec/session picker UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. -The executable pty probe oracle is `src/probes/scripts/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains spec/session picker markers and not the stale transcript text. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. +The executable pty probe oracle is `src/probes/scripts/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains the compact Brunch wordmark, version/Pi line, spec/session picker markers, pre-agent-loop selection copy, and not the stale transcript text. A local run on 2026-05-30 passed with raw/stripped captures under the script-created `brunch-startup-oracle.*` workspace. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. + +Persistent chrome still needs qualitative live-host observation after explicit activation: the startup probe deliberately stops before selecting a spec/session so it can prove `I22-L` no-resume behavior without driving the agent loop. Manual closeout should confirm the post-activation header/footer/widget/title read as Brunch-owned, include the active session id/label and spec title, avoid Pi-branded primary surface leakage, and preserve the `brunch.chrome` widget/status-key discipline. The in-session product command is `/brunch` with `ctrl+shift+b`. It waits for idle, inspects inventory, renders the same typed centered spec/session picker with `ctx.ui.custom(..., { overlay: true })`, activates the returned decision through the coordinator, and then calls `ctx.switchSession()` only for the already-activated target file. Post-switch chrome and notification use the `withSession` replacement context only; cancel and `needs_human` decisions notify without switching. This does not override `/resume`, `/new`, or other built-ins; it is the Brunch-owned workspace adapter over Pi's session-replacement API. @@ -271,4 +273,4 @@ The seam Brunch has now proven is the product relay and parity loop around that - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. - The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. - The in-session `/brunch` menu and workspace/session action are unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. -- Dynamic chrome was visually proven in a raw Pi TUI harness and unit-proven in Brunch; FE-744's remaining active closeout is a full Brunch-host branded/themed visual walkthrough because the temp TUI proof did not exercise real coordinator-derived graph/lens/coherence data or the final product visual treatment. +- Startup branded chrome is now covered by the Brunch-host pty oracle; persistent activated chrome remains a qualitative manual walkthrough item because the no-resume oracle intentionally stops before activation to avoid obscuring the startup invariant. diff --git a/memory/CARDS.md b/memory/CARDS.md index a93653f4..cd0d3d64 100644 --- a/memory/CARDS.md +++ b/memory/CARDS.md @@ -26,8 +26,8 @@ | --- | --- | --- | --- | | 1 | Shared Brunch TUI identity primitives | Full | done | | 2 | Persistent chrome uses Brunch identity | Light | done | -| 3 | Brunch-host chrome visual evidence | Light | next | -| 4 | FE-744 closeout reconciliation | Light | queued | +| 3 | Brunch-host chrome visual evidence | Light | done | +| 4 | FE-744 closeout reconciliation | Light | next | --- @@ -129,7 +129,7 @@ None — A10-L has been retired into `D35-L` / `I22-L`; A18-L command containmen ## Card 3 — Brunch-host chrome visual evidence -**Status:** next +**Status:** done **Weight:** light scope card ### Objective @@ -175,7 +175,7 @@ None — this card verifies a frontier closeout condition rather than building a ## Card 4 — FE-744 closeout reconciliation -**Status:** queued after Card 3 +**Status:** next **Weight:** light scope card ### Objective diff --git a/src/probes/scripts/verify-startup-no-resume.sh b/src/probes/scripts/verify-startup-no-resume.sh index 3c6cbcd7..2cf43fe6 100755 --- a/src/probes/scripts/verify-startup-no-resume.sh +++ b/src/probes/scripts/verify-startup-no-resume.sh @@ -10,7 +10,10 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/brunch-startup-oracle.XXXXXX")}" CAPTURE_RAW="$WORK_DIR/startup.raw" CAPTURE_STRIPPED="$WORK_DIR/startup.stripped" +PROBE_TIMEOUT_SECONDS="${PROBE_TIMEOUT_SECONDS:-5}" STALE_TEXT="BRUNCH_STALE_TRANSCRIPT_SENTINEL_$(date +%s)_$$" +BRUNCH_WORDMARK_TOP="█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █" +BRUNCH_WORDMARK_BOTTOM="█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█" cd "$ROOT_DIR" npm run build >/dev/null @@ -31,17 +34,17 @@ workspace.session.manager.appendMessage({ console.log(`Seeded stale transcript: ${workspace.session.file}`) NODE -BRUNCH_CMD="cd '$WORK_DIR' && PI_OFFLINE=1 node '$ROOT_DIR/dist/brunch.js' --mode tui" +BRUNCH_CMD="cd '$WORK_DIR' && (stty rows 50 cols 100 2>/dev/null || true) && PI_OFFLINE=1 node '$ROOT_DIR/dist/brunch.js' --mode tui" set +e if script --version >/dev/null 2>&1; then - perl -e 'alarm shift; exec @ARGV' 3 script -q -f -c "$BRUNCH_CMD" "$CAPTURE_RAW" + perl -e 'alarm shift; exec @ARGV' "$PROBE_TIMEOUT_SECONDS" script -q -f -c "$BRUNCH_CMD" "$CAPTURE_RAW" else - perl -e 'alarm shift; exec @ARGV' 3 script -q -F "$CAPTURE_RAW" /bin/sh -lc "$BRUNCH_CMD" + perl -e 'alarm shift; exec @ARGV' "$PROBE_TIMEOUT_SECONDS" script -q -F "$CAPTURE_RAW" /bin/sh -lc "$BRUNCH_CMD" fi set -e -perl -CS -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/\e\][^\a]*(\a|\e\\)//g; s/\eP.*?(\a|\e\\)//g; s/\r/\n/g' \ +perl -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/\e\][^\a]*(\a|\e\\)//g; s/\eP.*?(\a|\e\\)//g; s/\r/\n/g' \ "$CAPTURE_RAW" > "$CAPTURE_STRIPPED" if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then @@ -50,6 +53,25 @@ if grep -Fq "$STALE_TEXT" "$CAPTURE_STRIPPED"; then exit 1 fi +if ! grep -Fq "$BRUNCH_WORDMARK_TOP" "$CAPTURE_STRIPPED" || \ + ! grep -Fq "$BRUNCH_WORDMARK_BOTTOM" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show the compact Brunch wordmark" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +if ! grep -Fq "built on Pi v" "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show the Pi version line" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + +if ! grep -Fq "Choose or create the spec/session before the agent loop runs." "$CAPTURE_STRIPPED"; then + echo "FAILED: startup capture did not show the pre-agent-loop selection copy" >&2 + echo "Capture: $CAPTURE_STRIPPED" >&2 + exit 1 +fi + if ! grep -Eq "Choose a specification|Create new specification|New specification title" "$CAPTURE_STRIPPED"; then echo "FAILED: startup capture did not show a stable spec/session picker marker" >&2 echo "Capture: $CAPTURE_STRIPPED" >&2 @@ -57,10 +79,10 @@ if ! grep -Eq "Choose a specification|Create new specification|New specification fi cat <<EOF -Startup no-resume oracle passed. +Startup branded no-resume oracle passed. Workspace: $WORK_DIR Raw capture: $CAPTURE_RAW Stripped capture: $CAPTURE_STRIPPED -Assertion: stale transcript sentinel was absent before explicit activation. +Assertion: Brunch identity, version/Pi line, and selection copy rendered while stale transcript sentinel was absent before explicit activation. EOF diff --git a/src/probes/startup-oracle-script.test.ts b/src/probes/startup-oracle-script.test.ts new file mode 100644 index 00000000..8fa5e8bf --- /dev/null +++ b/src/probes/startup-oracle-script.test.ts @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises" + +import { describe, expect, it } from "vitest" + +describe("startup TUI oracle script", () => { + it("asserts Brunch identity markers without promoting the host-sensitive probe into verify", async () => { + const script = await readFile( + new URL("./scripts/verify-startup-no-resume.sh", import.meta.url), + "utf8", + ) + + expect(script).toContain("BRUNCH_WORDMARK_TOP") + expect(script).toContain("built on Pi v") + expect(script).toContain("Choose or create the spec/session") + expect(script).toContain("manual/middle-loop oracle") + expect(script).not.toContain("npm run verify") + }) +}) diff --git a/src/tui-client/.pi/components/workspace-dialog/component.ts b/src/tui-client/.pi/components/workspace-dialog/component.ts index a407a92c..b74c3444 100644 --- a/src/tui-client/.pi/components/workspace-dialog/component.ts +++ b/src/tui-client/.pi/components/workspace-dialog/component.ts @@ -118,7 +118,7 @@ class WorkspaceDialogComponent implements Component { ...formatBrunchProductIdentity({ logoLines: readLogo(), version: brunchVersion(), - theme: this.#theme, + ...(this.#theme ? { theme: this.#theme } : {}), }), "", title, From 94cab6daaca18017023877a546ef61602ea22317 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 11:15:05 +0200 Subject: [PATCH 162/164] FE-744: Reconcile chrome closeout --- ...-ui-extension-patterns-provisional-plan.md | 91 -------- docs/architecture/pi-ui-extension-patterns.md | 12 +- memory/CARDS.md | 218 ------------------ memory/PLAN.md | 16 +- 4 files changed, 14 insertions(+), 323 deletions(-) delete mode 100644 docs/architecture/pi-ui-extension-patterns-provisional-plan.md delete mode 100644 memory/CARDS.md diff --git a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md b/docs/architecture/pi-ui-extension-patterns-provisional-plan.md deleted file mode 100644 index 02398093..00000000 --- a/docs/architecture/pi-ui-extension-patterns-provisional-plan.md +++ /dev/null @@ -1,91 +0,0 @@ -# Pi UI Extension Patterns — Structured Elicitation Working Plan - -This file is a trimmed working inventory for the remaining FE-744 gap. It is not canonical product contract; durable conclusions belong in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md`. - -## Why this is still live - -Command containment, Brunch chrome, startup no-resume, and the `/brunch` menu/workspace switch flow are proven enough for now. The unresolved POC seam is different: - -> Brunch sessions must work elicitation-first: a system/assistant-originated question, questionnaire, or offer should own the response surface, persist a terminal structured result in Pi JSONL, and be projectable as a prompt/response elicitation exchange before the next agent turn. - -The latest planning decision narrows the first proof away from a Brunch-only `brunch.offer` envelope. Basic structured questions should use Pi's registered-tool transcript seam when it is thinner: assistant `toolCall` for causal/positional context, toolResult `content` for the model-readable answer summary, and toolResult `details` as Brunch's self-contained structured response payload. Brunch custom entries remain valid for establishment offers, review-set proposals, annotations, and shapes that are not naturally tool questions. - -## Pi evidence already relevant - -- `docs/usage.md`: the editor can be replaced temporarily by built-in UI or custom extension UI. -- `docs/tui.md`: `ctx.ui.custom<T>()` can replace the editor area with a custom component and return typed data; overlays are optional, not required. -- `docs/tui.md` Pattern 7: `ctx.ui.setEditorComponent()` can replace the main input editor with a custom editor implementation if a future persistent pending-interaction surface needs it. -- `examples/extensions/question.ts`: single-choice options plus a "Type something" escape hatch using `ctx.ui.custom()`, returning answer data in `toolResult.details`. -- `examples/extensions/questionnaire.ts`: multi-question/tabbed choice UI with optional custom text answers, returning a full questionnaire result in `toolResult.details`. -- `examples/extensions/rpc-demo.ts`: `ctx.ui.editor()` emits Pi RPC `extension_ui_request` / `extension_ui_response` traffic. -- `examples/rpc-extension-ui.ts`: a non-Pi client can translate Pi RPC extension UI requests into its own prompt/dialog components and respond through the documented protocol. -- `examples/extensions/message-renderer.ts`: custom transcript display is available, but display rendering alone does not collect a response. - -## Target seam to prove - -### Structured-exchange result + JSON-editor RPC fallback - -1. A registered Pi tool asks a structured Brunch question or questionnaire. -2. The assistant tool call is preserved as prompt-side transcript context; it is not the only semantic source for projection. -3. In TUI mode, the tool replaces the default input surface with Brunch-owned custom UI supporting the POC interaction kernels: - - single-choice selection, - - multi-choice selection, - - questionnaire / multiple questions, - - optional freeform additional input, - - cancel/skip/unavailable where allowed. -4. In raw Pi RPC mode, complex shapes degrade through `ctx.ui.editor()` with schema-tagged JSON prefill; simple shapes may use Pi-supported `select`, `confirm`, or `input` where sufficient. -5. A Brunch-aware public client can render the pending interaction as a product form and translate the answer back into Pi's documented `extension_ui_response`. -6. The tool returns one terminal result whose `content` is generated from the same details and whose `details` are self-contained: schema/version, status, mode, prompt/questions, options, answers, and transport metadata. -7. Elicitation-exchange projection classifies terminal structured-exchange toolResults as response-side entries, while ordinary toolResults remain prompt-side unless typed markers say otherwise. -8. No graph mutation or review acceptance bypasses `CommandExecutor`; this slice proves interaction capture, not graph writes. - -## Active slice candidate - -**Name:** Structured-exchange result + JSON-editor RPC fallback - -**Goal:** Prove that a transcript-native structured question can replace ambient free input in TUI, stay controllable over Pi RPC, and persist a response payload that Brunch can project without rehydrating semantics solely from assistant tool-call arguments. - -**Likely implementation shape:** - -- Define a minimal structured-exchange result details payload with `schema`, `status`, `mode`, `prompt` or `questions`, `options`, `answers`, and `transport`. -- Add a Brunch-owned TUI helper modeled on Pi's `question.ts` / `questionnaire.ts` examples. -- Add JSON-prefill / validation helpers for RPC editor fallback. -- Add a Brunch Pi-RPC relay shim that maps Pi `extension_ui_request(editor)` to public Brunch pending-elicitation events/methods and maps the product answer back to `extension_ui_response`. -- Update elicitation-exchange projection to recognize typed terminal structured-exchange toolResults as response-side entries. - -**Acceptance:** - -- A fixture/demo session can ask a system/assistant-originated structured question with no ambient user prompt. -- The default freeform editor is replaced while the question is pending in TUI. -- The user can answer single-choice, multi-choice, questionnaire, and optional-freeform shapes. -- Raw Pi RPC can round-trip a complex response through schema-tagged JSON over `ctx.ui.editor()`. -- The terminal Pi JSONL toolResult includes self-contained structured details and model-readable content derived from those details. -- Elicitation exchange projection treats the prompt-side tool/custom entry and terminal structured result as one exchange. -- Public Brunch clients do not coordinate raw Pi RPC and Brunch RPC as two product APIs; raw Pi RPC remains behind an adapter. - -## Residual catalog still carried forward - -| Need | Status after current evidence | Carry-forward | -| --- | --- | --- | -| Single-choice question UI | Pi example-proven; Brunch loop not yet proven | Active slice | -| Multi-choice UI | Needs Brunch helper; Pi questionnaire patterns can be adapted | Active slice | -| Questionnaire | Pi example-proven; Brunch details schema/projection not yet proven | Active slice | -| Freeform-plus-choice | Pi `question.ts` proves the pattern | Active slice | -| JSON-editor fallback | Pi RPC editor evidence exists; Brunch schema/relay not yet proven | Active slice | -| Structured custom entries | Still valid for establishment offers, review sets, and product-native displays | Use only where thinner than toolResult details | -| Review-set approve/request/reject | Depends on terminal structured-response discipline and graph commands | M5 follow-up when `acceptReviewSet` exists | -| Establishment-offer orientation expansion | Must remain user-invoked, not a default exhaustive menu | M5/M7 follow-up | -| Mouse-clickable action buttons | Unproven and not required for POC if keyboard navigation works | Defer | -| Strict built-in command suppression | Requires Pi command/keybinding policy | Separate follow-up, not this slice | - -## Open questions - -- Which details schema name/version should become canonical for structured-exchange toolResults? -- Does every structured toolResult carry all options, or can simple cases store only selected options while richer projection references a prompt-side entry? Current SPEC posture says self-contained enough for projection, so default to carrying all prompt/question/option data until evidence says it is too heavy. -- Should unavailable/no-UI contexts return `status: "unavailable"` instead of an error-shaped content string? -- What is the thinnest Brunch method/event family for pending elicitation discovery and response submission: `elicitation.pending/respond`, `agent.ui.*`, or a private relay under `agent.*`? -- How much of the schema-tagged JSON editor prefill should be user-visible in raw Pi RPC versus hidden by Brunch-aware clients? - -## Retirement rule - -Retire this file only after the structured-exchange / RPC-relay loop is either implemented and reconciled into `docs/architecture/pi-ui-extension-patterns.md` / SPEC / PLAN, or intentionally moved into a named M5 frontier slice. Do not delete it merely because command containment or chrome work is complete. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index a58ae84c..9b05ff84 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -11,7 +11,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in | Branch-flow effect blocking (`/fork`, `/clone`, `/tree`) | proven for lifecycle/API effect cancellation; residual pre-cancel UI exposure remains | required for I19-L and already partly used by Brunch | source audit + raw RPC probe | | Extension command collision override | not-feasible | product commands must avoid built-in names unless Pi adds policy | source audit | | RPC-visible chrome/status degradation | proven for status/widget/title; no-op for header/footer/working indicator | informs fixture-driver expectations | Brunch wrapper unit oracle + raw RPC probe | -| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection and TUI mounting; branded/themed full-Brunch visual closeout still active | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + raw TUI transcript proof | +| Dynamic Brunch chrome wrapper | proven for deterministic product-state projection, branded startup identity, and TUI mounting; persistent activated chrome remains a manual polish check, not a frontier blocker | required before downstream M5/M6/M7 affordance wrappers call Pi UI primitives | Brunch-host tests + branded startup pty oracle + raw TUI transcript proof | | Startup spec/session picker | proven for Brunch-owned pre-Pi activation with no implicit transcript resume | required for I22-L | Brunch coordinator/UI tests + `src/probes/scripts/verify-startup-no-resume.sh` pty probe oracle | | In-session spec/session picker command | implemented/proven at command-handler seam; manual TUI walkthrough still useful | unlocks reusable spec/session selection beyond startup | Brunch extension command tests + coordinator store oracle | | Structured-exchange response loop | proven for current deterministic public-RPC permutations and web observation; review/candidate/capture runtime migrations deferred | required before M5 lens/review affordances depend on structured elicitation | Brunch schema/TUI/editor tests + live Pi RPC editor proof + JSONL exchange-projection tests + public-RPC parity artifacts + web live-update tests | @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/tui-client/pi-extension-shell.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/tui-client/pi-extension-shell.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. `src/probes/scripts/verify-startup-no-resume.sh` now supplies the Brunch-host branded startup pty oracle: the captured startup screen contains the compact Brunch wordmark, version/Pi line, selection copy, and no stale transcript before activation. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e <temp-extension>"`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e <temp-extension>` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-exchange RPC oracle:** `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. @@ -233,7 +233,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Structured-exchange product relay status -The remaining live FE-744 gap is not generic structured-exchange relay work. Brunch has now proven the private adapter/projection parts of the loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, web clients observe RPC-originated structured-exchange updates through the product invalidation/refetch path, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. The remaining FE-744 gap is branded/themed chrome recovery. +The remaining FE-744 closeout is no longer generic structured-exchange relay work or branded/themed chrome recovery. Brunch has proven the private adapter/projection parts of the structured-exchange loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, web clients observe RPC-originated structured-exchange updates through the product invalidation/refetch path, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. Brunch has also recovered product-owned startup/persistent chrome identity through shared TUI primitives, the chrome wrapper, and the branded startup pty oracle. The remaining residue is the accepted A18-L command-containment limitation: strict built-in command suppression still requires a Pi API seam. Pi source/docs already give strong evidence for the primitive: @@ -259,7 +259,7 @@ The seam Brunch has now proven is the product relay and parity loop around that ## Downstream posture - For the POC, Brunch can plausibly proceed if it hides disallowed commands from autocomplete and blocks branch/session effects with lifecycle hooks, **provided product documentation does not claim strict built-in suppression**. -- Dynamic Brunch chrome is strong enough to make the primary idle/working TUI surface read as Brunch-owned in a local proof, but exact built-in commands remain a residual shell-containment risk for product review. +- Dynamic Brunch chrome is strong enough to make the startup and primary idle/working TUI surface read as Brunch-owned; exact built-in commands remain a residual shell-containment risk for product review. - `I19-L` remains protected by effect blocking and transcript-reader fail-fast behavior, not by complete command invisibility. - M5/M6/M7 should route Brunch actions through Brunch-owned command names and handlers; extension command collisions are not an override mechanism. - M5/M6/M7 chrome/status affordances should call Brunch product wrappers (`renderBrunchChrome` or successors) instead of raw Pi `ctx.ui.*` primitives. @@ -271,6 +271,6 @@ The seam Brunch has now proven is the product relay and parity loop around that - Interactive autocomplete filtering was source-proven but not visually observed in a TUI session from this API-only run. - Exact interactive `/fork`, `/tree`, `/new`, and `/resume` pre-cancel UI exposure should be manually observed in Brunch TUI or a controlled Pi TUI before product signoff. - Keybinding unbinding/configuration strategy remains source-audited only; no Brunch-owned keybinding settings wrapper has been tested. -- The startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. +- The branded startup no-resume oracle is executable and passed locally, but it is intentionally not a default CI gate because pty/script behavior is host-sensitive. - The in-session `/brunch` menu and workspace/session action are unit-proven at the handler/replacement-context seam; a qualitative manual TUI walkthrough should still confirm interaction feel and final chrome/session id in a live Pi runtime. -- Startup branded chrome is now covered by the Brunch-host pty oracle; persistent activated chrome remains a qualitative manual walkthrough item because the no-resume oracle intentionally stops before activation to avoid obscuring the startup invariant. +- Persistent activated chrome remains a qualitative manual walkthrough item because the no-resume oracle intentionally stops before activation to avoid obscuring the startup invariant; this is visual-polish debt, not a blocked FE-744 seam. diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index cd0d3d64..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,218 +0,0 @@ -<!-- CARDS.md — temporary scope-card queue for one frontier item. - Created by ln-scope. Delete or overwrite when exhausted/superseded. - Containing frontier: pi-ui-extension-patterns (FE-744). --> - -# Scope Cards — FE-744 branded/themed chrome recovery - -## Orientation - -- **Containing seam:** Pi UI extension affordances, specifically Brunch-owned TUI chrome and workspace dialog visual identity under `src/tui-client/.pi/*`. -- **Frontier item:** `pi-ui-extension-patterns` / FE-744 on `ln/fe-744-pi-ui-extension-patterns`; these cards are slices inside that one frontier and do **not** imply new Linear issues or branches. -- **Volatile state:** no `HANDOFF.md`, prior `memory/CARDS.md`, or `memory/REFACTOR.md` was present when scoped. Recent sync edits in `memory/SPEC.md`, `memory/PLAN.md`, and `docs/architecture/pi-ui-extension-patterns.md` retire A10-L/A23-L and narrow FE-744 to visual chrome closeout + A18-L containment residue. -- **Main open risk:** a full Brunch-host visual proof is host/PTY-sensitive; keep deterministic assertions to textual/ANSI-stripped brand markers and record any irreducible manual visual judgment explicitly. - -## Frontier-level obligations carried by every card - -- Preserve `I19-L`: no branch creation/navigation, no mid-turn mutation, no parallel chat/turn store. -- Preserve `I22-L`: no prior transcript rendering or agent loop before explicit spec/session activation; the picker remains decision-only and the coordinator owns activation/binding. -- Preserve `D35-L`: Brunch chrome goes through `renderBrunchChrome` or its successor; do not scatter raw `ctx.ui.*` calls; do not publish a `brunch.chrome` status key. -- Preserve the public-RPC/product boundary: RPC fixtures may assert widget/title/notification events that Pi actually emits, not TUI-only header/footer internals. -- Keep product visual assets private to Brunch code; do not expose Brunch prompt packs, themes, or assets through ambient Pi resource discovery. -- Keep strict built-in command suppression out of these slices; A18-L remains an accepted/residual Pi API risk unless separately scoped. - -## Queue - -| Order | Card | Weight | Status | -| --- | --- | --- | --- | -| 1 | Shared Brunch TUI identity primitives | Full | done | -| 2 | Persistent chrome uses Brunch identity | Light | done | -| 3 | Brunch-host chrome visual evidence | Light | done | -| 4 | FE-744 closeout reconciliation | Light | next | - ---- - -## Card 1 — Shared Brunch TUI identity primitives - -**Status:** done -**Weight:** full scope card - -### Target Behavior - -The startup dialog's Brunch visual identity is provided by a reusable TUI identity module. - -### Boundary Crossings - -```text -→ src/tui-client/.pi/components/workspace-dialog/component.ts inline logo / wordmark / palette helpers -→ src/tui-client/.pi/components/<identity module> reusable Brunch visual primitives -→ workspace-dialog render output and build asset packaging -``` - -### Risks and Assumptions - -- RISK: moving or sharing logo helpers can break asset resolution under `dist/` because the current reader resolves assets relative to the workspace-dialog component module. - → MITIGATION: either keep assets in their current directory and pass the asset URL into the reusable helper, or update `build:pi-assets` with a test/build proof. -- RISK: visual helpers can accidentally shell out to Chafa or depend on ambient terminal state in tests. - → MITIGATION: keep generated ANSI assets as static inputs; make truecolor/240/plain fallback choices injectable or directly unit-testable. -- ASSUMPTION: a small Brunch-owned component helper is enough for product branding without using Pi theme/resource discovery. - → IMPACT IF FALSE: FE-744 would need a sealed-profile/theme slice before chrome closeout, delaying `sealed-pi-profile-runtime-state` and `graph-data-plane`. - → VALIDATE: unit-test the helper with dark/light/no-color projections and prove workspace-dialog still renders through product imports only. - -### Tracer-bullet check - -- **Invariants:** locates the private visual-identity boundary so future chrome and dialog code do not duplicate branding logic. -- **Proof of life:** workspace-dialog still renders its existing branded startup surface from the extracted helper. - -### Acceptance Criteria - -✓ `workspace-dialog` tests — the rendered picker still contains Brunch product copy, version/Pi lines, and no user-created workspace wording. -✓ new identity-helper tests — compact wordmark/logo fallback, dark/light palette wrapping, and no-color/plain fallback are deterministic without invoking runtime Chafa. -✓ `npm run build` or a targeted build-assets assertion — required static logo assets are present wherever the shared helper resolves them at runtime. - -### Verification Approach - -- Inner: unit tests — prove the identity module and workspace-dialog integration are deterministic. -- Middle: build asset check — prove compiled/runtime asset layout still supports the identity module. -- Outer: none for this card; visual judgment lands in Card 3. - -### Cross-cutting obligations - -- Do not expose Brunch visual identity as ambient Pi themes/resources. -- Do not persist startup/logo visuals into Pi JSONL. -- Preserve the picker as pure decision rendering; coordinator activation remains outside visual components. - ---- - -## Card 2 — Persistent chrome uses Brunch identity - -**Status:** done -**Weight:** light scope card - -### Objective - -Make `renderBrunchChrome` present compact Brunch-branded chrome through the shared identity module. - -### Acceptance Criteria - -✓ `chrome.test.ts` — header/footer/widget snapshots include the compact Brunch identity plus activated spec/session/runtime facts without fabricating missing fields. -✓ wrapper-call test — `renderBrunchChrome` still calls only `setHeader`, `setFooter`, `setWidget`, and `setTitle`; it never calls `setStatus`. -✓ RPC-compatible projection assertion — diagnostic `setWidget` and `setTitle` remain plain/product-shaped enough for RPC observers while header/footer stay TUI-only. - -### Verification Approach - -- Inner: chrome unit tests and typecheck — prove product-state projection and Pi UI call shape. -- Middle: none; Card 3 supplies host-level evidence. - -### Cross-cutting obligations - -- Preserve `D35-L`: chrome remains one stateless projection wrapper over a supplied product-state snapshot. -- Preserve status-key discipline: do not publish or echo `brunch.chrome` as a footer status contribution. -- Do not use Pi theme discovery or ambient `.pi` resources for Brunch branding. - -### Assumption dependency - -None — A10-L has been retired into `D35-L` / `I22-L`; A18-L command containment is deliberately out of scope. - -### Promotion checklist - -- [x] Does this change a requirement? **No**. -- [x] Does this create, retire, or invalidate an assumption? **No**. -- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. -- [x] Does this make or reverse a non-trivial design decision? **No**; it applies the identity boundary from Card 1. -- [x] Does this establish a new seam-level invariant? **No**. -- [x] Does this change a frontier-level obligation or verification layer? **No**. -- [x] Does it cross more than two major seams? **No**. -- [x] Is this the first touch in an unfamiliar seam? **No** after Card 1. -- [x] Can you not name the containing seam/current rationale? **No**; `D35-L` and FE-744 govern it. - ---- - -## Card 3 — Brunch-host chrome visual evidence - -**Status:** done -**Weight:** light scope card - -### Objective - -Capture Brunch-host evidence that the final chrome reads as Brunch-branded in a real TUI launch. - -### Acceptance Criteria - -✓ pty/script oracle — a host-sensitive probe captures a Brunch TUI startup screen and asserts ANSI-stripped brand markers, version/Pi line, spec/session selection copy, and absence of stale transcript text. -✓ activated-chrome evidence path — either the probe drives explicit activation to capture persistent chrome markers or `docs/architecture/pi-ui-extension-patterns.md` records why that step remains manual on this host. -✓ manual checklist — the documented walkthrough names the exact observations required for full-screen startup feel, persistent header/footer feel, active session id/label, and no Pi-branded primary surface leakage. -✓ no CI overreach — any host-sensitive pty probe stays outside `npm run verify` unless it is stable enough for ordinary local/CI execution. - -### Verification Approach - -- Inner: probe script unit/source checks where practical — prove assertions target product-shaped markers. -- Middle: pty/script probe — prove durable textual markers for the startup surface and, if feasible, activated chrome. -- Outer: manual TUI checklist — prove qualitative visual recovery that text oracles cannot fully encode. - -### Cross-cutting obligations - -- Preserve `I22-L`: startup proof must still show no stale transcript before explicit activation. -- Do not assert TUI-only header/footer through RPC fixtures; RPC proof is widget/title only. -- Keep manual evidence in `docs/architecture/pi-ui-extension-patterns.md`, not in ad hoc scratch reports. - -### Assumption dependency - -None — this card verifies a frontier closeout condition rather than building against a live SPEC assumption. - -### Promotion checklist - -- [x] Does this change a requirement? **No**. -- [x] Does this create, retire, or invalidate an assumption? **No**. -- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. -- [x] Does this make or reverse a non-trivial design decision? **No**. -- [x] Does this establish a new seam-level invariant? **No**. -- [x] Does this change a frontier-level obligation or verification layer? **No**; it instantiates the existing probe-oracle layer. -- [x] Does it cross more than two major seams? **No**; TUI host/probe/docs only. -- [x] Is this the first touch in an unfamiliar seam? **No** after Cards 1–2. -- [x] Can you not name the containing seam/current rationale? **No**; FE-744 visual closeout governs it. - ---- - -## Card 4 — FE-744 closeout reconciliation - -**Status:** next -**Weight:** light scope card - -### Objective - -Reconcile FE-744 visual evidence into the live docs and retire stale provisional planning residue. - -### Acceptance Criteria - -✓ `docs/architecture/pi-ui-extension-patterns.md` — Current verdicts, evidence inventory, open evidence gaps, and downstream posture reflect final branded/themed chrome evidence. -✓ `memory/PLAN.md` — `pi-ui-extension-patterns` status/current execution pointer no longer says chrome recovery is missing; any remaining A18-L command-containment residue is explicitly accepted, deferred, or routed to a future frontier. -✓ `docs/architecture/pi-ui-extension-patterns-provisional-plan.md` — deleted if all durable structured-exchange facts are already reconciled into SPEC/PLAN/evidence docs, or trimmed only if a still-live residue remains. -✓ `memory/CARDS.md` — this queue is marked done or deleted once exhausted. - -### Verification Approach - -- Inner: markdown/document consistency review plus `npm run fix` for repository convention. -- Middle: grep/reference audit — no stale claims that structured-exchange public relay or web observation are pending; no stale claim that chrome recovery is the current missing seam after closeout. -- Outer: none beyond Card 3 visual evidence. - -### Cross-cutting obligations - -- Do not archive handoffs/scope queues; delete exhausted derivative artifacts rather than preserving them as history. -- Keep PLAN at frontier granularity; do not turn these slices into new frontier items. -- Preserve cross-skill truth from `ln-design`/`ln-oracles`: public-RPC parity, probe artifact path, structured-exchange schema split, and chrome wrapper obligations must remain visible in SPEC/PLAN/evidence docs. - -### Assumption dependency - -None — this is canonical reconciliation and garbage collection after the scoped evidence lands. - -### Promotion checklist - -- [x] Does this change a requirement? **No**. -- [x] Does this create, retire, or invalidate an assumption? **No**; assumption retirement already occurred in sync. -- [x] Does this slice depend on an unvalidated high-impact assumption? **No**. -- [x] Does this make or reverse a non-trivial design decision? **No**. -- [x] Does this establish a new seam-level invariant? **No**. -- [x] Does this change a frontier-level obligation or verification layer? **No**. -- [x] Does it cross more than two major seams? **No**; docs/queue cleanup only. -- [x] Is this the first touch in an unfamiliar seam? **No**. -- [x] Can you not name the containing seam/current rationale? **No**; FE-744 closeout governs it. diff --git a/memory/PLAN.md b/memory/PLAN.md index 6545ce51..8884016e 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -14,11 +14,11 @@ ## Context -Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The remaining FE-744 work is Pi-wrapping closeout, not public-RPC substrate doubt: raw Pi RPC editor fallback, public Brunch JSON-RPC assistant-first structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private Brunch prompt-pack topology, and the Zod-authored structured-exchange details schema layer have landed. The remaining FE-744 seam is branded/themed visual chrome recovery, with strict command containment still recorded as an A18-L residual Pi API risk. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. +Brunch-next is proceeding on the razed `next` line (tag `next-baseline`) as a thin product layer over `pi-coding-agent`. M0–M3 proved the basic host, JSONL transcript viability, probe/RPC substrate, and read-only web shell; detailed completed frontier definitions now live in `docs/archive/PLAN_HISTORY.md`. The remaining FE-744 work is Pi-wrapping closeout, not public-RPC substrate doubt: raw Pi RPC editor fallback, public Brunch JSON-RPC assistant-first structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private Brunch prompt-pack topology, and the Zod-authored structured-exchange details schema layer have landed. FE-744's visual chrome closeout has landed; strict command containment remains recorded as an A18-L residual Pi API risk requiring a Pi command/keybinding policy seam rather than more Brunch wrapper work. After FE-744, `sealed-pi-profile-runtime-state` must make the embedded Pi harness product-safe. In concrete terms, the sealed-profile/runtime-state frontier prevents ambient user/project `.pi/` settings or resources from shaping Brunch behavior, and persists the active operational mode, role preset/runtime bundle, strategy, and lens in the linear transcript so prompt/tool posture can be reconstructed at turn boundaries. The M4 graph data plane remains structurally next after those harness/control-plane risks are scoped. ### POC assumption pressure -The POC should maximize assumption falsification rather than merely implement milestone labels. Treat the table below as the live consequence map from SPEC assumptions to frontier pressure; when scoping a frontier, prefer the thinnest slice that can validate or falsify its assigned assumptions. Retired/validated assumptions A10-L and A23-L are now carried by the SPEC decision/invariant residues for chrome, structured exchange, and public RPC parity; FE-744's live closeout is visual chrome recovery plus the still-open A18-L containment residue. +The POC should maximize assumption falsification rather than merely implement milestone labels. Treat the table below as the live consequence map from SPEC assumptions to frontier pressure; when scoping a frontier, prefer the thinnest slice that can validate or falsify its assigned assumptions. Retired/validated assumptions A10-L and A23-L are now carried by the SPEC decision/invariant residues for chrome, structured exchange, and public RPC parity; FE-744's remaining residue is the still-open A18-L containment limitation, not missing chrome work. | Assumption | Pressure / what could falsify it | Plan consequence | | --- | --- | --- | @@ -47,7 +47,7 @@ The POC should maximize assumption falsification rather than merely implement mi ### Active -1. `pi-ui-extension-patterns` — Finish FE-744's remaining Pi-wrapping proof now that raw Pi RPC editor fallback, public Brunch JSON-RPC structured-exchange permutation parity, and web real-time observation of RPC-originated structured-exchange updates are covered: recover branded/themed chrome. +1. `pi-ui-extension-patterns` — FE-744 Pi-wrapping proof is closing: raw Pi RPC editor fallback, public Brunch JSON-RPC structured-exchange permutation parity, web real-time observation of RPC-originated structured-exchange updates, private prompt-pack topology, structured-exchange schema layer, and branded/themed chrome recovery have landed. Remaining work is PR/branch tie-off plus carrying A18-L strict command-containment risk forward to the sealed-profile/runtime-state or upstream-Pi seam, not more FE-744 implementation. ### Next @@ -94,7 +94,7 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-741](https://linear.app/hash/issue/FE-741/graph-data-plane-intent-first-workspace-graph-ready-m4) - **Branch:** `ln/fe-741-graph-data-plane` (stacked on `ln/fe-737-web-shell`) - **Kind:** structural -- **Status:** next / paused until FE-744 chrome recovery closes and the sealed-profile/runtime-state follow-up is scoped +- **Status:** next / unblocked by FE-744 chrome recovery; paused until the sealed-profile/runtime-state follow-up is scoped - **Objective:** Stand up SQLite-backed graph persistence; durable intent-plane nodes and edges; a single global LSN per commit; the change log; the reconciliation-need substrate; named homes for coherence state (verdicts and violations) — all forward-compatible with oracle, design, and plan planes. - **Why now / unlocks:** Pins I1-L, I6-L. Unlocks all agent ↔ graph work (M5+) and lets oracle / design / plan planes be added later without re-foundation. - **Acceptance:** Graph CRUD + change-log replay tests pass through the `CommandExecutor` public mutation boundary; command results already include success, `needs_human`, `policy_blocked`, `version_conflict`, and `structural_illegal` shapes even if pre-M6 policy classification is minimal; reconciliation-need substrate accepts inserts/updates/resolutions with LSN invariants enforced; oracle-plane stub tables exist (Check, Validation Method, Evidence, Obligation) even if unused; the persistence layer proves the one-transaction protocol that couples authority/result classification, version checks, structural validation, LSN allocation, change-log append, and any coherence updates. @@ -232,15 +232,15 @@ The POC should maximize assumption falsification rather than merely implement mi - **Linear:** [FE-744](https://linear.app/hash/issue/FE-744/pi-ui-extension-patterns) - **Branch:** `ln/fe-744-pi-ui-extension-patterns` (off `ln/fe-737-web-shell`, parallel to `ln/fe-741-graph-data-plane`) - **Kind:** structural (spike-flavored) -- **Status:** in-progress (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection comments, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/`, and the Zod-authored structured-exchange schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` have landed. Current missing product seam is visual chrome recovery.) +- **Status:** implementation-complete / tie-off (command-containment, dynamic chrome semantics, hierarchical spec/session picker startup + in-session flow, RPC/headless initial-selection contract, pty startup oracle, centered branded overlay reuse, evidence-memo reconciliation, structured-exchange schema/builder, TUI/editor adapters, live Pi RPC editor fallback, response-side projection, option-selection comments, structured-exchange editor fallback, raw Pi RPC structured-exchange evaluator proof, discoverable structured-exchange extension source at `src/tui-client/.pi/extensions/structured-exchange/index.ts`, public Brunch RPC structured-exchange tuple parity through the current deterministic permutation set, parity hardening for distinct exchange ids, terminal non-answered statuses, option content/rationale, no repeated deterministic prompts, committed `.fixtures` public-RPC parity probe artifacts, web real-time observation of RPC-originated structured-exchange transcript updates, private code-composed Brunch prompt-pack topology under `src/tui-client/.pi/context/`, the Zod-authored structured-exchange schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/`, shared Brunch identity primitives, branded persistent chrome, and Brunch-host branded startup pty evidence have landed. Strict built-in command suppression remains an A18-L Pi API residue.) - **Objective:** Demonstrate the Pi extension seams and Brunch product RPC seams needed before M5/M6/M7 depend on them: product-named commands routed through Brunch handlers; effect blocking for unsupported branch/session flows; dynamic Brunch-owned chrome through one wrapper; Brunch-owned startup/session selection; structured elicitation where system/assistant-originated questions use Pi transcript truth and TUI/RPC adapters; and, now active, a public Brunch JSON-RPC structured-exchange loop where an agent-as-user discovers methods, activates workspace/spec/session, starts/resumes assistant-first elicitation, answers pending structured exchanges through Brunch methods, and leaves transcript/projection evidence for current exchange permutations comparable to a TUI session. -- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/comment/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. The remaining active acceptance is that branded/themed chrome is recovered from the diagnostic dump before FE-744 closes. +- **Acceptance:** `docs/architecture/pi-ui-extension-patterns.md` catalogs the evidence with verdicts (`proven` / `feasible-with-cost` / `requires-pi-change` / `not-feasible`), distinguishes strict command suppression from lifecycle effect blocking, records the minimum upstream Pi command/keybinding policy ask, and captures the RPC degradation profile for chrome/custom UI. Brunch code exposes a product-named extension entrypoint plus wrappers for chrome, command policy, session lifecycle binding, and `/brunch`; the centered spec/session picker supports an optional continue-last fast path plus hierarchical create-spec/resume-spec/create-session/resume-session decisions without UI-owned session mutation and is shared by startup plus in-session adapters; TUI startup runs a Brunch-owned pre-Pi gate before `InteractiveMode` so prior transcript rendering is opt-in rather than implicit; creating a new session lands in a binding-only session for the selected spec; chrome receives the activated session id instead of fabricating `unbound`; the startup no-resume pty oracle proves stale transcript text is absent before explicit activation. Public RPC structured-exchange parity is now covered: `rpc.discover` describes the supported Brunch JSON-RPC surface with method descriptions, param/result schemas, and examples; `workspace.selectionState` / `workspace.activate` let the driver enter a new workspace→spec→session without invoking TUI picker code; `session.startElicitation`, `session.pendingExchange`, and `elicitation.respond` expose an assistant-first pending-exchange lifecycle over Brunch methods, not raw Pi commands; the deterministic agent-as-user driver answers the current structured-exchange permutations through Brunch JSON-RPC only and reports blockers/frictions; the resulting Pi JSONL plus `session.transcriptDisplay` and `session.elicitationExchanges` projections preserve prompt/question/option content/rationale/answer/comment/mode/status artifacts at TUI-comparable quality. Web clients now receive real-time product update notifications for RPC-originated structured-exchange mutations and refetch canonical projection handlers rather than reading from a parallel view store. Branded/themed chrome has been recovered through shared identity primitives, persistent chrome wrapper updates, and a Brunch-host branded startup pty oracle; persistent activated chrome has only qualitative manual-polish debt remaining. - **Verification:** Inner — verify gate plus unit tests for any extension wrappers added; coordinator inventory/activation tests for switch decisions; source/contract tests that switcher UI returns decisions rather than mutating sessions; schema tests for structured question result details and JSON-editor request/response parsing. Middle — probe oracles per affordance category (manual checklist + executable postcondition checker on chrome state, JSONL tool results/custom entries emitted, or command-result discriminants); contract tests for Brunch handler shapes (`rpc.discover`, picker selection, elicitation start/pending/respond relay, transcript projections); pty/ANSI-stripped startup oracle proving no prior transcript appears before an explicit resume/open decision; raw Pi RPC probe demonstrating `ctx.ui.editor` JSON fallback round-trips through the documented extension UI protocol as supporting evidence only; scripted TUI demo covering all supported structured-exchange permutations; deterministic public Brunch RPC agent-as-user parity probe where the evaluator has a mission/intention, critical UX or feature-evaluation focus, permutation-bounded turn budget, and blocker/friction report; parity oracle over the saved Pi JSONL plus transcript/exchange projections, including no repeated deterministic prompts; web real-time update smoke proving browser state changes when selected session/exchange state changes via RPC-originated structured-exchange mutations; TUI-originated observation remains covered only if it reuses the same product invalidation path. Outer — manual TUI walkthrough validating visual quality, full-screen startup feel, interaction feel, and controllability cost between scripted-driver and manual paths. - **Cross-cutting obligations:** Preserve the linear-transcript invariant (`I19-L`) — affordance prototypes must not introduce branch creation, mid-turn state mutations outside the command layer, or a parallel chat/turn store. Preserve the workspace hierarchy and startup invariant (`R19` / `I22-L`): the workspace is the cwd, not a user-created selectable object; `.brunch/state.json` is default acceleration, not implicit resume; no prior transcript or agent loop may run before an explicit spec/session activation decision. Spec/session picker UI must remain pure decision rendering; `WorkspaceSessionCoordinator` owns inventory, activation, state writes, session creation/opening, and binding. RPC/headless startup must expose structured initial-selection state/results, not invoke the TUI picker. Structured-exchange affordances must use Pi transcript truth first: `toolResult.details` may be the canonical structured response payload, including optional user `comment` fields for option-selection exchanges, while assistant tool-call args are positional/causal context. Slash commands and action buttons must route writes through the `CommandExecutor`; the JSON-editor RPC fallback is an adapter over Pi's supported extension UI protocol, not a new public Pi command family and not a bypass around Brunch's product RPC surface. Public agent-as-user probes must speak Brunch JSON-RPC (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.*`) and may delegate to Pi RPC only behind Brunch adapters. Any new custom-entry kinds must declare `lens` per `I18-L` if elicitor-emitted. Establishment-offer affordances must stay orientation-first and user-invoked when expanded, rather than turning the full offer tree into a default next-action menu. TUI chrome/status affordances should call Brunch product wrappers rather than raw Pi `ctx.ui.*` primitives; the chrome wrapper must not publish its own `brunch.chrome` status key, and RPC fixtures should assert only chrome events that Pi actually emits for the current wrapper (diagnostic string-array `setWidget`, `setTitle`, notifications, and any future explicit status adapter rather than TUI-only header/footer). - **Why now / unlocks:** Lens/review-set/reviewer UX in M5 and authority gating in M6 both assume Brunch can render rich interactive affordances over Pi without forking it. Proving the affordance set early de-risks those frontiers and lets the agent-as-user-driver extension question (controllability vs cost trade flagged in `ln-oracles` pass) be answered with evidence rather than estimation. Can run in parallel with `graph-data-plane` because TUI seams are independent of graph persistence. - **Traceability:** R4, R14, R16, R17, R19, R20, R21, R24, R27, R28 / D2-L, D5-L, D11-L, D12-L, D13-L, D17-L, D19-L, D21-L, D22-L, D24-L, D25-L, D26-L, D27-L, D29-L, D32-L, D33-L, D34-L, D35-L, D36-L, D37-L, D38-L, D39-L, D40-L, D41-L, D48-L, D49-L, D50-L / I10-L, I13-L, I18-L, I19-L, I22-L, I23-L, I24-L, I25-L, I26-L, I32-L, I33-L / A14-L, A17-L, A18-L, A19-L -- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [pi-ui-extension-patterns-provisional-plan.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns-provisional-plan.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). -- **Current execution pointer:** FE-744 has landed the public-RPC structured-exchange parity spine and its hardening: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; `src/probes/public-rpc-parity-proof.ts` drives the deterministic permutation set through public Brunch JSON-RPC only; and the committed run under `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` carries `session.jsonl`, rendered `transcript.md`, and `report.json`. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are specified as transcript-native ANALYSIS toolResults. The Zod-authored schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` now captures the target present/request/capture details contract (`schema` + `v`, `exchange_id`, `tool_meta`, `comment`/`message`, candidate rubrics/graph refs, and minimal no-graph capture details) with parse and JSON Schema export tests; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The Brunch extension shell is explicit again, and Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: return to branded/themed chrome recovery. Do not return to `graph-data-plane` until chrome recovery closes FE-744's visual closeout and the A18-L command-containment residue is explicitly accepted or scoped. Run a separate `ln-design` pass before expanding capture-analysis payloads, shared transcript component subparts, or the runtime migration from tuple details to the new Zod exports. +- **Design docs:** [pi-seam-extensions.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md), [pi-ui-extension-patterns.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-ui-extension-patterns.md), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md). +- **Current execution pointer:** FE-744 has landed the public-RPC structured-exchange parity spine and its hardening: `rpc.discover` lists the current Brunch methods; activated sessions can start/resume deterministic `present_*` pending exchanges; `elicitation.respond` appends matching `request_answer`, `request_choice`, or `request_choices` toolResult evidence; `session.pendingExchange`, `session.elicitationExchanges`, and `session.transcriptDisplay` project tuple-shaped Pi JSONL; `src/probes/public-rpc-parity-proof.ts` drives the deterministic permutation set through public Brunch JSON-RPC only; and the committed run under `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/` carries `session.jsonl`, rendered `transcript.md`, and `report.json`. The structured-exchange UI extension has been remodeled into sequential `present_*` / `request_*` tools under `src/tui-client/.pi/extensions/structured-exchange/`: `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices` are registered; review/candidate tools remain named stubs and intentionally unregistered, while future `capture_*` tools are specified as transcript-native ANALYSIS toolResults. The Zod-authored schema layer under `src/tui-client/.pi/extensions/structured-exchange/schemas/` now captures the target present/request/capture details contract (`schema` + `v`, `exchange_id`, `tool_meta`, `comment`/`message`, candidate rubrics/graph refs, and minimal no-graph capture details) with parse and JSON Schema export tests; runtime tools and projections still use the existing tuple details model until a later migration slice deliberately rewires them to those exports. Pi can auto-discover the extension when launched from `src/tui-client` for `/reload`-based iteration, while production imports it explicitly through `src/tui-client/pi-extension-shell.ts`; keep tests under `src/tui-client/.pi/__tests__/`, not in auto-discovered `.pi/extensions` or `.pi/components` resource directories. The Brunch extension shell is explicit again, and Brunch product prompting now has a private prompt-pack/context topology at `src/tui-client/.pi/context/`; do not expose these packs through Pi `resources_discover`/`promptPaths`. Next build: scope `sealed-pi-profile-runtime-state`; do not return to `graph-data-plane` until the sealed-profile/runtime-state follow-up is scoped. A18-L strict command-containment is accepted as a residual Pi API risk unless that follow-up explicitly routes a narrow upstream/API ask. Run a separate `ln-design` pass before expanding capture-analysis payloads, shared transcript component subparts, or the runtime migration from tuple details to the new Zod exports. ### flue-pattern-adoption From 95941c17139e8d9d94b244d12f34cbbda9285530 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 16:43:31 +0200 Subject: [PATCH 163/164] FE-744: Reconcile chrome title status --- memory/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/SPEC.md b/memory/SPEC.md index 263978a1..c5eb1360 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -332,7 +332,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Chrome surface evolution -- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch defers wiring them until the question of *what state they should indicate* is sharper. Candidate signals once a canonical chrome-state snapshot exists: terminal title carries spec/session identity with optional working-state tied to the active agent role (e.g. eliciting / observing / reviewing / reconciling) rather than raw `agent_start`/`agent_end`; hidden-thinking label varies by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…"). Both depend on stable producers for those signals — the chrome wrapper must not synthesize state it doesn't have, so wiring is deferred until the relevant subsystems (agent-role dispatcher, lens registry) land. Until then, Brunch's chrome owns header and footer projection only; title and hidden-thinking-label remain Pi defaults. +- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch now uses `setTitle()` narrowly as part of the D35-L chrome wrapper: the title is a stateless projection from the activated product snapshot, currently `brunch — <spec title | cwd>`, and must not synthesize working-state it does not have. Richer title states tied to active role/lens/workflow remain deferred until stable producers exist. Hidden-thinking-label remains deferred: candidate labels vary by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…") and depend on the relevant subsystems (agent-role dispatcher, lens registry) landing first. - **Status keys as the dynamic contribution channel.** `ctx.ui.setStatus(key, text)` remains the multi-extension-friendly seam for other Brunch extensions and future dynamic Brunch state to surface in the footer's status row. Brunch's chrome wrapper does not contribute its own status key by default; it merges all foreign status entries via `footerData.getExtensionStatuses()` into the footer's right column so contributions surface without anyone owning the whole footer. ## Lexicon From 782626d1e761e8d859be3767139d35a4ba0c231f Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Sat, 30 May 2026 16:45:06 +0200 Subject: [PATCH 164/164] plan enhancements re graph extension --- memory/PLAN.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 8884016e..7c5117b4 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -143,7 +143,8 @@ The POC should maximize assumption falsification rather than merely implement mi └── async substrate (conditional) └── if observer/auditor queues land → backstops only, not primary capture freshness path ``` -- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing through targeted probe runs; first batch-proposal probe (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in probe metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem probe scenarios exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. +- **Implementation layout:** Put the Pi-facing adapter in one explicit product extension directory, `src/tui-client/.pi/extensions/graph/`, imported by `src/tui-client/pi-extension-shell.ts` as `registerBrunchGraph` rather than discovered dynamically. Use `graph/index.ts` only to register Pi tools, message renderers, and event hooks. Keep tool definitions in `graph/tools/*` (`read-graph`, `create-intent-node`, `update-intent-node`, `link-intent-nodes`, `accept-review-set`), boundary schemas in `graph/schemas/*` (`tool-inputs`, `tool-results`, `custom-entries`), transcript helpers in `graph/transcript/*` (`entries`, `projections`, `renderers`), synchronous capture in `graph/capture/post-exchange-capture.ts`, reviewer target enforcement in `graph/reviewer/reviewer-writes.ts`, and the Pi→CommandExecutor translation seam in `graph/command-adapter.ts`. The extension directory must not own SQLite/Drizzle persistence, LSN allocation, structural graph validators, reviewer-agent implementation, or capture model/prompt machinery; those are Brunch product/core modules passed into the extension through explicit shell options such as `{ graph: { commandExecutor, capturePostExchange?, reviewerWrites? } }`. +- **Verification:** Inner — verify gate plus graph-tool/capture/reviewer command shape tests, proposal-entry schema validation (`brunch.review_set_proposal` must declare `epistemic_status` and support/grounding coverage), establishment-offer / elicitor-intent-hint schema validation (must declare `lens`), structured-exchange `preface` contract tests, and projection-helper tests for latest-offer lookup. Middle — `CommandExecutor` contract tests including `acceptReviewSet` discriminants and the rule that only dry-run-valid proposals become reviewable review sets, direct-DB no-bypass checks, extension-layout/import-boundary tests proving `src/tui-client/.pi/extensions/graph/**` reaches graph mutation only through `command-adapter.ts` and never imports Drizzle/SQLite directly, post-exchange capture fixtures distinguishing committed facts from preface-only implications, reviewer-job restart/idempotence tests keyed by batch-acceptance entry id, reviewer-write-target architectural boundary test (rejects non-`reconciliation_need` targets), `acceptReviewSet` batch-atomicity property tests (one LSN / one change-log entry; partial-batch impossible under mid-batch validation failure), `supersedes`-chain acyclicity property tests, lens-routing correctness property tests, differential test comparing dry-run validation at proposal time vs real-run validation at acceptance, and cross-surface projection checks. Outer — kernel-card-output coverage assertions begin landing through targeted probe runs; first batch-proposal probe (e.g. `propose-scenarios-with-tradeoffs`) replays through review cycle + acceptance; A14-L proposal structural-legality rate captured in probe metadata as POC-phase fitness (not merge gate); 1–2 known-bad coherence-problem probe scenarios exercise reviewer precision; side-task / elicitor-capture / reviewer-attributed writes remain indistinguishable from other writes at the command-layer boundary except for attribution and reviewer's narrow target. - **Cross-cutting obligations:** Preserve the single-authority mutation rule for primary-agent, elicitor-capture, reviewer, side-task, and batch-acceptance flows by making the `CommandExecutor` the only mutation entry; deferred observer/auditor jobs, if introduced, are operational backstops keyed to transcript anchors, not a revived chat/turn store or privileged primary extraction path; reviewer is advisory and writes only to `reconciliation_need`; lens metadata on elicitor-emitted entries routes capture/reviewer/future-auditor consumption; establishment offers remain orientation artifacts for chrome/web surfaces rather than a default exhaustive lens picker. - **Traceability:** R10, R13, R17, R21, R22, R23 / D4-L, D13-L, D15-L, D18-L, D20-L, D25-L, D26-L, D27-L, D28-L, D29-L, D30-L, D32-L, D45-L, D46-L, D47-L, D50-L / I2-L, I11-L, I14-L, I15-L, I16-L, I17-L, I18-L, I20-L, I30-L, I31-L, I33-L / A3-L, A11-L, A13-L, A14-L, A16-L, A22-L - **Design docs:** [prd.md §M5, §Authority Model](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/prd.md), [pi-seam-extensions.md §1 Async side-chain sub-agents](file:///Users/lunelson/Code/hashintel/brunch-next/docs/architecture/pi-seam-extensions.md#1-async-side-chain-sub-agents), [ELICITATION_LENSES.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md), [REVIEW_SETS.md](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/REVIEW_SETS.md)