From 4b1c26049cfaddfaea04fc08729c2ffd0dc535e4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:02:49 +0200 Subject: [PATCH 01/93] 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 233c2cd19c50a1ae69e75bf981c880f26d5a8194 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:10:10 +0200 Subject: [PATCH 02/93] 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 ee3faff84ea4c2fd7b35c327f995e7003c45b1bd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:15:30 +0200 Subject: [PATCH 03/93] 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 9e3a5acd7a7d03699f9109454aefb1ecfd689ca9 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:22:36 +0200 Subject: [PATCH 04/93] 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 1fe7ce4d8fd51ec4ced4271e770a5968f93d3ac0 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:22:36 +0200 Subject: [PATCH 05/93] 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 a56a215ee7f5f4abe8eb74977fcc3fcc19a4a635 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 14:26:29 +0200 Subject: [PATCH 06/93] 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 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%0DDL_6yH&L*VyT!5tEyg_naFTF*%K| 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 n3z6f^E8Km}+1PM7 z`&pQJn@Zp_zBZ2T5xsRvk|5o(%Paab0CO0lr9N-m-q%8Km zwdYWC@SNnIS z$pNrFd`~8%_tCC#7Vr=Gq2p=YAdU&?*3tg`jb12It8T{Gu;k+8H0-|SoO*QwEnNSW zwDJ9`2$xM|Q$}%~l0knivrsK1=rdBAj`nqR>S8g?I~%)XrbSTZjrx_86TjHT=Ap)pV zMoX4_RTH?G>CYE}uKxBVBBtm%-|IXdn{_@>u;|;v2VH3RnA{i`j2i~nw%5y>(FN>; z&v0SiGH|({vU#h*Bm*N=dkOd3UU7xK))ha659Np7qy9OZ|3rh7EB%m6UrCtC2=dRL zSH*&gA`@#@Es7>gGEYYNOxGJ55`Ks`WaWBF;<4cSHlnjmN0sdK@7bT-GGyU$IR@H( zOKww_%!^Go$I;v;4?9#^{v8;sB+7r{R@y1P5}TRo49R+%u)!XOZR}>do`?1R^w@df zG&TPPu{4du=hF1sEPc!Gib2jE@BD1Lq8f-Xj3L~cmZ;x1w}Ee-2A!sxY@~5N(_j%m zX(QXq$p7_Z7W0SM8e z@aQ~M7n$KPqbTh_RK(LJLR(@@HHROmI8LAxo(A1?e_DfhuR`L*;cn8q*3+nw8dtF- z{57sH!_QU(4}f$b^-;~`3@I++M%zDqgelki3!^A2<*|>KR;+%qUW%Lv=O&|8(rP%l z_xq17>61T&dou;Z&IQx-O-ak=b}G3Rw-4#?w!N92{9kO(m;HyI0A!_jpz{Ozd_m(p z_Rgs+^LxYW7?+6rv}P?Ave4t*yRt{Qq0Te*6UQQaB9aAi{9meV11a{;$DV+-DL41< zk3xoR<0e58&ZU+~`+@b0oDkDE=lL+t&{G$vW|_}4htpx61`d`aXnQd^PfqT!PS%Mcz#0CFI)0v`>R9Zjew< zZ_+Ki^RIyoBwc>M_{o??i4Q$eDL)PJ`OO7<%RUpe#iSwL%Q0AP23kp0%VT#Y#)LQFJLm+;Rm z_|hBgFE*`+yZ#?z_8j`5NY(LrF(u6R?lTqNyfdftgl%$Jbi8!QY~%S)hdIX42#xbX zYwRl*u4*WT8yq7;I(^CSwpq!BS|)GCtJ=ARsdyqm^}nLeV+1~lH{;jr*xUK!%`ZNf zf*b8yOj&iMJQPCYql4CUg&7wV+HJtK5d%MF8;N-))O7^k)#Sx|iR>#T1Cje|ly26# z?_?!beEVR2i~*FYGu!nZRFTi!u8ENi>F$?RemH=lL{E zyz)hU1wjmwMjSRA+aS5VAsYXfB#M_MTH za^Z`WJ|2(OT?xo_sHgnPAWf@s`!fluH$D1Cl0YQCkef6a%Is)X8b|2gisnCtT#Eu;s!wY3sv5c(M%db0>| zLX3Tq)AK|iLH!NWCcQ6J7-O*QWp&IdYR+2HQ;TIERkXxi?g`-YP8&qM$P4t$4tZ5C z`O0(|@ot-TF#gr!m=Lpf$OVymmG5|QRgMQS)n#-&Ik*_Eh7hw!Io?_aWra6BiIv|h zyZOwu%eUoMueM1BRJpGP4Now1bweF~s+`nShwlCWQqnZ^eZ0Jn)5>DEp>uuwDPcBg zm_gf1)>DmOCQ9*Wo!bOe`91L&{j|47*h!ZxM0`Inm{Tz2TPpMgT?aHL@aQb)9B|eYIZj*9zho_-d)z^ZA7z<%9k${qwnijp3cQs7Kuz3R=&l z6F6pb0ljy|OWLFX8Uh2IW{$6wDyK6)Md=#{9PdlUC7Yr?it#QO(%JrSNvd`>SqS0( z(Yx3miJ;9_S^5|YqbaC$5-kXaS9Xzbo6F|O0M*A7pFg|}l9@^2$4PaY)t(m}-*|g7 zHF&<~o%Eg0L$IPKt(3IEywCuKz}#lJkl93>W4i9dkf`Qh#hvpmC_q{NWi;EZ;or(3 zzJ0aTN+KtJV9x#kn4*OSpb5BTir*il%*%Qd$PQkXK!?_9G{elUS5z1GV+>~T*E`nO zx~~4bbnKmDT-G9D+SN_;;~G)!C3rcvj8MaDaIe0EXQyioHbS3Lo8%AhR|r&uPS6Gq zUz(r~8Vyu>1QbDXQBWbbzhRIM)(x~xpS6lSZC%mH4!*SuNoP1Vop0OIQTK`bVo zGoFUG{FK8 zKmap{A^{8X#+9Izcj$J@F#sRn(Xk|7t@9Qh8(uhG%e0 zS)}{Od3f#Sx|3OsX`4p}=^hNV!mq(MQrZsXN{^3Z;?QT!@*=E2S!YJFwN6vmJJx~8 zq9p1=TyKoi(i?`Y)Nbl?%(lA z$@uPU3qZddp1zP}^g!MK4;cXP%m^)FG-zHH`;FQGern`?c4J2gI_wYnmLyhEpGj`{R z*X~<_W<)k{Ymn<{sIqjiH8f%s&;V~O zkqKJ<(0?2_de69SVkI`loL7)P)Sq&#u*q!JeE-W!cF(pJNfIbt(MG#MzfE}nQBpzI zD`tfoZEg$W{~-VW-sS6e8+hwDPSIEkPS1#8FB@#iy3wx=apE`U4L-^-zOX`;riZ|> zg0nw|OYhtvY-p2|nk{(?pIhqxrGs`(i&v4h_4EVZBW%oCk8eNUGhtu0AC@9K0m6?HHy2CKX1tWS$4G8)^{d^)Qj;TXM8vq!K}@Ipp;){$e;?5#5HIA6gd})M@Rp zWowS2e($QKgK$jrKR5*!ojNG;y3 zrq0JhR2pKKpN`G12hn10yS?ep8O^xt;S+uz5@Y62n#HMP-RYlcd5dNF?+tq~K5&-Y*^NXeCrxBu#%%U6$hA}^b&U-Rp_sL>X*oWFY4 z1VX;)4psU`z#@jnwZ5S`C0@$c*Q&KR6lUq(yI*JKKm8}_eQ4D#QEBu*$M0hAkOZSOEDZG;|sYBX!*hI2Ui^-(;y2dR9CSaUU)THMkh}vOdO?k^3_`;T`I9?2% z{2@Zve!h@fZEu)2?(q$H-9h7qEa|*qPZ_6dXcuQ8{Z3blcL5i9)=Tpmak8O3dAgnT zfyUsh!6gL+r^7F2`7#?Pw6CDcD7Cxn!Dt!hbClj9*MaAt=e5mZVeI-CRUuGi6Eva`>c>3?XYxU|B=M?v893e9xH4zkK|tF{~PEya4Dpb5_!Lj z3@XKhno9LlNQOX zbB;QRUMDzsbc&uIwpH}hI)_nY`0?&#+!FMPgL(wPwTQQ^^bCiir3T*ki~;+fWSsQI zQDeh-alhxC%c>h3>c*M`WbkCQ(a-KAxZ+ys0^F{z2mN(gkE+u(L4@0M;juoWUAa+@ zEcw6)kLc&5KI`S;MGOD1rlJP3KP|OaC!3*Ho7dBd&D;7}YXnoN*$aYQtC2MOx5}DS zxFT!3XU+#mbWv&Gx}{`3}u1TcojrBYjeo~S#d@B@LoOFBc0as;AR`(xz-Ruv3Hqpn=6aLEB z!lom8UiTc?02{ks#|mZ$i|;it$c9c|y-CUyxP9DlyIZA$)0Il2>%w`fhA#mR(Z+7Y za$mew{58Tn8v9bzPz8mxWz?^anE-Ul#&OMUkuQfQT_%YfK zJlv88vf)~i(#Qifri;>U;des71wvNqmp=|v!Sg9ldWqy&s7=7AvM)!c1CP0ITZ~C9 z{h+>m?d+zj#J9-%fvUH>39C-Z^nL4fOa1xyuZONI21b)>I*D(Q2lAQw(L{|)l9#DB zytb-nK!ZjA+ExbGNir=M#uW5;CAY?Zqvgc6H6TZV05Xm(HfNTL!Pqs>HlNUw=X6Us zAo4)7m|?k^$%}`wO*bVOQ`$cYPSyd~_;2<6Bl8U{*nyXQq0G@c25NO+cgtH8W|Yn!zP-H>{{${2@1oIF4o&4_0(;+h1Dl2QB7x7W z?7)>6AltZeb*O6+9<(8W4L?cgIJQ5#Q%&gC^<4BSQ-kX_qrb0v+ddU#(YXd6z}GHH zxKxj6`ij83S6gBJ9N&mtqM&_elAY$oq8|YTy|y_dY9JM6H_&Rg?~GL8xe-brPY+S{ z`1ygjhqBo5cI9Xlbb2B^iMY1W#Ni@g{5)4g6HJD5oAUNJLC1-Z)xEpiwledEr-1Bv&$YXZ4{sxD5H=O|CY!UrmRs12uN7E zQ7X^VL(y5N6m1%-+$4wjY{4Km!d3W+t`fxDuSp4C>T&KH`mpC9H%=I(83ImGD{I=o zz6eLp|Hrk&A2JI$TYBRc79e#P4ve$=+*SXt@0nhpg9uVm!y%ybvZ~zsC_Mp{mw7zH z4@+xty(%M4eXXF(I?(00a&`?8XcO)09(V%*xN#)Phb>Mjn|sa5Sri=3Cd>JyG;M_F zr{@(qyZV=k?5HVk>drI&>5RxM_h!6MXW&gAgUi6SUwDz)Kr(%&PvC)jH3m%2-5be| zsJaX&SGzN0=GSwH*0wZ5H~L+De`k*XE{sR{+BU-a7JcLahj*WuWj{0R?Dnu=h`I%s z8>>x!P^vDXC^Nj`!?kp34rjbjdl&{i8ycl4b^MDH5ql@OXW#aN4JG(f0QQV87~`<5 zG`1*~GOz6QQMeh2Bf4g7e$8=>SbWA9H%}YhwIaH&Ij8)(JDE*X{9+M09X5!3b|}H)=Zg>(TSj9U$8IE z^3NH$9<)d?!JYTDd!8rFkY;RD12I*XK>Srs{uM{lD`sx?6a@@gPW)X~K1@4iG_+`1 zGSFG^ojFdMAaRxrh1?Ge5;VYBNvaOVmwPZy6qG_8&#(SI3?$GUb&Q#-RvgW?`Fwe3U@S-(IFqJ*LhlF#!Zq z==cHI&S^=>$oZ1W&IT?a&?PWT4!TLX>9k)=qrsfoDZGem4D(a8<36qAk^fn$qKV=P zjQv{%Lv>}S{TbpOK(WeqGVbLvH6BA9gZh1J&XswLKJq@_vtqk~4nKCfVUTxz zT4Q{A9iMwB1rOaEO7Du41;&|qWMAH%CvJV8CoZ+~=5Eq-1EqmVuJ=a{7|$h+^198+ zLi&}cF_e$%Qfzq6hyE5A&xpvkdbD-lp?o_3DkR8akEp?d|OdUP72 zVob4_$p8*is!$4`{!c9#25wRs^-eXkiBT9iv-$klC}4e`CW!Lc6G|^i*F_ww%KNuA zJ;0H#*vHI*e>WxW51yrsiIN54c$&>%Ay&?P@Oh%pFYmI>N`X7Pl0VLg66Kk1SHiD~ zy)>_@|Y~mnZ+NOTDo4&|1@@5$MMx<8C>GG`{Al9Uh+GZ+8q+Df?+<{E=XxEUB_n zPXjVwjTZW4zMW|BS+%Nk?}alTfW!g8#4pybHEan8BJfbH@}i1HER=m*x=#17$Q2K8 z)vU472xd6yN^#@E&O%&srL0L8ucikd((HD2D&_f^?O5eCN$6eQx zoxJ6PHF8gubHpe0`l%hJEs*?%n#8sXl|a{g}v<-Qv)U zDy7fnap-_b)}~d7?s*-1vdeCdpGN(4*y_Iu*M`-)+gp_V3JCrFrMV_JQ|~{-Bj^;0 z^XR2f7JLz{;q*xk<<^$uP{xt1A&&u^S#C6aVY+x+I@~iir7p%ZqD1NualqC7*@gZO zUkdfWe^*T`T3t25PB5X)5rx|2ePL=#A+D_tbPX?|hV2iZ0S@EbpAWtF!qPp2K@LpUF42&^$4)xKiwN|ez2tj8~wx~>{`4Ee1gi8baCEV4Ig zH5|rimdOwHh-4~wtQv;}Nli@=0{=D<<$A02Ny$VBd1zbUGq-rW=T9N)&IBiwlf95G z+2}sZqr;D~j+ttfR@5$5X1Kv?Ch4*QP1F-wj-`uoje$yEYyErVz5D9PKI89{^s%C{C%qLSViS0@8w*Owe0n+O@j&E;jB2Kr~%D1-p zBOevSaBi1vm38!co(!Lc`F9{qVL*s3>!{Mrxb*VPvPXYatmf}IHTSBJ&QU`nnIGGt z#fe`;1F;&=P_fZIh*;CKu{eAye2)wr=DqO4BA}$Ma03U&kQ$22941;N$4`5l#v5nZ zq}c{U#*qv}^Ax+K(9y5`y3E(hbdd{#iJhmwxxX?tl*+dt6G!?31?7Ux zt`jWFBF&uUsqV0ayjmd|3q&b|o5_6S9?-7&{%#wu#aX3KvB{gw>Wlz&Sx)b<)2%>Y zA+x6Ji_Tr9w6>3wj;l+jKkj)U{(UFbuNFva`Tv%Yqpf`RZCZM$gmX77AGxY&(01@A zY8}^>0ZI2^k7F{iJYLgmHmS@4RvBR8w=$>dEt8K7lgoO=By_#^yg9mO32FhC)nC@ zRVe6x^6>et&A#9{&CKh+P-LXO3VXQp2cN1Uyvh>KRP+4H<{p(3`%k_Hled^}CHxFG zPwA3DTBl+;YeW7HbE$3#Na(O3$5rxTOd0nRtl9?78ME1C$#>S(C`~Vz&D7ry5B^7N zprwf|%SwM9d1*x(3d?NSuuuloQ__S z22QrpwQ;%_y_votf~uU$GOuGTWjC}tot7~Z`vAB)5^C67%Z64|8TQUm5+iM}Snt#I z+7FNHsO(QXS>`iD=HsBG4m1O}M*K9@#1|I7VpM4b3-b`ZHT1=x^w*dTx(VP)sq=pW zRbG!j^F3Y>dCYuiF=f7euxp96$CMGC)g$$-wZjioJA7DS_W%ti^3cgX=tYu23-<30 z-?cyI@fT(V<$N5AzU!@Li&F2LEeIc8B?3dd4PY1(fs2_Q(1|WjN~^|F{hPZoGrzdB#C~FDuK0l!!qZHePhx9QTtU?#P<_r$zGmWaToxxN9*!?cb-H+41Dv zfwk>PomJyKm!l=IX$@ZJLHxS`tHp!23Jx5%9$vzILoiN}>$Vf4IXi;%RO)q*v-$%= zch-kCEqATLUe4(+3dO%Gt1U;Jh-Y5;6sbP7yRHpzlDqV@cmH02gl1k=vV^mh ze#^Pix8;RGjwBWRh$@^&DG$YT43*`W{0{=;w~=h~@Q6Km!`sZ3%%1Mr0O_jQbr^l0 z_AX?kRU`Qbn{xC#VIA*stYbg_wq#wLV~H4V)^d;tZN0w4U+Y!k!vZFw-U2J13;V@2G$`U(({eOkPBTxRB$W+vQk=tR0-pgQ``Sil=07lX0V8y2`j`C8FWBCw0&)W572^A^EZV)hPUPNZt&dQ$KE7R0 z(Let@y9EaOzx^5pOimngIQ^(;y2Zn=+5bM^rN4uZ!lU+ImH>+|1<3HCovSp)lYD$= zd#FNC-Ho*ytpNP-kDu5Tq$hhfZura^bnQ0US?_%VjCinr+hM+rs^ju(g0_qO8FB%j zd>moLGoktneFY-wgdXYOFEjCHnM_5Hdp&C#c<~v``GZ96PYcOKQEQZa?5If&#D8SI zG(GM4r2q3MljPy<^IzT1YQ4~XwLZTP3qMKC4S`(CN|(~2q8Diu+YK7h{E=3rJuj%W zX>OWR+@8JxfhH_Um{Hl}n(12yk$|*;cR6B8c9l3c+f71#ffszz!1Z?p6&E)1$Y5oh z$Dw57gqzX{14min_z<3nBa}laHjCUptp1bZIF|@ZKVA*+^NP_MdI%jf?zbzPSv2wB z@KGii2`L9-6)LH~(@Y@t7KX-8>Hq$DHP{s-Tjn+*)@E_3waneWgu;SNUvwqaxUKC#5!|9s$tAujQ4jQPF+)w^8h7{n|8&zPrJC;-j7-?%8SU;pFO5Z^sOi&_iF} z5+m#2chUd39i~+23}=X;r|Q)iJy-V5$z8I*|kG{|0bVW@+tSgp(qA3^moJf z1Cu2mfNktZrBQnd(?5v4T-(gpU_M`Ia!=%lSj9{|Qq&naemcdkS-+s;30soa&g`N6 z!caKx*LD0;{fkTh?!mV#*u?lh-Kgg^mqH_o%JG4IFFVRcnT(Xq85~E&$d79Ai0i9Q zicQNJO{;wP(<&V)qan7Iq6A(*;cCZ*+-P_h&R;)+Tf6m(;Nu4uX+VGfXmJg+5X(w3 zM5HXfJI~lVC0c*4$u>z4FQcIHCTu ztjDOwU2FcLL)FlrA73RTUb{{T<*kx;7(#t0CCox09aXm%%GGyzg1j-nJIRrf71;jr z7j-qoE6e=c#rn>>Y*k)w??{Vo>~S~TkU#Bb`blGV8ClW+g_q0jcBPlPa-K*PAnBkJ z_gmyZEfv@Y$^(qyWqO5apg_q0YOI*qzm*nTN>5hJfzicsL1e`ELdarKI^!^euLd#L zr7tN>)0O%mgleO&N`2^J+^%NSJsmFR*d~y$ZS`-tBT($5nfz4*R?P`CjZU_RY@iS? zj_ompjy6594q}l%cXpbG&FTVzT5Y3|nxHcP$Eg`>XiC6LKnz;rom+DOY1L|XT#?+o zqpOGxITW%VQ=mwfT`{$bGDQxa*Z;;$q7fPxzHDlRO+Ww+au7@47yS5N{%KxszXkv0 zKMr^Gfo`yElGZC#N|If={ilfV+_!z_PDE>RFMv$kdNQQnY<*a1jYV(0d;8s#mOPxr7t?>~cmt>z~c_Ha5o&K;$F9@frq zx4)b+SR6qT$lXn6^%uoHX;?c`wQO1)?71Nf7a;vz$wfpb_TykDE+=V|(dG|VD-Qgq z@ha4Sd>vbWM?79pfO?6@w57zz+A%8%x7il;7)t zC1q_e=h?_lp5&$EGxn3I3KIW@2bwTLyqS2+D>i1R;bFaf*c99Bw;}_lU6ZMFTx6M3eYYVKm&PAe zo738Q*}&<2bI+bq6)d)AD~UF|q8KZ|SzxQb%@x`5sCO9eH`i1;AQX|9UcUCZIkgZ` zyjgPAFeLy6*BsDd`JE@-)TP`)py!DyA(K#%12h4KhITStEiu7a8HjL3ah)$$*(8;N zryNug11dE;?oP_$UIL4KHL~e)F`;1q^GJX5h}hrg_~8zI>h2+S?RcQKZJJ7rGQp}OV;m{$S{ufN&=yW*7qiAd7&<&zO;w{>Q4qYw?1D~hoalgi| z_~f>r3y}?o=hSub-8W@rB!f{sS;3pC=(E=5|Mi=2w$i_uRbY=8=ZB9tv7Y_HtVDdd zx)K>x>hI6PDrQKR{JJZMfT)hvAvn{DOV;eC!J|=+BGm~b9!Wzyc-($$Uw1m#h#l4F z@{(@G^lx+j64@Y1`4;W}QFI=TQ2&1%KRY`kk=>vuBUyJ=eJjZ<-4O0``#AAGX@{}>cU zJW2zX3LV3leW<6EuE0=oHnVgKBtY)(U3he0{_M$RMsqF8iv>1%MO+6Ut-c@x$2ni7meO-ad>5}?{9 z-2@JdJAFeh%{AaN<*{2uN3MsH&A_P-OrD^dYfhQ?_ zv!+`BUxyj1csA0#3qYrOnJzywLDQl#rQ2skA z@uqw)@^jdm?X*sQuM9#o4^o*N?LcBWr|7QM=}1j<)oa)IPY2KDY0&c2d-Kc5EwlMT zZ!porxo~^{;ia(a_XHdMuSJ)PWXi+?eSxQyR-qu}Zu)eu@aV|KBwv zO@a3PP8!B?jC$*TI&X@COoH)rwk4Dj%TUm#9b@&Y$^VtU_HM7$b(~;MFTU3l&b`Mu zdsoiN>Go#N4~Hq-d!cN?r`_A8hxF+k)rRUYzE+gj>vvLd9tnic)Vqk(+J?!h3&7UB z;O)fWXkexwDMI`X*YV>F^5|KP`iW?^)wQ|&?yqIoYvA=sSLmY!d8U5lwYYF$2)-7#ync#HiUen?sbF2WR#1J7znl<;{UIw|1!yzm>zE|ey(RxojW3MODxRF<;9V%$% zlzmMPspqc1Su*}r@5L)^xijfM5zRb#^BSxiii=$1PVZ<1|9Q4rTdtAP1d{)VoPv89$~H$i3_*?KuT(p;*}vgH#j zPxByjxgN>$cKs@9a_eaO7^jG9@o$rf8xo(1Yuj6DK4(se1<=O;yN> z5b=qXNvj#D1Do87cL>!Uah)<_M%-Z`-I9s$$({HDiLIfgd_TM(sWkXJk-QTyR+bsT zml9lJj6@mv z_~Izi<|y)FXG#5h>7toY5ZK|t)M{zfhplu|Xh}7k-6jh&KmO|zDz*H=@k-AX&pbou43f6 z&8Ivf(*Vx$2YRh!ZQJNXCoUWwcP0)9zC6DZ`H-mvIX_s>JqzLjULr9LY;o(LbBUwE z$-PzafZIVG=b>Q#f>r@JzlNJ4L8pkne}#`-HsRKLT?dXk#-o zUk#AfQaOmE_szgvYm2GdDxqr6O|L#i#>4cibnsvu2Cj+|U#lxBu@&cJ8^ZO-727_ru zh_EH|3W%Yp(l?16iB_@NQa~WHcpZx2RkfoYYiCFRdpmgu*u( z=R*of^FZ={9%h#bztsTKGwcKJN>Kc;rEC>@E{?c zr$&!@^ML`l1s2yrxZhM2$kxii*Jjk$>i@t{40d2Rb#x2%K9qDa?hptxs8ev9rTQ~E zuqEDVJxCbTQH!*{`79&=>36#5OvWx@T+GxdiPs=o++@qm@KlOt70}}|)3LHOIw}}i z9BQ&{T{l?Y-I{+yCrT1a9hXmEyIWBJEYmfFM&$8nRJL#hzN8}&l@!7=#T?;`Cn|E~<~@D@iry>h6R}r7#m{w5(?SM1 zMxc60IS_S3)8n-Ajos7(15e7W2F?!X{2^c>@=Na$@UHO)ljNrG%>FAVoQA;mLr@0J zM4J45|E|Q{bqy%gTZ?G#%Spaw39`^5YzIp#jiP!d>%5ZO_%gsN6gCnlkG1+yBKK>%cm!!>`O z;Fs{_cF`3uz+7!Z793vkC+j=HYb3eNBMFTpW=^(zPt0=m{;&IUMOz2P$p7I7nu*=1f%ATde-s znEqM!<mej>VDP)*x%5D^LU0ARR zt^ow#FvgP7BeD7c2=b5wi8UteF6t*mhe8s5zr-dgFpKRd&v|7$e<5B~nIZNRar1fO zQI6y}l0h4Z2B*=$9EZ^(+>1%PR}3J2OMt z&jOjHDw`4Z33`}$DK-0V|Il_%J+?1F9 zv8KX}YsYeLE6-z8X?@V^CN7-918Zi(#1PcDk3wdy18_l{&I@?GS$hFNdo6&ghIy7b zn27u>NySs^5b47Rg7guockpOkzVJ4 z%?*CFkBtwe_A=>eWyjQ&(z?sc5>#H5D}=dsBsDO~t+4sPS^Pe7S@RcQDJ4mB)7?xl z=&Vzw^qJlC0?0Jq5?vZ9no^I;&biuc@p0E{7)7_3tH1Q=DpsKV9Bv=%{zU^gQ`_y% z#A?7$!UbdU)D!VAi5rkSg0SD;gv|ZG)f>+?ZieZkHmx*D9_jt?8p4GE4_M;WvE9d3$E=D=e_#L<|dll>87d ziJpVCho=URpNXd=ZlI<}(r%;VgQsDV&2VZu6pP0~aLIr|qLz3W1GgN9T$H+>CG4^z zAt@O-5468(tY1&Zh%=F{yv1I-d{hyb4&yy+W3m1fVE1vD^0vK>)mlYnEANf+TCv@% z%BwPIRmc=h5!`nzs@X~Ho}{gCdpgJ#(w^(+qJrh$yleRQEqmOq^+7UVTkALN1Qrsh zmEh~pYsL-@IIX}6Tbm&SnafctewgB+!RXm4|5y5Za4eG^evJ26^-k<&p-a@jBpa6l>vEl7+U%LIn=H1uqI=(jA zV%wJ>eg0&>f6sPT!<#JYV+3;AKZ8c@&)vG`)BGPwLT^s9N_1KS?A5k?l#XF>CY`SXcHjR&MH z>dQ$~l<9XV9!+b;XYkK)gd(DCmR2dlNXY7{+GP}FF-E7mi z#O+-k)BR%7bDXKQ0iK~sewuy|+P_GPHMZOj@z-c>o)Yy7elvz}`rXg@AzSxh@dxvp zx<`fRJv9EmYd2oK2g^TW{n!1a7_*slOMyXy_%uNxhlSl%JoUFF^&e!np%C2!+Thw) z0(Uvd{!!PF{n)OBM_pNH#)(F|Tkei~0;aFC_=?pMnXO+>-8dynH=~mD?wh>IP(HPP zbC%UOx2-+`u)j?T3%TxSv?%EF`33Il!vvZP58mBHL0dy7kRX5AEQga?#coH)S}*v= z3&w#!J?~a`SAW~OTiQ$QFf(LqdToiq{D;Qkm7&`ch(CqwuJvi1lEs&29+*TP_m7@r z5wTI`0>I}H7t_QQ>du*o*iIly8Cf=b`00Ou zilf^qYrZ};8Xbzh4<&pgdT=rIrq57C<*l7O2w<5ZzgG6a2+4?9$S?-1So zPK}R)*U91R=QrSN$)^I=tH_fJ@(;=$*n+5K%} zzKU3Wm$NxRciYf+l0?s^TyZ}S!`MtO3p6r7QLQ(+#dUZ2Z+U*V9+jkO2oco~%(LvM zsc}V1wx;c&<2ErH(5%PyAKukx3uu|agoGHj7r~2*dXDFa&Q3ZmKd0#zM-}nm%BNF8g592-sXPDkq&LOPOaR!pSEDhK8Aj%#Y^@ZMl#)nY3Ber za~8S%5?oA`inAo%;#pJ|vX-`WRv0fg7a5Hobny(@X2EY zk)gQCiq*tv%~IjEI#ohh@hDhGXT-wBlYy-^mU*gSMg?K9s)D4a^u*JgJ1os(n85jY z#41Ciq6|?ZqcN;7P{t)rc1P=RjT+M@f78bKi?jI<`{Pnckuq-wPMd=Zn=e#Iu&rLI zBhq(B|IN{DIuTWYqxitHo0jIbgvAD6#dhSh8z;3z_|*wmZP1|K@`!UvQIEK&JVjjh zqfdHu84}MOtPN1Y-w6l8nN1^)HV)f#cO22OEP^Xrz;d2^hcup)Q|{Ux-of2S$gR8@ zCELGX&sP1}C%n=Tq=Pf9=Hy8@S|E}2UK#a}r|@pmr22tY*;C$H?r_~{*qu4RS{{Kdc)>hE@?(k` z0DmL0@Ap&QgXxNeyNw+vi>D=577_RR9(So0VQ3a>E^W;haPK+R%sDf-NxCKwEZpvE4r`)+zonD~=zPgQ#Fat9EKq|MlE)wP;@6H5P}3 zHYXoM36T5-0~u(AU|LjmvMQI_U_S&Zi2X zYy^aEneggUq2uyH17jYiAu$k>Ofx^u1FanEO~Aey#>4QEwuoA&=g8e${H3|W#>_#x z9Ui^%aPH*F>P3ecNH(7s8pO|}cQ%Ihbf3wOJ|Q?I2}#5ra)YeffH4i1wQ;pNNo4+( zt(E+;05TuhznM5*2o_O}Y-0NK@9Q&`gQ4)}EA9`(YY4w?BIcwaXVToqeeZT9M>36? zdgUYIeLfqw^9g*9e3mXE(+cX4J*I>n8-G;v*u>JBoVD+Lt027J_#jy)&ArN(&-hn|Y0~N~<_kM(-){`1RO+01T=KN}gEupXLHPU?RUrRn z;d=WIN?JDT@Fdq&8KGt6r$EuE3I9)yE|kC%F0(n5Tb02roIz9o0C|!rtRLIaa%-Jh z`c7N7$8f*mXnbCj_SQYp5EN_vU3oG`#)xawQ~TVvBRi;5^~plXc&_j=Z)4x<^6-nZ z$+t#{yISzG2j#JpBzAUZUEy!F|HX&r1GZ2Eq4u|dA8U(E*DtVqFhC>qc4u{<{Y3+# z>MJ~kw}uIj@kS9818kL{_UUutQGe9=WW1v&(E5C%FE7nJ_u*#w=DTk{KkYb#q^6%u zuKyeexPy3nuY8wx>$c7Z6=UIAv(UM-Um2+k)nIF}U8IaLb#~xOr#+^|h}6WWJ%bQ( zp$X?0h(`ZDc_V+pvaLQh_6x)B(TR%f1mI~e?e99Vh08p7$=r#7Fa6xJbxW4vj^4aukZ<=JzlXpM;z`xt;+0liWcI_GGm$#~M+)FjP1hfsC5_?-W@VOt0m#XLtkvR*<=S`9Y$4iBQ}F{YXh1hu6m;h0t-*HJV;C)W z&|R-Tusgn=nXCRj@yore(7pJzc|zK8mmI28yrhw8ox3=r^7~i<=Wj@mRm*hdhbX>U z_I=$?TO6m^w676#$+k~-h;|%|* z6@uVrqjoIH?*9Ujx{yX-+L75be71Up?6d{E+aF5#=I@gnIHDLnTw^W|D}{!P0PQZezZrYfAV}l`2$7N z?Xz#}xVi~=bs2-TgxEkH*?%IpM_q!h%!E`fy33)#etgdo{AUnmd1Evp_SzWB27%%7 zYmk>dpMIp4+fwbnR%3Re&8y&*ER4cfM1LL8512yi|;Fu^rxxdn)1P@9-A2d zt1tS#Cf}9`=2Sow-6@Krf;DFF!zd}nZEy0;a^gMuYKCi^6*p8?-;x#keJWh@+(JOEbqC} zlPlDv{N;Eoa;%{sJ-Ywv^Xr=edFKiAazNC>awi+-Q0X95hS@}yGn{YWdAE{03hB11i`id~)4Wb{t{d@4;;KTt zb^?V4IxfrGy|g(HvjsapY7HqUYWhC1slYmA=x$?8eW>{`1FupL9rXRSt?*ljO=0nm zyEAAB#GTruqW!+|I;I2noB0SVSqLzEPE~nbbj1m<%dwE~D7NW4nQ9f3tdV+n30uNA zYFw|FS??TYb@!7ak1-Y@8S8<@ ziH257nJ~DC;D?4q6#B(%?qFG1at2IKr(Rn8kMfMP?68$grFJ>K=V~$^B*!+3Z&JpL zW2u9JKOWso3_->A0|HSUlPkPz+f_nT?eLyPc9kuv1N70 zNS1_zfNZ7wl~A8K!#{1;jBN8K*en#$taoZLB!WK2nt{&OE;(q1n%1q``Zl|t!0B{L zH)@;YVenSHjCZ*{tHg~57V=6VgTj2`Wq)hkH>B$$5QS}t9^ZC}=KUprfH*UNK<_g_@ zz2PA3&Fp&-4f0(2veXIO_L4`1E)6s;^obE=bq6+7x-@~ZTh}!vy0y7(?(42j<0@P* zkI+7~GeeWFmqa3;Lq%0U!YS`=Pwi@E9f>T|$~;baxB#rcnd>Wu5eD8ts>q(W?wdC| z{@QnSt#9x~m||b_m#>_rhA2~ejTxhM{|+lJdd=3&=FguI;1`F*)(1s#Y0Q828TBVw za)v^n0`cIz;LIjzmZrB|3*T1N=0kn%(B?hs5`88n$o#A(d<>@gDI#8hi&>XuqtOa^ z3E%LbBOCow{n(?6^x=pf&2C_k63HKg&uQ&s<~rJL{9E#DI?OY;usTi45CKu+dt zM@jxljl*hU+xv!BiP>mMeM2+T-+S>4tyRuQe-CI=b{MJr4{sMSaQ z+WTOgnc6OGPnsk3rX{Hyb1tg*{*#CZVSF!cbN+rB{(b5u2}8vDUkoGWC%pvm{^^Q6 zznDRjFctLnUHSC5-d&?$;^L&f6P6!-^ePv{p=R#AWZ64MGci280Z&i;kfNk&F`nR- z(tnaS?>}*mMxeg47BH9jv>oshMKj^0uW4)ng{Ko=mOMPTRdsUs@CwA$zCnBeqP=4e z09G8T_Pc{(#To8zsu-$mxjQ`W8O&2=lDi63@pv?lMkfV{V|EeL8K3}aA##4JQ;i<6 z8joxzl2Q2HSg?)z3r}~9#o-Dp_USdE6UqM!@u-{r>to8b5b-G}(kF5Ll`BJag7p_Q zORNK-5l)<&Z~4D?X~!W>^-xFc)>(6+MWn~m4~6a-!QeoMhX2Bc+p*t7UM3@!7}M|YEPx0X%KE_V?=H>d2C-R* z^A`Vz+H?CC;Tj0)IbCeW6!=-nYsJwTEg8E{qo&5@22v-s6BXP+*@A!!T!(;zVn5n7 z6~j6q5E`q|MO`qd#2z0MopoXowrdob?+y?q?9{gtB|uIByUM1A_wrO1!zVhcCqq7l zjNqG6asxa3UZiLTm9I07;OcI6pCvBB^f*%JxkAt&OmsBlRAdgJ=-Q;w6#JRmE42f+ zyW@D8%!0l5z;hCC(RD0%-NO#liZ-$U9R@p^H*{MCi<^QG4f9*Ugd%Y{X{Qi>*?%(8 zfPcz8p(@4%#U7^ zD$G-Ih#S)V!FrYW>~0;p<8c+S&>p464G>4-x7ln(#uMYKj9PS))Nmn8f~rJ}*2%V}UCv}EB1W7nRQyiYz=Q#NPme{j3K$HWO1`^LZ#d0r&C`!s zD^7A(WxD?;P-8ouu$%OXu(@JVf8QaWh+TDzF#k0kG=0rxM)kgJOEGN;ID`S4;0R_p zx&5TBP?6fFH?Qt^hH9tIi_lpIP7B)J-}QvVKu1H`0b9l|buoi0dRdWM?z5k9w!R+{ zBCl)MjB&O0U}+cCYWoz=2*8Bgq;z~dZ2qnclr2|aoTYcpvH=YK=bd_%T zMpA2^g`Jw4tch4QX1%ylTm1Sz1?FGap)WSO;Y+5SE%~pT9XJe=GJPdlW}C;!wx1^5 z##W*PnvtXmmnO)vTTJt&6)|A?|4(T+U}CVO0~r{DJQRSiWV24a*vSJRg7eAu~v^$W9xTl%J{;lf>rU#8;Y z2X3u)L%Iut0yJCP+MC02J;CDf!@EC2#61S4j4#&wP)odCYMOPT;kp&4RX>`Ee^Sr* zUaywk%?M`|!2s~rs;nJlH|Vg};IZF8u9Oh%9A%9Ye^t)}-|hdu3P*RFxIgO&8suB9 z=eE)MZ8VHI3g)nh)GdN+1%77Cx#Y;w+$Eo=Pz9l`DL4j5I8m_XlBoi+^|g-)++Pjt zz0CD*^1}N_@yWcR$`jR%41kU0XVUGGS^9-L7%vZo?8(Mv%8QQG8bs2x-fsPy0j}z( zO_2XzW~dVDtX_G>2WF5`!C;bmyjq}~!~UJQ#`*^JVWW9B#?FdhO$hy=VG{*eD-x!} zv%5jR{h42juv4rx`8*YUkouojTLIVX>w~L>GX25rw$pr?f^+u+r54jsMAW=^=wha_X{4W*m685eg1zdWCwnDr}e?lU_t+};(_{uw7pXm z9mS)MjtsdpGm!GRP`eGGSuTqh|4ihXbH{4q$rt|6;y0M3dM;YQ@SuY)$GvXbkj9_K zRanvChR2n&QSTO~iLx0{lpN;zRUf<(?G&TTx?;TIj?Qz{*oGoQ9Xqa2&!M#YXcX+t zd2>yBWW);`c~?q)~`TTg$wTFvAX+7}^!Dja^o*n>x4^-;C@r zEjm%y*wt3{$pFER8%;ZF!OD&G4zc>f`>qWlK1??iXwDa<&lf{7vmEB4Sfe1b)m!oF$eos%*NwznyKod9r`tIQN8XXdY?7{L&cvKlMp?t z7D~`y33ExTH2zdT{!Z#Yo-4u;gpObuPYvjV7RQ8@VXOO_cC&0=Zni9!@fGC{n?52_ zA@EfYLr5?*+M=p6n^NCEM zdNIK5HO+s5&&Y57Y-T`-bs;Bp>Zipmlw$G?dR&@SZysqt-tRyzKznU$*i_@3$6)@e z^2&EOXI4Jfm;U%ibF+QGrNl~$r)_MunQwZMHE&JcUSfCqold)6Ofl6qAGZQ(&u$RR zmu~XZB`iy5hsQKNd8O}IzOk&WzgYTL?sGx0!e*JV+fD=(`f=d(sG%x>l3NP|>{^1< zo2Z=ISRA_E(3sBmk zhbO_5dcz1@q)BN5yzRNBwwVgWv7j9$WJ4QBFPvCFZfRz)i02Xh`2*!l2&TW+W;4(D z=40=ixZ4tNrj{EV`Px+m8I!6q+w_}Fp6{#=79zu7CyY9uGiB(fDW< zz5*OJ4KLxU?bx~@%%c;Mr5AA(dNR5W5SBo*eg1S}?5pbAocw1y8TY}5Z1I!C{?7OV>*R7M;H+mMkB-A=U#Iq8#T zK2ObTRT`qd>+3?{#67p3T|5H-(V$}g=f7q|+Yhjw4?u%qIAyPx~N!$}XtzR;~dD zc7Xj}oZ!H>+sm(1XXzJ^?NORdFpk*3^F~Y^wbI= zD4&-71Y5a8l$akPV@`oy+r)@K!5(MgMPWT36;_#){SL)ws(0~L&WG1`Tp zT+{mS%S)ARsnyP4nq|^VXN4gf3kW|QO1{crrt`nN-O zk63Pt;ZVCxklY!RugtprQ0h-z`$>IH(;DO^ucDqnK#DLXk?#lC%T44UuG8O@E8pq+%}YR%o_#UJ^oba|_K*t%E63z)V$M)x|#`Y^Y% zDlm2%`-?yH0gzJa_wnoo>b_CKMpQ2=iH$h<67%{kf?C5T1BkdJv=BOK0B2IPH2!9# z_}QALVqN_H(gf88i_t73Ozq>0ad%jkc*}96YTsR4WtIG~{ zoMHBTxSn5TH*feLIP1-L7W+|vhGb@j(yt=m7zD5@Fjh=Ro2>)2omA=jo45nFAhpGou zdmLXPo{`1EHtUD@H4>bLZu%mP)>y|LJk-n>^9>rhe8E3z7lp$M1rr58xX%|jBXUv}Z!js<>Y)>uTg>dn}4OIeMs!o)Doe)ubQVT#qP~PXX zL_F(n9GDcrQKoQE{EqA2NLRigLpJl+_4t#0{fmA5kkkJ&Nvn3xwybEc=*JVrT=pR< zz*WSawsC2STs9G$DF|l7m%u zm2-6{iQ&#vir&RgZnLDgefG1l#7S#32k!7pVQ#!tiZ5?@8UU)j<0}Kzv&5~ly~$p z@bC51_|M;DyX~gvu+AQzXENGv5(NJMtz!#{y#?^O(kErMb6q9Ul4Jcz)}-0Nu$WYv zmy`AtjasF_&Oy+a9%R|nar8YR_mUO7;R)5`!B_b1oj8=L-5{Ifw;kZMYq_Qxg;+GRnI}mnp6szC8-&uhJQYs z=n|`(?-T3u63}dk6K5F@5bIxS3EgKlXsM9DTffCY_XU(*InyoZJd({ z0$i9j>U_QA+!UH@yuguC97W@`32>)e8no4Q$~;Hy7)E3j7-tM!hX^}k2ceQ8vF1VJ z`s-40ZB3(-UG-^JcsV#V?A$VU(R0psaoWFJJXBxw8+VZ$iBRpCC@*cb@!(}jmf20; zZEU)Sr+y1=tyIUxDmJRDcG@%sxBdO+m^Z(zkeII!C?;vxujJ6LtG){AK@O1H&PYS2 z`p>?s2@r%V5tDDi`Fb#>3-9aMO4QF-N7*8B21DyFSZ5;W8t(DQ&1T9H*2UuPJxjBn z#0wC(KJ!SC>5m1c6h0G3)N|MSdKhOa)K5FqgE}^CE+}+d_GT;8A({q0EUknM_w8D~1zvvLcBd$3?5gR>G%icYQ*trI zL?G=7`JeZ;%MHIcDe}Id9{)XAh{;oRr|$>K{ECNs@x!85G2OBN;josF$;8WU_TUWF zoYJS7fuvX)!3e#OhwH{OgQ`n^q80T+gPE79h?}IWi&LA6f#K7Ep8w-^)NZW?X%}}; zriD9r8WjRI>m z!RXkl{cdgbH$U$y=jdx9+kMqb2Ws@BlObe2e?s zHL?na_m!Um2J2mEd)QKyj!Kss;;d#Mtr=PWJV$@eD-YiS?FTD)d%)M#BKGP1@JivH zIeVAY4)B$>s*pYLkiC|W)1Hvi=&6`Tccbo+No6$aj-_dVOb9WHAwQugC#}Y2uGlV! z!>DPv*MpHyV=lA*T|TXcDc{9O3Db zk4}EiAGvk8?#BA23x-)r!1UAdz(4~1AL6$t{UJz7uey_aI8<-m#a>0MHlyY8dGua5 z9fGKr)qgqaDbYXXX7X(SD7(3}$5AGw=m$_8@9Gf8Ek*ZJRSU~*byjqsxca3O&UU(7 z%hhBFpPs#Q^-YWN8txLXoo6Be0!+zzFLa&%CAR^k4p>9=?Hc6=p;CT-lVpSz;mmb@ zXx!}I8jKF%KxcNKSk||TigeFaoJ`3W{?@yq2JHr+gnkOLwDjCfNSc4CTnAV{rjluw z#}}ZD6)a-HWnpy*Zy&NdQ>f) zkyYq>SgT6+rk*c=ESRg~P}{@7EzXRvj8_e3QVUps)lDyBsqXDPJ+^)O6|VS>TJd=O zXXHRK;p_~3xp!k%Kb&$HPIrSeYB{#80h%7y8+rf}dr5=U_$FGszt!A5`DAOWpu-q_ zd^6n({7w+W-N$(!)m<3}>mEFP^T&(?_^bO(ggf2;uh~V;7F&W4P)j@s6uGn`G^q39 zRLaYLxW?eYW437ntHFw>c&^ z543H%FDxMcVa>9!)8g0PC(u{;und+HU1u^TXbo}PZ-b#ONl5u->e~up30&eFKHccw zY{h^>1Os8r?a|N7w@^&2;J|A#id=EeF52aVw4WW5c%{JmStXCMq>*b7y&EhDhCNU> z?guPA;aEH7q*emaGXfnV06(I0KdBb3y2>0-uctM538JU6WSX%SEASONtC=pFPulaG z4g8Ac1Kkc0flYV_+w_X5TE+T6KSrK$yaM?tpqqob9USVfaWu#qQm3b-*5 zhqPP5lrN$m~z=JaPSU8$c`6dm!l5w46VKU^0Z+JDp_wK*-olc)Z8{p za-MEi5VEvFe!Sd0$P7uX3%BW(zaAH`SDrob0u3 z{8EKLYtVC33g?{25$7<-CRUH>qG3^14;Y*tx@){`1h4yD!v{sTFof|97o@Ind-ia) z+Pto`3Z>ZU4g22r@m%W*yW>~(cfqo?NxnNGV}1iYn0j@RxQ_<(A`r8+A8kawJ+F;`k3}x9LM)GUJhOUn>i; zKMjSu;3wgcr*D!X01p*?6Saa2`%I(It zOD_D6r1Nk~f^Gjk?#$7!9J$(NnlmS^?(VX3P?@Q>;!Lenj8Jgl$jr=DX^s@Ma^#-5 za4%93au1e?iW5{++`c@&_dnn`fa|)>^E*C_#libL>E~)cL7OuQW1mnEd9Td?_<)}ssn$JBc96ts zE-Gg`2od-WTa>qDozc)dzo8C3S}rw+d^~r&SU$b1<5zNmxA;A=aIdy+01tmi1nKt2 zpw8#8(!fLY*qfTeh%vrbfu8P169-4DUz^`8vNOz(cLE~%**CK?sE;9%))_Fzi6+z< zS0;V`Sr0aIUU1oKd7l!zc)$!h`u}0Z>WSBi^}gLhob(a-?NvR#jI6GnkNNNJ_k$M} zEu}K*?U|`4=)Ba`xqjwLfk?)a@*spx{Q%z|yD?suimuGEKk}0w)jRX-)kKv(md4!~ zMwEgzzd+xx%>8A&W)|wv-~V)Gm*L5>L%Z@Hn}tVZ{}ZxQ-})WI9h7k2#4@<`o}E5P zmuoNTQ43%bQ$LrX#SDE?iFXg}vF=m`jwXBqWTg}`6^})5jrV2dwIOr#KL4|S+*vd@ zv_I|3wHyD8MQona?64FLHX83WL<`(>^=tfCk|RiTbPIju@uC|A``i5Nlzv8pjbZ0b ze}I7amq2By9mP?y30mS9y)RXy6>Y@hpP%L?2U~tzCh^~|lDH4Q!CON*l^aisYS&+G zrhic|GU5u^xPhsA1Tsmq`)vu_NR}=S{j5$^Zp6Esza1tt7x~H4psYv|QICbi|7J4o zd?eGkR(^Kb=iqU{+k)`xw&Zu#=;69Wmy{Ad& zp2l`AIsTDc7RxrGk@#?JlkrkU_33h`5giODq8B>RI_7>?aDvUrC)SsiY`L6M<76?j z@`pDRR^FC|wtmGl=^9D-|4Y023NLvy64Ecl6;#*G0xJD+oYfCL{y!u)lHq2&%457* zusY9`T*kXde^YVgovgTQl|F{9Ykf6t_u}T)CsVTP)kU~~4G$PVBA~q)e!Np%K|9MH z!M^SW3w_r4i{gx`v|~MUXjuPYgM>2a^)3VI(}PKur|TTD#Dnqw)o}dvlaj>xyi)z8 z^~=Ne+MXio(S-f^TgqH`-|(Q9;E_%KHr*qEp@g5;10bMhxvje{TgrAG_&UI*&_m<6 zAHDKtjF*)-A#F5ydcn)#FGfGt_q368>AE7X%STIt2i!Y+KhW--#Xx`Q9tjUF}zeX+TkrlDHfO59don=dxjiIM#A8eEKc-Gg$uNW!Mgo&`RvqC4z zsp2&qQD_#|YG6GGvM%24W+|}Dwcpti3s!)jc7&dW>!&QxY|Gy(X06xIw+CH4QP1!z zl-vlOYGA(4$c{Z=GSX1zdA_QYBvOW<78_rUc_NVhutc9}E;s^hyZ}Ro#e7hxhkLjmvDG)dyf`^g* z$-?~z!`b68t*3|l$?)zs09A>0+iXdlAi@P=M=0Y5bZs{`{ z`ZNY_&BXFJgEE8483ukbeGHjpwb=xx7DcXke!4il{G~ztWYLYO(*0$wnfp6eg;pIm z-A`Id#~woCZpz2E?^~*4LjHU{Cu-K}c%_?-=3V|RcJ83ajTTrhMRL_sy@gzM95R}4 zW=fUSl+i@Eu{}vaDiI-->+o*`n0wYOgX5Ljgg#QqF>?AH-`x~!e;q88#MR|60a!N+ zr%a&&#S4m>EwTXn$7}C&H*IhU&uYsLD|3}zsDUK&t8%gQ zljn_{^R&jsQe=77rLF~Enh_OVM+P3-&O|&YK!eR*e(GNA8p$P0)kRMeUA$o&jTH2+7oV7ESydfOCwZg z9P9Rnwr@~BbmTVd@n;XsN&09oLsX^C)3y3@k{+b6!{TFkO(vT77V16P$+B)__p-0J z#DqK7YrZH>^g0gb=pdf}6+vk0cc4Q4o$cI6d2 zK`I;!^zo62<(umbc&JuNidPz{cPV&UAaH_J^SIf%;v$4>g0xYzxk&meGNUe+Nt&wb zD0?=V9{bWWl^b8d@F2y2zf+;iIFaM5x<3lpppz~Aoh;&~jQw{JY}e>DeWN|ju0+3( z^+_PKV_boyQUOeji+`{wsW<(|B`T1R7BDxh0a7-|c9=#eg=5+bg)wL6*=?}i*hqgN zTvC@)LLex2=O(636BGv=z20ZD=N+q0wNMXBR(j%>YRJbO(h)v>Kn~Yw+kc~_ZjZj` zKO%gpK}83+9MHhY;Uq-SN;iih%4M((iGga1eyaI88g+V!dZVZ{e|U6Q59n|P)cp5v zw0!7o4H^Lc?~HS&%lL{m=%};Y)7ZbLgVvAyAT&XP&oHceu+xDUR zI}w8StSIV<`)i0wy^%>-fZwq~k&9+q7pL^G6cIbNXO(ONR^IoIOze{0zZ_+mSqI5> zyz7Lst4^{Lc$-8sFzZgU&*cij*>P0Ju)PXS%HJ>y`PA`N;*4+Pt+spR`Y_-q@AoSaT(hev~xQ`VNy!Zaf0jr`)qfOnw3j ze6&Oj*4Qmoh`ybQ*_;);D~rlCZ-F0IdWH6p^e;=r-1N3+-nck79a3@V>pGw=7Ut0p z*7q>{-IJShQLI9*zS;hX=&c5!AE8`}h{`K!=u+vra;Gd}k^A51w?|K72^m)xlO`|L zoX#PRkb%$38Lz{#Tt_8b&ld0}UI|BJ27P%^AR`YkRFbn)a+HhnNrM-;UF>a>4ClD! zdjPCbRy%*DryTBOevwt}L%$CRmBp9-<2<$3@xq1&c!3sYT9tWi z0Mmf=*(^IzYGcSVH%M~>Wsk&b(imWv()bWaIvagA4!Fj22!8p){6s6$f%S_>y*_H{ zy(d0I>GuE6JbyAxTz7?f^Oj*Tu!4c7tDL2rqt^9g+y`|9#12}wwC*1FFq8->INu_i zCt|cbnsDFl@N&lZF0LmuXCME)ht>G8`BC)IMApGXZS(Zmvh7jcf$)uIq$CTSSh-?d z_wd~_85!yD!KSV>iPamgd~Lnc=VT+{7#-O1u@JE6)ar9#`*U8lM|)~0?9{gI3d#!K z@4YdQc58nX@i@5L?&hiydCT*!4sjI$9#-e^l_lu9#U-}Cf+*ZZn;4KMU(f?KV{bXt z64vrL<{$PKTT;u_+YlZ2smpo{PeL-C)DE>q6#jzBRm=;Hy7YI2xB(*qgSoT_H~Jrt}g7ht4N!mn!iSxHQE^n+5R~ZU?a2lQ`EQHaAJdZ<=Y!6 zZ49=f4p?L8FeBT6Ug4`s7+LQ(*Vr!L0ne(6iqRf`CFU%!jbL~hb(=Si%nu3UT&S49o^JbR-SyL6B*jGofveP) zo37)S-3Pw{lvy42)d}!aY7-9m;{AxV;oHDcz?9-c32&^%RJ7u$SK3REFM9k8G==49gN1J#u5$-jfHXA!Kh%+r1As8CQ%$!y+ zS^reH|LOYC*7g&`%VDVyV!|y$VNLp%`~6NP4n7fUC}G_z;q{>y88R`^P-vq`IyD2GAJ`{DmmySuoOZF+UdN z`m-^kEs=e<$!fLp9_q`=(md)e2C(=Yw{%SthmX6Nua;jE6)>f#8>KR)CdiYag8D)3 z`fh)-R2Vd{dezXyW3WKrhhvv^dI~4hWr(9x1Not`+|>j%jb^f zbT@?}IR^*ZYbhZ-n?t%|M7@HPiL42FXA}h+@W$K%o)Fp1 zxTC8AED1I!AU%HgLwPcsb%ttdz)Q|T8k`rI_fpxklGK}A(v&Wyi@qEQ3@6HdfN{;A z%8%us=%gBXem}}`O1~~T76b_8R)kwVN@cfKcc(E)%m)f%*9T%P$}XmF%N8HFqWfD3 z%E<0^0cqpiu^0B-x_Fh=SSI7X~*_`VUpL zc0_=x=Y3zf)&_Lf47G8MYIc+LsL-fZ8%_D4EiSoJyjfJw9P{w*gm&MJiBa+9?DeUt8H|7ruaNPI z8-1sfz48Uajq+}*nXnu0Kf^!GRteWzOqlT13V2{y(IA=5y}kyhpZ*p}v{ZQp{o-kP z3^z-kcClW;H95KqwHFZYguafg)mLbTF?foBXJxtrYIrhzO~SfsF*v5rNnZrVxYoQV z*B1mh)R$2GaRsq6YE2PtoK#SDShdMZ)||>`7~$Ij`DVCCK=34Y>)5`9e99E~I~+I> zv|HfWQ_=IMqr2nJkZDMxRLnE-(&VsMwt`TW`5UXwQ$uo#EHV3)y0RzHbc6S!ZSD4H zD%NwU;_i0&O*gwY2esAB&Q6%q7En{^I{z3qEg0a?UF~~m5FNy0i0HVT^|TzUo8NnBe>V^Bg^#e&Hfo7KY69P zd`TI1jZKf=vuMazz%G78i@fS`RBhyR8$(cc>M9K`o1TJ4kOkNz?vfh=k6&e z$LKR#VZ@72ekRIR=KD|YE&XLLop-ia z)S57}>*^JD>^&0S&m#=LKVM)Ck^UGHwIjfkeI9P0vf#5H)Q~%)XB7eM#W18vYbyn! zC%n)Qroqj}A)-36X?{i5Je4uDv6a*En0QNajq{(0g5k^*Xmq+wug>nPPoVJTjGMk0 z6%C@rKxYFtlE_@3N_V}6iTSw z3*idx3P(M69;LH{mmKRr4zg7b!u@gTbHn}yZrXGo z_CD`CW_LTI^`n_N>&v7Q3r2xpVSuNs72$MrYO! z|8^OGK5T<=|D&9VY*GfM(}ks9%G#@V2P$<_o?0?kC?+(fuvoHCw=}=rn`LhI!OOqY zzC-74TZhfvtF5rCeP2CxbZh1puT#S>#Cgnanx}b(0eeTUd`{gP>^iJ!a!|CwO>OYC z4Z*vil5I=dIY6!&D>rlAe!0mcOP)bQXQ{WM=i z`^cF9+k*2DN@ncrka2fmV7rXqOrY(%&mr|mVC$@rX}v_d00)}YfE6yduf6JFnL3He zwdVR)5t?tK!lqa^?+W=zzpbi^_~z*;=_aarHBx0)lj^ZK3xP?w!r@t}Pq}Tjxu;8M za=!3rICn_oo<3sctrJxEJhgtN$TDHu3a|601e%}xgHlH>P|oLNC|CXyg;vl15LrPOM_>j#>osb9u(&F<4&L$p2Ku&WE9Y)Ph{pb8Sssyz^@7GfJXjHWx zYfQ2kyF04eB2A?m0k&OUVoJ*%9EzTX0xSK`KS&*0yBlQ(C9Y@6XgaI1HYGcdhNXQ_ z@wY$=#j)3pXu=0(rl#ahWf0Y1b9>@=c1H+^`&F_no?r`;d{s3&!5A!iMBfhClHwq= zqumqxAI}Byq>$zn|M?*Cbku6>Iigdd9a`_w4=g?0%5kAJ6&9=6OYfxD!1I6q2YF!g z{Lwop{)e?XIej-Cx#qiE`Zr0RL67w|J%-hD3Mx(Q&jYHUVETPYCVOzp4cmW zxU*Qqj9Z*u|5svWCc7`nnzOhqt8XPG!;o<+t0(Lpya%gvh;_#wD>r`sh@O_>4vMq` z{Yd~CT%Bs3r(d9jwi;lfhuz_V4Lg?N**xWAw-W;pLc>?;5D+*37*17Lv-=uYtFDl^ zx1QP(!uJr-h>5^B9e00>rvLSj2+Ds;WMCydOL>0?554{Ueu7<+6P-56Q9;Tj!ysl%HCl2}pkt)ayEW2^JW!6UHHyyPG8cQW@nKY}j$ zjW625F`d9}+4j^HF<6YhL^enp`BxkSCJbvk8b&crn~!N-{zqpHOwfjf`0Q5U#{KP5 zJyC%+ZS7?X#3~u`1J?Xsh?z~ibP8^tcA?R8W;R7Q_xu!5!s`G5P zLDQ3kNIiuoU!d>n6c4^a{}mW-laLjHM6q!pcLI}xEDj`U*=9jCW$AkOi#@Me=J$DO zr0Nj$!Rb=MKb!Wa7MVq|w=kd=NnYvHAOR|unjg*aNiWl?zVYQi> zb1ot?9Ck*<^UJ3) zp5x{mCvd9Ky5NtI)AoYhKJ)%i@J;!#d;^w$ow&4RFPXI&D&2hc$KM>09 z)yiaxP1P=fYrp3qa1;&q#63b*GaL?aaJy7#gHi5Rz4zbZ$Ey2+gct%Is0Q$of`=i! zn_d*ZyG%R8?b!5WvjTDD_A&`wGDw-|;JteAQ4JyVFXx&harmAw3ZaJ_ob3J1sjH(T zIW4INk$MSa6~;cwbasnl_g@%Y=HiAW2w3&=#nJU7S~CyLF)0^wn+3Q~>F8vd%K+c7 zjx>2AUqHskC_`tpsYkTpqq+Wh!?7m-zjoQ8&fviuiv~~v%cqb#4wh5MKvTsVd=kGE zY?)%YMn4y~9C-W|E5GcBL{MN9UqM+^pByKWM6$%87JO*9)^5B-?{Vuwm)d%`8(r5sPpS;Rrt-`DPK9` zD`q0vrc3bGuahb#><}?-C%{;w+~VVg7BiV0mnQ3QV{!k!`tsjIm^Xi~z5vpr=YEIH z6_f2C?&-nHl6HP&>91OxycS>&nO4Pm9^{I@{N%|qwwvB9eCLE};oZ)7sS~g8TFv2&V1P`lv z(W!hsd}B3bI?&0TQtRNGp{L*w%l>qmv#0*`KoDy^k@xu$4!`r~MzL48@7ccT0KcTf@X*uUNevG+n>5>Mun8exyJS0(zOU)*JdXA#P%sKT@wL z!#7k;n16mmU{3`a`vnsrNa0%E3_tGPH#>Z$-2C*ot==3n%KlOHLAJ~UTv>8!(U(gP zruMig{eCSJ&ASGGV;bqxr$!B>#1>I)?HQkR0Ek{)Tg2=K-}GH?P2b;Vsn`DyWqixr zo?fe#YruID0hFC-s~)sP_)Emg4UR@FU+eTHkf$EsFLxzaXd)d7G64}6sP8{{3d4wV zN1UFiXB-S>~ON6aSI34VcwH*GUhtX}W-g|b+oY?O7 zG*ROH=31Qes=hcHHnCzT#pXyVv zp%XGcAZs;Sy1OfQhRiJVutO4N`+_Sd5~khR1HZ0a# zL2|XDg_WiD%R4jLZBh8X2jP~&LEIZ8^XOY+d&bm`ba=_=^^CPR zQ%_W`q~96J*vvFu-^y!XU_>@M!RAHD3p;rr=MY)mg_J`y7=F&;QJQWL6H0a4MH_Ih z77fiym$-YtJ=`)^+%UOv;^wV-n3!d6OwV_O&L^<7@=a6X8^3}x@<$`vrEXqmDZepI zxo&R1_q}@5OYYn|XHxnMmiVTNEcJ3LUQzDj%njc7CAjOrAQD%nCo2~FWcyUE$S~G3 zhgxO7>6do2zm<49)TJ(mEqj%!;4iZhpD}vI^G?g&!|(L2FT%};OtE6m(tt=G$!+`{ zBi99+yhO3=ti|g7I=8wJ%^I2=u+66kq^iuv%h zN1y(C0~0Ul+Y9>;o#=b9|6hy&@%)_MM|&Mp#`VHq&6$dT~*+1hI;KuaQqdP zPc{ns-~DE{zJ;oy{nkpvruu<>oO>xkzE4XAiyub0RY?gGj&F6`ZMEd|#&CO3k)>U~ zHq*I=5l?^69l`~|?gG?aE(wZo;sYZXdd{nmVH^cfWL^uh$Sh5@R29|Hh z(vym1+DNB@&|@cbmUk35#?605r4}nWTZySr!zSV7j&-|==#yx2eSNClJCsOI6&Q5k zsR+&Rc;~EU-R`~v$Qr>;k^pBkWgtW^*jwx4Vi6f`1Dc=)onhF}AGsjGK?{}gEf+E^ zVB-liUo^(8Qs56{`uEO&ShoE{Q_m-(4_&ig6C0WHXQ`@- z42kSRd5_R<7Sn?KOZ(%734GgIA}+L$66wZ0?}F1=(53QuMF$r|K+K@JcpmE3WSS&` zsd5~LX_`@?enn`fXB~*=RrMKUlhsXYSH86SB^O*{%GwrpE1c5DtvwW6EFvtjl+^%? zr&(B$W@xIhyUc#hV*)g&9@W5UD|oJFWORoq$zoSYRj1AhO|ZuApBM|l6S=k$3P2`~ zD=Q)EvOI})+oI(ZwI#Pk%$#bsW-5lZ0~wh`#9OE{1;@o&Q~m3SE21D$(DE+G{Lppa zw~TbSto1ftL@qD7#$j5)e>dh92yojNX@!RG_C2RHH|LBB+m62u-t^Qen(I0${JgeL zN;KM?H&f~-UP?4rpJZ)1h6aNiJAyJ+ENgdmv&e%<)~YlE50Xe_|GBFs9|K6orb^b) z%Qw%HvVr6U5f9cm{+W%XuM8gL+Bc?kSf!d^?tP^;jKT5sbJ2uWH}L~qd3`s^$&=+Z zX~birB86S=Krl-eyeE1t0ET}N1+R2|f;mg{<#82CXmmtS`Lw$#U&^ zp|dlPu1z@_t(zKu;^*motS8;!uf&0M<%HzI*y9Bx{qFokY2ennrCKC|3jc{=HU73B zqW(VV{I2j&AQ@3$QDh0!;Y6VF5t*6;H<@91wy)S8hBRTLlPc~3b{=`(j$tkz*qKRA zD4o$b9**`7`YKp~e{FEDwPVxx^4~}Ng`F3wE!29=bsZm)Hcv*|9kAv4wl@m1&r>dC?- zxr8*k0G^!}3d!m`h)MA>o&cUddZXdwyWW_H1~5Zb2_*XwAKRsD)b6i^E#2Us9(FG< zXjw1|SLK(>qpEC(1RJXwOdK$V7b1Tl!ZJ)LJGA~%qO~X@xHXi$bNtMw&j|rLyc@~@ zFSG=;t;RRJF>1MCpV`waR^@q?q_2a+7-PV*_r*Nfo4iCz^3^_82fFwI+5X0sc(zOm z_&}knG=G&;x&A60Rx%{fuUJAnw)&&`7-}3=&T4^;TX^9gh+`19TW@yUPciJG|o61ihx;51_z7Tvo7EnA! zZQgIZuyxX%8h7K^;MQ8Y9#xmx1hjeM9|_q_6A1Y79xOD9Y`RcNf~*wA9*laR%}QUW zm5H=e40Bc{zoav5jyTv!_J$r~e+?A2I|OQCH!%;|6_?M??cbaFxL1R4$;wh7hTqNj zFcu&nSf1Dp6`!egmdVJew18}!d|#FNc9pAVWV+0EmNFdysPFG>b6W4W<5ntcS< z%^IS=b0%Hge`KydVOBbD_jQBT?`)#Ty4PrN@-5w!$B6vm7AY?;2XfL$uSIuzyR4t` zB}Zcas(R*W+{+rKM3>7PqEt&)I0sDDxt~@EYwrAXj`-t^_t#uqz)u?;R<+Ywn^m!x z@ve7pTWq!JYeS8v9st!8aCV3X`4P@sUDVtyV@Iy9nRGVS$32j9F}WFZa?OMjG2+ZW#aDUBb|1?j&^{~=j%D?;evS>M|2_${y9VX;W zU~}Txg@r8F!U<%m(m`DZRdCKOCiDSvxE@D*_>Ev|;IJgtnExCcpBwAE!8N^bH7j`O z;)wv&*#L(*O`TwFcF&s^wI|8_$V?%iYM#jG&R&P5g0l{hQormey5*wDZU2EUD(Z7= za7U+Ro>6Wo&vI}ZlQRLK5|JMeeAQt`?69NT)b$(G^+lHN`iMtK(t`M_+U!3yZyLi( z+Sr|S!K8~1$GCrc{MCC(m%a11JHRiFwIkE7?!~`XwR5f^ROzjP&L+^VP&0;4Ug{OH zrpKMUBAVt~pCXah}-#JvcX{Ue_>A0 zA&GE6O5*yiygDAq)H~6j^+225^7^|CQt)}%#xitLD~@(0<-T&zJKpp=Wozzb85%wb zPd}OKPiF&f>C<1df7^PbtdDluNhl2ZuX2$h44TW-BYPkv=29J_?l?Rl{@e4Z2-CJx zjn5&Mz%)f=E0yk-Yb304xAgcTvP(EEg~O^^3IWUWwg z;BY~(a(`S%{>(jl;b8d=$2@YK_|FsHVKZ=t_l`4}DqF}`QKl=eyCQM__b-``w zR25Gh$QADKQiK%aLkO$~d{1;(fyb0VP4rhR)zQ_y<(G#!qMQmVt+mp}ow3C`QHR~r z`}&}h|Cq8lYO>^1roF}CbZ$^Q(4Y84QZ+xsjOCBZDZ?}yi!9169WJ#TE}dvv;ZTfL zc4~KfVN^4Koqrhz+~yv>m#~lrv4Qy>t6o0u))Ba0FPvQkx$DT@X>E4Xk1m6y+9LdS zss_kDGrF@TnE^u`D`*z>2Y>KKQ1s zu~qjg)pd`B$RdvyT#xCyhw7WNTnN4 zHArL8V)B>a!sSPV&1=pmtuDr*-+x8a^H+z9haXpXnHB9$I!JY2o2=6pPz9@3+q)U9X+|ap_ zXPq*AXsp!Fj7M1Bb9dELXuAefjV&a0fAfd&?hn-+3iE&xEvIMC#eT4N^;XfTx>$dm zfEMwoM*O(j#g@qn@W6>I^97h@%Xwropad3u-xyw4x=NVJzU;j$jmoG!Kc1bWnAl1%vmcw96bpVbix+JTl#hyE$K3{!uzmVh+&wI_(HjR1g zBsHEGED_xNS*BekmGSWLKlh~E41ETL31beJG07F%BZU-Ob@*jxRKQy`M~La%D(3n9 z-dLf1zMo;>K%s4{@+$ewifHnlZg*OD5AE*Dv(rghN1U1szkafWTV|woc++lwj!ldF zHN9qmD=*+@iki5VmbJO3y+rS}$c2WUe=R#ktV1Mx3JR%QlqsvQ%TY=9?VMM$-Heen z1sh52JezV0y|GwUDBRj#L0p(!*X0Sxmsbz>{F|Bqc%4KY;XGEph=l^N25wgP0Q>H4 zo1WPEQ@vhi8C*-2o@WYKKv-8k01aMLaKSt)bHl$b)Jtuvv7dpUi2N&nt$ttGsRV23 zl#3Go6lk1C`n1L%{or4&>x=~>(hDesGFkDRTY44X)@T}enu}Cgtwaw-< z5z!ICrfRc*ChD3Vh2V#1e>RgaB<5FXE?=}PswB6{SM4!9MtfJEi##weOg~NTq z(S8rhci#~ir?}jy!tj1|;j|P@5+*INeG)Hx`!a-^WQ3@z_B6)oEk9G->q?+!>f9g( z-{70kU^sX;bWH;mbO?c6-|}rUsTZ{5goC`Vn@z|{AQ+mS&~L@{?}sLXK@+|+cgB_K z#q#bQyYyD5)xecd1i6B!n&)4%-?bQVT$n~r$#9_V!IbjZSiP%L)rZu_0WEiv&&7T6 zn3EA(3WWy$Pge-GH&t2I^LTo2Euj4yBvRfZ>oD-Bi%)9-SId7c|=I?hlxiC$PLf`^u{-P+$jLG+st8e zI)UCFQu0zE4Ft&Q-RNuAeLsA5PgLwghsnYj=uB#Eb$iGta0>m8--YEW{nmsc1Mj{1 zu-uFgu_4Rgs^5G$Ln^P(FWbxYhJmdK?w0V$&Y$-86kW$}@Y>*#rQm=XFx6%Ql^_|g zt61+*9#rI(pB4$k%y~>yhPj{4HghJd=|I-0YqHd}hg@t{3yp^!%;*YdX<+&gYfC6E zLuAtN##``l?O-EyI(Mf)VEzfKI;r9DVX80S@+G5LL>Aw4LVxzwHRFcTzr#MwhsSn- z{26e(*s)rr#?d!iy^$5s2sL|%b7<(@u)mo`D+RCw*xRY%z4E zEy$@_pG-S5iD2&~!j{e3JiwRH(6(&qMzk&Ijb8M9f){r$*Zj;K>{b4z2eupL>=+CX zcJE3rn(wgIe~dHqsu#;x5LXjhqifM|B} zD>1E~rdOI_FH|!~voF+aXz)}4b8~K3aya*#r8D<=-;a3G6M4rEi2{uzKn{m~=Cu8e zxsPXt%%3d_=(=B1EO<<5&CvyCUp6ms z)Foz+08eq^S8*D&lDFQJCGv-Sl^^U(8Ivr#WKza4-l)&k*Az5M2G2Pi&N`v>iAjmZ zhucm6>w-UW&3#xU9rT8!SZZKZI6|5;GM}>S+1FnAPh%atujf-SW42Q4SwAcLmOon4 z0nje1QoW!6(l2Fv`moY4xqsDa!BEGx-tm2$`1zKC3=2;qCa1=>^D!&4u~vrv?oUbV z4q7V-?V-cIZb$H&c{ip5hjNPHV&uz!6)HxuX zZ7$ZD8N_9}g`blMYTcddQd3(8mu>|~jm6TB*EA|)WfZb#;h||PUWUOw-=yg&JxrSo zriX{%IX=QnJ;l2ECCGuPscHZhBGq#81FM-zi*Bhj_hx{)8|#vjPD?dRRb zDt!jhUq%NXfF*;B6rgJ&wpne!IE>q0**>HCzOjHx+7(>V+H$0c?B?0nqDQ=?eNlbf z4k?f|zNuQ8lR&m8(Jgh`!Fk=6DYLBLN^)Ck0L%?{)u}E5;KOvg=Vs3nymZ>3hONGF zfguVRWE#k$M`TniM2+gLG8M_1Jgb`s#PqamD}+ufIPUyUOHehAWZR&R4$B#sp3hV8 zkr)es{RHf6YzfbTekd z9(7o(ZjWW_FU!ijr#V*17W_@CYw%?gVqtDbOc%su%2hxGQM^_ zz4o6Lm;1*OcE3*l5wv&lKK;}y9KI=^`@#(up0ka-CE~)sJu)|RYp_#z zut~f`{{zEgz#?4HRDbTKclhgn9Hbl7x8iBTm?sd4FaMJQR6Ss53h!vM;#C(tfPu;W zHGgSV{uxMMl1_n4=G-@O(l+r|E3oI`?A3WD!$^m&8I4u zyMAqFpSkdCVJ20WjDf`oO$&_t!y;O^hjjqN{)7HA2~5Kf)2<}H#$An_eLk#}%! zSjG5K1;eK_-~n!hcf1kTLx=nd6uO-&i6Zc^O1I(pfj{0_w?N&BHEtQV%6fp=l;dp| z#{C#%PKV->%#VPr+jjy14P@f1cwzy$aw!4SQUIDFUWB;>;+JFN&myySkkh(b7k&{l zwV$+--}osl<~Me8$XRFsIZlc;wTZ)V37uNjIQp|gU5#KJ?0&yW{;%@pUh9=6X{Xbb z873P)n<1^{Q~qaZ!O4Aewr1jL27rz0a3paY<6H(>gQb# zCX9bA(%sxOG(?8x8TvCRO{zv~onlV=P1L=n;I5PTf*_|nRx zANBFMpUlQFDVwaOJKjlqdIZeM6EC%St+KsLeokFTW}KW|+2?UT66w;xx%9i*V~c0V z_01ZYfIoa2XYamiz?e@wgTEWANJ;zs&M#U4>|4stbUF4By>wsb((P+ezn}}lL$l<| zwjRxsZYrL7r z(}PR*2zn|vOz3A`^;FaN2|PXgRcH~iE8>KDV0pDO zC2T%AbUuPrwJ=*2G*aZa^G2d^s*4`AuXAQkE<2F7gZqn!y-_5z>GoRO0s)3Q{;M6T z-90k)p}+p-_~sf!XF7FgN4T?uH;esYOkT7_iZ)hXrq?31PJAd}B%nw}>rpXm{1$=J z7IU=G1wt-bvGMJ`pfFBrXQ0QrvKTp?>)+M)x1W)aq7QE zn16eh7wV1w zZiOBHvt!GHh{X?FZI%s(NL)xb6WuU-%{?R0R!8K|Wd07L>jO6Gxv4+UtkdCMI+HtpJ>kvDQoAt)Z1q4N z&9#se!FeUR0nLt?cCod&YoYKttX(r&Kbx!)T)ylvQxO-DdiEm~zb0DcDDID~@$Fpb zeEuY3x7T({gYFGVF_iGA_GQhsS9dfQUq4$*DQr`Sq`C2q%eEve9d; ztyVYL&r1fN_-Hbblj^jLdXxr`4$2MS5s?>g_I04z|GAO(xt?D$Ay|F_DesiEvmZCv zvftOT-@2o-J|6HX6D`*EhgWOE^}Oro@yo$qa1|1!Z>wxiLX_NpL|v&Z(;^==qu;6_s-f<+T;nm; zPX47HNiYmJ)dGQd5#!Bi-%RKCO0dnmotOE#1n2tm@7Zp4^<3WzSB^eJ{9nCDe|9kW zw&9r5;{a;Cc3gFv;%n09y8-d~SIKcfU(#UTiOvJJ5ihCG1LG|#x|DxUQmD&tR^sTK zbaf?={Ts?hs6oNZV3Sb^&qnfsHpp?i2Q23S?p?3W&>hsH zpgaYHft&V94_l-z<&nLmHlKXPY9t&O%-!^j9p{*@ct&sEFtU!6SRg+^f91Oz+ZR{l zFuXDiA_FuhuFbVpcjRO(RkLSK<6J2f^Zc{{IjW$M&2fTDCw@;CJNKbr*#EP27VBSZ zBlJgW-1G6D_m1*(nhGShrB5EMcAT);F{xY?EbT|9?tt~m*ZV$f&iG0MJawoyZ}}k@ zA|k!C+vxNAZfdhdlIJD=uU1(TfYDupSpD2EDxU2a*h|>*i&$eBZj^bx+cyi_Nev5R z{;w=zh`yfA3N|e+3>6S@gMd!{Qf!Kt`B?RI0C`;MZ-CFc+-nY-0~8}l#oSj!yd&S$ zNB6y>`haqV0j%tKhDh$ysd$kGozonkji0jHKwvY&rN7#$pWN-qY1NcM|FJqfCWNS7zu6CHdIZ6G=pf>>M3(EVitUoe!}1H|Pu?NgeZHnXiEo|R45ahzeJMSv z+Q_3v-Dy!5&%H;Y(XDxuyz;!sA0D zpu4NOAH_)pUE!iQ@8 z3kpO{ws{*C5HF^5(Qf*nuWg1YSu0IUB(smO##A++?j6zgeK#(K@9!|z2U$(Kr!~pq z$sLB`=a*Yhx7D9V%Xa7OVP3_dZ$GurDQe2JAy@w+k?)N=jrA4(U ziq_t(n)Om*wxz@FQhRG@m6TW!vnX0yjT)hB7RfeRv>NjWh7=(q$Kykkt3-3<3& zlEMys5bc-pG6s%g3HRh(bQHpd`WZvuZo#z%emV>q1fLY6ns2{349RaWw-zj4MDD=N zPbC@0W%!%kF5X&E`m!U}(w$%HnR&*<91H(ur}ji`fqew(9e;fw(MapdeI1aJ7Q>92 zzuiGNpX|;-z<%dhKCEuQM2dyJeu)ncVP(n<7x-|I7{(wj{xNgT^zx(K<^`V$!~CHC zYID7e%D&AVC}-tG&u>=#dJs&QvB0`FO?wFx8Tu&YuY4*+x#C?q`)_{w`_13&#t@aO zJ}|hY?NS{_xpc&r8KIA1V`QYVQo`TEHAa@2`XJb!K}7M?+sydm$o6p? z6UB;S;pd=TI#(r6q(pK|!q>E+-7+u7qvFaE32ErvYYK<0Rg1z&1CR`FyUxWPXOWIa zJG@70+8s8h4pm42yXhS(i;dBG;NVxa*0co;ZBe_Q&sLiIE&UxvaL>~iRzBr7xj~-h zSaYWfv|-ITRw!yxuS!+>3M3>R(uZJ*{LLovHVHg&5|Pj;^8sxOxx+`VXS+*A{-S)1 z_s?03g4JFYo!c6FQE(}Xd~|04Dw8MdTP{Jc9x6;z+TnSBJT~Kk(3Z}tBuExW_UC@u zeiZJjA~9MxAG&koKu9-n@Vu}oA)qDZ_FL7nH)%HN#?(=FaVf&+67sE!!o7LWoz!Rx zn=K1*A12}^Ifwh3KZ!e>R&x=a^YzXx{l}c};PlM^ZWY9=f4}f`M__t^5b&R%#5j>i z^{t@=lZ+X0URLfNd92QUj_V#7=Qi3bKaix(36*S>;}om>!Aa)NM?i zvbYwH0(IUZ(I9_&x!{=#y?yl%+xA2Ec<^#!io8oj_Cu$iSBtAtXZYy+rqUbrCFz&PrnWwFt zH{&K>3wg1u+GT0p*{S>5fni>?>6#&VD#jb@xaQ@5P6N3p(mtKLk*z7=Ro@ zC{<=K^C+ae?LvX#)_-n8PJb;c2RgKf8$Qsji%alB`H}rm5(ar@6hCxwkV_a@+r0rf z%vd@K*1=3kp`5#HzD}x5oGE{qG4ku*zSsa!Ua|h-y^`)NKYGD&sbLUlJ}~_|AM%%$ z*7kcrr{cj2TOKcG-H6u6=Pu(Y6*op{p`G<}&l{ne=QiGX*bhNhpxFc|ynE3mnsLi{ z+=Tg|_c{|!;Pa&yb9p=K!fBOv`&NwCgx(?zt2)=hhU?qYWqxjn-(sp`o;*MYCVCNe zWz*ftxZl16A$FTO&*Id!pL$=|EUUPc|;;1KiQtFWQWpaSTj(d0Kd}Xk@}6gYQ>IK#-D!ErU0GmvGt>is?G{U z44H?+qHYweN%NForX5d*1nsk=jJ`>aEl1D0`MCE*itJ_BT9icWRtCl9-%GS!zjttS zH5f6JX1k{qZ+Lv}hZ?+{vnR8p^bpSwme^9FYgXYA>SiJww^evJ3mN*}kHwM?kbTZO zS29M{3Kj@d=5nNmuVdZZ@wA%)q}8F~6$Zt9A@t1==y%(x7lmW~sG`hK$T^?+1Mo!U zEcBZh@I6L53uz%lSS97!$CJWmF>bpzNenH67%J^2?kT48CFWsQB=~QDEvDd?Dej^U z{2Ezr^xMh-w=Elv?*{I=?ftcBExsKeR%q$JmJzG+S4lyMn;}|fCwEzt_vLpWk%tuq z$G>(?x&_MqHL31s=IP)r;I3$A86DzX%LDou+vj0q_etC$HX-0$4(=@MG$yEgSpt2g zwsuk%cDgsjXFU20!N*0%jv$ng7K%EgsEWXBI;l7x^tVCC!#O{b^pYkHXKRziwgxB$ z+ZXO&pY7bq_pZVPiCzrBf)XgFp$uyMdYyPTxd~>xGWZ?%4_r{$KK9Rzqgjl35Pa`c z3)C-L&lW&R(VZbjdJ1E{bHw)!8_73UzGLwEBO_#neq9#Y=lq?@^mhn>rzGz z^xN7Y_w%)~`9MDIm*1sxgw_RkX{Jg(PR--{sL3xku3y8(^hUV(>?}?5RhPk&TpshJ z2M)7#|1mPXQ=<(I4}S>oHotb_u&tEI5s*yP8^10ix)GH$Bu$}Z`hp}43^SXs}cG`l17r3 zwi+ZogqvFG6YzS%HHwaTFwXY`Od_Ln$3ctd8PzHt)y4YtokT%0SE{m>OcASZX zl*a)=xIynb?)?{dmGzmLGILB;WP5)r;>!!UK@T;gf9264+b)!$xoI2$xhk2*H#XZ| z?47?Vyv~OTKk`{TGnsK?)(Z#}%*qv+#_8yWori{;ES4#kAmHLlo%;v*xD%sMU>m_g ze3Q*dt(1B=qvl&VbyyRBFxq}|Fb974V?f)W6FD55dTQ8x9j8;^XLWUd?fe)toHib& z^+`Z4mArv<;vxxriEW`$$@iNh1J#_bSWa1jMuJkeY+bzSM1ig46U+wtImLNkgwuUt zOfm->#l=fVZ`))sI;A@1@kx0|-NE*X4u@f!Bq6BzTjF#mT* zU{Dqjtzixfr@Qd21m813aJgvVMOh)_61~wHQIX$MJ&Bok(SbE56vxrqh=Kg};IXvO z88MtRepF;UretcM!zfqlQ?*KF2)7SyPJ+wQK#aHOar4!Mh%9F$pbN*gkLgvL)r99F*^iqxJDuob!$CC{@b}&DB!2z8 z!OXL}%FG7S9Q$zc3A62ON-f8N{iW=$VdP&&*8O{?NZrbW{N!gwfe4w8iG14LH=xXV=%8tgSb=8&mfBkevl{|YJ)JAw0nNuX(pv`O zD*r(N?F55a)`$V#kHRM`kxBIJm>g_^9Yg!1bA{}0If7raC-I2=+_2NVI~2sVA9*=0 zK%_8Hn`1j%X@9+Q%_&CzT>q@3*rRe-a^B@D{v}$D70J8vG0k(4vOte3(T(P3k(RTFUdf=H?PC~Q)eIok?qi$d33g~m6~`( z)6%#b4EGscs|m-w!kWM_S0E1J4mAQYj-%PgDz%fwh0p}*cHeLZt7geoBisd0{WxUA z1qk);%GGsaqfPX&y-W(?B&1~3iL7TOELte4J8IL`1aXMP0Ky1Nk(}(F5`=x1)fNYl zI4-YKi&H(iQKLhibL&K;f%l@me#0})tm78E4uzY74{o`^@(=(5o>5ryy$%8E7XZ(t z-6d%riT$JG(MG&wOuM`-uVkyabiCbwriLJ@!avtxCmqUxGLGL~IHV`NGb zDEs|_PdV$p?#Z2K?~i~KAVO%GPv_7Ebe3}gu1zz#g*^%IN@^VfI1#%r+QsEcUQWbs zhpAD5Mv{q#%&P5qe6e5P{YI-#hm6k78&8sh-d(v0nFtZW-9Uu=p)tlZfs%$gCfnAN zm{5xSC=BuJ6KH~Agzbf*buP|6uc+y18De4UVrH9TzE`9{bFT!ju|4`hOH`8)Oh^{EWv&bHzT8bRSuzpPC*Hl|K#P#vPKya0>2a$fuYwegED!BHgaeaZ z(m;@Tm`<{P_!ATp8!{yM@KYTU&0HNl$fzX{6z*IRG+Ps@KC;7e2{G%m6V(Ik1|8hn z$Q|Yn`xe^$?PVJ z&!pK-sIlI5&D+6xs@?2**7NWPJ-DC(C`hNg&ag&7sHH=kw~EI_=jPB5Z4eygF-WS13TRV*~; zYh?V_I(J(M>;Zncn&uMmLqdks620=7k#(dFcQg0Bv)AO9h9ioM&(qqVRLT4=uq0C1 z@1}a~k6FEd4BRC(r9cFnn)pJt%)o^3gGLUIJERW8g`kpPL;y2;ilY0#{=S9`DOhS| z6nCW%cA>FJxVmXEO4do2ERF2gX(83XPOn?*#?3dhgVQ_@rH?wCiq}-Gvzfm{*!%q$ z%UPNN3j3w)Jet1}GbzC6-86uL`t@TDlYQkv^w$$TqqJdiuRe49Y5BXtZeWH8ir4i) z<^*WN=$j_Gti`U^e+azyB>0l{b+{9P*>4y5B0Le0=8LP=>%Rh-O?#i!Ec5+sw=ea1 zp&W;P`}HZ>ye>%2nTC*zi?_<@-gxM&dbZAx*)?ZsPpBvceg%g;iW#bBrJX*%$F#6s z1jQ6TWN08}%6tl=jpdk9E|6acx8StSxN3!6@E40?C9^Ws?5@gjf*y7(X#>OSZT@Ia zy*}F(z$o9w4^M`NSd9e}-&=R}lVX9okX=^sB(A6WeIa<8|$ zTznIhoejNmFJZe5&aoo(cV`C-EcJ0!_xXBKR);SD{EIT!IZ34~cpd58Ez_7$45g*2 zK(QQJ{+LxXc-x@sIwRHVfe#wdKBVpLL>vc_Iznay zrO!mAUk&K=ZzYW13Eh>Y9k`MHb5Y{_0B;LI)Ua}gR!IFGJ`|_C0-f2+pI>o0El3cu zW2kk_hOMO+nS~V#E<6FOWB`ePwY(|diW%lH%r)|>Mc(INF!QaZix;>3WTt$cPiSkc zQ_L%$7kV>f_O7;Op9H78bA|_B`uxVUwFm8|TRLGMDn_VRE}#4>9Cujbf5Z5vmnXLw zBKn*4Hzuuo1GX88Dcjvh6Gsi2gE@_UAaIy5S#Lu+b9}^=rB{ zJ+kKpPTh-0-BocbRLtr{J_RR>?dkD5j6l?$x!GRWvTjj#Oo2+`O8Nw-;p%e2YzD<) z=ae~s8h4LW^M!Va$9@ep??qP4JLH$vO5Nry@Fn zX496G;HnTs5M1~vviwb=vXT{&{~rFfuNKbz`-1q6BRo@=zjx{~!ODDtFafVYTHGOq zrIC-D7QHKO`+awo!2ZoR1JWM*u-{N_a)YIir7Ky)fZZRYSK?vNRY99}>|3!_%g2~z zCdm>srLfHDxtATg*195igX?o0P4KAHkQw>zy*);3!(<|47iu$vg8E_*T)YXMx4btj zyE?268_n@x1MghuvbDX&7Nu$UQT&N*yu5z!m9Qq}lS>0A|AqV~s^~^e4}79&YmG$Q zz}=L1lY`V6y*-ougZ7BrNb9Ldd#?oTd|UyE4Mb3Zcd$_SyazjsKw?ju!gQ^dFpH<`D$xBu<_qXY?R7^jgViQ`u z`W@Md6C4m!x*xFl*`W(?ws^vo?xJ}3=S!;QP1VUx{pKUfpfThir9j32XPtvw3jVg~ zE0gBS?N{LAA&qBNEVY#yJ?XmYk&_Kcm?o>R4!ULYim}>$9Gib+Fq3ZIs*xQ(bR9cr z5!paJ4%0)^OKA?1>HI#)A_X`iNBY(sWH zlOB%0*3WCjL?(N5-MGZ8+SH!@+~rE|aW;skJfk?aD`j(O z;6T$%*PsP7!CYuRQjxyxf2io}%{%8hYGnNIrqk8LGJA?7?vee`Mw5?6DUoO>EsdiV zs_ma@qRvig^Fu$A4!`~WUh%C!Gmk3={^OSRH0Tq}S?&AAZP9=Q&oD}3r+FFL@5`7c zUvxx!SiriL{w%1cZ**oGLA-OFlp2UFJ0DV*%Wd4S{P^$F%+n7y+gQ(dE32M+t6FzS zV)TWpXWG|ahdejS>k4Y1;FOL+6rx=W2et0fteHsM_i^{TibA-)Ej-w;=@4>VEDDk3 z7_c332q4T^x8*TQe;HH03wA>-Z(AmF_`JH%e-Y56UEN(xFk>y_Cnsl%6~L`xN?aJw ze#;&onLXF?&iBuT@$dE@EQ|P5uT|%CA%p$Xct!oTB(-?IYtA_JbCL~bII^0Ug;zu8 zQl=SG@G1B<#i}aA)DTE!J>jV?7$u{u=2)xSEd`&auFb57A7RPKbjh{fMh6O+l78Yx$;Y8j9pd%b^ZG-S8FCSmt3VTUie5vfIyT>qBl zRcJq%c<+}IEE&W$U{d`rc4 zf1fClZ32%BXMlr8o=qnPIV_a=NBKMJw;X)lJeoZ5l~X#Pfc)F9ubd!r&%MW&A&iM zlbMQ(9OW0ou&G5d;Fuy8?uy(TpJxIQRbT?Wq2&*kgplJ6P>1Gt`I+svlAoVk*M6d-N3}@kC>WNjnAOfGfQH4_o#F6nND= z=X@qLS8wvV-niWbi8!!$RXTeB#;7s^nnWBHqu<3J^&f8=l$y+6?P%}k9ff%-8ShOv z9G07Oe}DqlVw#>zVy7oB#H?Q5Om$~_3@LSR-o zu-j+$E{ALl?)W04DLdQo*21fYxNugguOMXI0|p%kms?pwj+q*k=J_6JbsgdJkS+gI zY;=x^ubVWqPpY?S^mKwd#^}B*FicTLdJcp1#^}!rc;)YA=MlDCR7t|ET|G>pZ`wz# z@7u3p0@&N0ny*AWTxWp{uu*;u3VtAJ%%IDXLNATxb$|>Aw_GmsY`$B=$!cY~3jI@B zPUeh_(I)k^b#VFNAE6(1Tz9d!s)Y6Nl!#mUW=yU2BGqY}BzurtkWWGVW!5lSz;hBE znEX3M_~4T{nls5YS&N3}=r67YN3ym~%G3g{s;BMoDYguVuQnc^Fmh8koK1D7o`svE zPVOWsBO~x2dP-v##dghKZ`CosMpFqZ_xRCPFXb=3IUD&8z^s_^+?FKd`Pwf4;}Sm9 zvS%60F&$F-7BQW*+(yz*%QX{t5v*6(uAHiX$KD-Z0TZ8%VZ;a& zv85ZxvoN`>im%xZm!_EodzycRhV#7Rx#h;51d!AoPz0<;s@qE83`Vb|yVR&Ne(d<% z8zH&z3S{P5_xmUUw!sWepnjnXRWvt;?R}HX95yTqUK#D2R0kq%&!2=%?mLZ9=*=KLobN>&3O z#r54{OK5`rkVTWi>MqGE;xwSb)rXL%g|fa%e*TwlxP32dv$cTm4d8|Lx+(u^^oEW@X2{{Db?F@B^I4 zHZ7kIoa}=u1PLkM9}vzeD!ykM`QytZ#Otfa*4Z@!;4qPy?jGBvKio1|+l}I*{CwFsADhCAE%fX092x z$#ndljFjmMf~?}I>tGonp^wCNJovTg8-e^U0-=MZRT_7%ekUo8=la;YF|=Y2G&2ANoEW|;&6DphuYG7~;ycfgEw1L0{0{wz zrW~{9jejdC{O>In2ZgJE%VoRBk%-Xb*Ppo&SLGZu>T*Bsi6fD5-sS=-tEUz%E}jJ% z(ji^Izcvnw94@Wc8(k2rHq>-%2ammgolqXa?zviBbvk zF%sgifPXMdnM&d^7_TrY^f=8(vfnClS%7(P0{65hg~^PWsTJ-B`BB}q{H?jaOxnn@ z)6{%$1E1z5V9wj$<0r;{q0>rWkm~bnojfZak78;~rvBzRlWeR>hj9XEL9i#21Cz`z zVE))W>Bfo{goY(+D;XV5n?i$mK3)82FMdXC~Ci(btXn?Ts$LAD49 z%#H<-z*gF5BItpY5A#bKM3ot3o)l|Ae){Yz7+RR`5(`L_N&s&giH=*GSW z!Treshn#*i!k_8cUeuNi-1{2G!h^L6mc$Dty_bF31J`fg-wT!?CLGtQ4M_KlE_{xv z`Z z1kAD=^87>B-`;KI6ehm}o~;@nGCxIhdNQHJh=dE$CzJw4wtfHwW$*hw6;ydZG572} zUu^zgrMdpCQs4|9}6t?>z z&$j9(<}!}>9_{OJ`Mynzp2ut1#g`M5445{EO${M zHnYfq4E!q_vRf;V;FwdAlLdj3M@{yY?J|f4UrnW6Z)$)H#!>lQ!V*18*Igo_LqxIE zsKEDYfsnbM>>Q7N?w4NFy!#}mq6OM8v-g25w?}8KN+1mQ4M&oI+6?? zJkO*Jn*GL>RbmsI*dnXpX7=;Rv#Z2SNj9$jY*@KvqFNlYbf|#$e}B}BU2XV7#da}9 zi!x3yyxBm-YTk+2wz4CO!@&9CTY@%2f%k4)v?uMIFp#_S7sF%g6bAvT3xyw~Hw?x% zEDDL^)F_r#hw0aAEGuk{U<4xTzr2_JX36v9v>R6+CY0FZrg@a!_IbkcpVkI~Zdq^A zWSP75vsiueKDD@Bsb3tIbnVSA+{Htv=4`I)J%o9hqmQ5I(EUCCxW=vf|98}DZx9-u zRZR-1x|gQC6{2q(Xw8;BI(8A-FFnpd+}28U$8LHcOAszgmVHJLsYUM+xOp3XR3dt!ha9B=T0d z(1L?2;Q%-J^+Rmi1&J`U<45Q%RcZmKq9&-V2W0}U7>hs()~Gw0wN{la#f#xMy(@c~Duujgkk2`muSL(l(V z+~Da7<2cV%jU&Q&a~4{ZDjp3~-1u^=n?k!41|=UHvtniGhW$SagT*Mq0lro31{xV1 z9!(I1BkE{UFM6e3$LrD*pK-3hTZgS;AHDo`LOEEddgkRnyH}N>v?f)hAOTa|lwPf) zgAYu(BT#5kaW0kTQudSfK5X@+(sFx$@T|wdUD1zFK(>OJZ*r0L5Z%jY?{fr;=1HV- zOy0Hy@>%~KH#1Gw2%Xj3@qI~w%B}YtZHMd(jWI$wMI}KuF-Z+QRPD{vd2)4zS4w96v^~vd>!64amP|DW zaOVoMxhv#Lg9#I@A~ ze^a(KWnds04O;8f>_n|Ro6rHikKd5-(e+mkFfVb#yb?h(EjTNWn%qVF1eHXMtT#B2 z=VYqDbZ6UAQitrQ+Jr_srxugyu9p;;8x;LV1>SN)oWlwDxocDk+~h{bL2)MMA+gV| z3+H^0ce>phDY+Gz^{IX3f=4%K`f*0=G$yYn+ttpo;#ObeL|bLcM2`YVBSh)but+m- z68eM--7g((Fl@q`zM{o7YO5A}dK~}rGK$zha|2gE#>xg3^V1=h&xEr;_PUMz^g(MV=e_1E={x!ie>^Gr>6WGt);^nvQfzCi?^&Bo{3wXXLF9v#?_lnTW zba(O^t{C{ZSeKc$Rd_e@Qb@UGeO1}CO+DP6wOnr*RJmGeapB;yORq*g3jCQ4CW&oI zZ^h-VEZ;FW+9;L{4oZY)#nAf{zxNVb;+ynz^palNXnXbju#antUVnZIdYWvL<0^EX z(W&$R|0;PPyK8~W?rrFSC^bMWJ#=%xXUa1zijm`CnSN=n*z&>oaUkd24{E@p2df)M z&|F%cqM~I_W4_08;y|lCVB3vX+i(e6@`_2vp&h62=T~M8CkO)Hu&9NG zV5%k$rRvKXvdYb9VO9lTt<8jL8m_G`O*o_)mzz_v0QG;k;?+iI#I%2VNo&B}6EOdyl2^&p2Jq#?)WD17~EWbhb&ZC1!O=_W%s0$~!`H?9pEt~-D zr57AGbYM8HmZb9mMSBD{!!YTl3`mwMnTcp72Rt1hL|n3rh1>gOVrw3>4{284yXr29 zDtyeoqV>W4&F@};SH_KMY;5J6nD))eKCeFjErP+c{Jl*5@97cUj8A5XFaOEY{iR6- zss4x3kII#yT6yv>(Jm15m8 zBTXvB7YsGOki5esApz); z;W!k(j8n#y$P%P*$FHHJ=hh)2A_lFj^u&B&O)TO`5ErN&$LcUJ>A_xm3Ya-L)2M!G z8#G=+u=WZ`EfsDZ5O}5w;(FF_(<9`df~76MaOB1elQZ7uJ?8zCVPRFt#8@e#S6ssa z{nZY<=iaGs?Qo%GllJXvZ4I=^J#-d!1*e3D&#~0O^X+NM1E^)2F6V%*BG`#s4zqB5 zLqHT(3k%B5TpV~SbX3o54A|xL9Roy#${bLC%ftac?=nkM?vIzTJThH~OK-SM~zrA0YraCYO5zD&4m;@-sL0OcJ?xutK(OU-@ zW|g3|{SZXp5sC+>wj#Obs14Ugy~A5#i|PH;;kh(F*vo&w8f&Wc_kZO_Pb1-D$)2!3Mlq{C*Ji}6nj>p4!=>xS zMO|FU5!wG*@KtO!cM{6WT1OgNt!iA(8M@p?o({nq=5FBV0keE8*ilo5!Tpgfhf^7) zL^^an1di4u`yM*}#t;u~)hn?;%rS(YzCrHC1)REE`@#2m?{J82QFbm`@eh69rpT)! zcV|_4d4L&u%P)jZXN!~f?|tf*Dd*A+LW^xy+-=kT)TOUuUO82!P)lBg8GnSUdoOy9{c8*5%028u1u$|unkCXQ?)e7{>eU`qv$W$@&$Xia~%*TAA& z?H4Z}61CkG1NRpSfy&MLmpYcUfgWAMgjum(?FO+}=bbP0hLd}CZnk31>_wRqdZ-rr zXBm_C$|41Q(?EmG@+Z7wOvkn9B{$33WB>upnkUiqxBW((%VsOD>78kk&O9ihv%RsN zb4jm->wWqxM2Y|Vd{LmyA3i$(o zZ<7E1fCDT&?1SVSi3Z+KP%TzaF{aJKD@3`gqy8RlZg`>M=el%?KxWj)spRL(>Sn== z4$wl5XcFv(7+NB=16QrW{;c)U%!wn7i=CuHCsatBdz5OKY|G~oc&%w{k77oQ>r(K< zt2?)PtTxP|@&sS$Zr5dy3!;~-ec2bHYK)y%Ly9=~U2c0jv=ev>d$PNbf&tvUk_Qw| zKtHIfWn7#xtIFd5v07-KPrIX$HK;iN)V0CS4Ye7lgGiq$7-zCXw3g!;JQPetBFb)T~rQL}%1bzMEDQ@|Z(Ri(iuOkR+Sx7o$2AejGw zxc&3_UD*v!%%+uAR_9-l{sO6IjZ^geYgV*oz%j(sDw{z{5j zdL#T`tOwy{_4NwdFHoJCOJ^YG4pDNk^Tk=mr5ft&D-Y91N8?V3IE`Ky7?Jdo6b!n$ zbJ@*}czcV}mzq_;nC4s7B%>Ttu44LBRUEc%`eWHTmFdKCJofOq%q_LlERZ(S^>6># zy=b8xW_v0I{nGT-b$^XqXCx}u76eUJoH#psJ)3pFonnM|Ms)#Ji)vmi|CfOqG2i3j zb+39Q(4Gs=#<`PQlyy9>jWEu0w3eXkrK_;hof5fCrQMP0UW+6^Rl~}!J90!ETD}S0 z8_d-Ta&L>%I;lr*+3vYC&DVww*4)h7JzqA^^T3yKMeBdyMkhOGCWya%D5zU?cEiTk zi|fU%UInY>$z^TTXSypqxtax}@xXI$lbAzp_d$q8ss8U%{a>z1g|Ahk>$`*pm{#2= zjt@4k?)b2rJemiEN<@h!=1?Q^jHyZxy7=(Exl({aQ{oOdYOU7U`uPb&w zP9sAfcbqw7<;v_pr{|_j!)&rSp7jFgRMqP&@=V~92SgGVrca(3g$50(&lnOp{UDR{ zlDGNvJ(O}8gy~(eE`g+aABF>B+CZ?z- zKza~i1K}{snHnj4D0fNZATs8QOoS$6f>C|1UCSc!t&q7*;(q7Z&E zy!wRT0$#sZ)xIaKW$`fGdaz{oep#kaDpdZ#YT3Figf3gn)q7`Emucr3@**w(E#LU} zjoN9%019oK8Lh(8|K8_KIymOxms~c)5k8{IRp|CEQJKGoK&YYJ;H{1U0Ux{<)-2XN z()bnB{;=6DseN?s>xF(B*{DO++`Q24^Rb@z57h5OKsDzI+jEhR9i5qJN`hGr<|B}{ zcH-LW9mHk@jcvBafAw_>@>_*f&$I^8m{G|0TWMKYZuvW}enxdTXbAl7ejfeLa_d0@ z>@vak45N=R9u=}{0jPU@`~Tl#bpl}Xojq`SXXM6;a&P{DWM?gOL7!wP;_-18K$w)(gDrzekb=m~niLOkc^|Ip>l9p1(G~W)H>X}CM{HYq>p$H=wHm5o2{%{)KZ3q=f;eVr#5vockFGkIW_y|FP7$0>KLP z3yXU*H%xzr;VyXuoPC)X1e{1Not~;Bc~%4>9xH0h+|nWRx!!Jggs}(BNb+0!U5v~2 z$@8V7CDBGZS(*>oYRP(es*2a?f*C9x}T74Tj6x?FLQKnR$BQdZA%4p9ibf zJqo?6w${^my96hAASTlhz5J!O%AS&5#F#I0M8FQwlVB+H;f`f7qJuhP3qekLOXJZl zRn4Aa)E~R$G@1Q!-b_&?c2In@@kKcFC9ULQcySe;jTAg*S`E}&T&N|nBc zoAgQxkTqz7Stt8Kkn&oOM`HH5Zg>=)-m!>1WX3!39qi9V^^v8CvorbKiB^%}+9|$O zMo&gR@4lVGX)B1RK^gyh7!(r}X&6egCH(bCEVZ4h*FBuKX~*D^E!^xcni^_#J;1is z=XSj8eX1G+L1ST_!XR9Rh{sQV$nc)f&OW}hchQMBJ7nZlNYq(rHg()70t)-Gxe5!! z=2HF{Af9KLv~wHjR*iJ^+9LN;iqwD_y^A3prTfu!TSaQhb1ow%U_qO0e;;AF^WNDD z`O99jbXu4GF_9KiFRP_?a0cOjSirdbniQ5Yn1aa#YG%hht+e+O=aSC*laAv%zI`MF zChm9xm{;#tC7*{49jae^)l37Yb-55!-I-9mxh;`0X6@^M7e{(wt*8y;??pT%`&n+$P_dx5Iqv?eaG7 zkH{jvTKK+8f;ZRAm=PO_5g1RgvoDh!Fq2feFmQ1VjD;k&*KjYlUbJk&3IEGovE_?E zAa=6D*!ySSDzbE=;_iCP+;AN0TuGftF7X>S8Ae%@)(fg+Qr+ajkd3?XjEKLS0iqG= z+`hPP+{5g@NMYuF18E9Xtp*w?x(3q`gJ28)rh9@XJmjbX+#W!CqmlPBC_PycL(Oor zqZ8`oGCWSa%OpdoEyZRr0^8xqnV22Ww?oOlUZPo+^uJT}JiAldk!12a?9YKVtw_{* zi0t~O(6==sd13I>Iwusd#VpU&HL!X}YqzYn(_WX}OZ!|7TV__aHz(%n9s4Ii;41zn z{%O0vM0KxH#P88=J@y{9Fe@b9(Ut!%^yb>dA|}j`CP%~dJMTEmx!=|fd{IXGVMX!J z<1KE!xA>g@uD1B;wcc*A9RcH~#$NB!7wm_E-~it_dRp}Hrv8$~?=(1~BFIpXPDNZX z&iQ>AASPb-_995ITB8+)B-CBUg{BZ?*ZXZ>Y~j}8ws5%9Tn{GH+T^O(8GqaSxfwCS zhBBWOkSR}xzh`bwyiB&a>AAv+vzYOiQET5k7_rn*T&SC#g3PpdPZxoMB ze{blnrE0b>>W3XJ)*`m9l`~@QnM~iWymIvT0kHZPNH3S}?kh*8W~muraDp(>u7V?G z2pDj7>oWSJbM>%bc44zlB>vqtXQ0GT5OpC^nj?ojrs=a*csV1tAO2;II*VqA7D+3X z{-4X?-c$+BFxjI6wja#ay!xc;FRqk;pXCPDnqug}!S;aR*F$NtUxU{~F}$Kv%JxPc z9My3x3@s*GdxrZpP`9xeoDEBD+~E}12mKwdSTvtM&>ogK>Y`0*-}&h~Z$VOP~=kF0}HjW#%w zdwArIasPcy%<7dY#f!eDCgrb>1pBA#6Ep#|E1f6kVa7iaDQek{cAqFF!hT&^zqh^H z|6=DH`qo6Z?dy;yr57Pw6lyYa3eIU<>6uwAom_CK11dr?Kp?MT1IVX5(_W!&=n{kW zwLF`f6K}?PgD3U3 zM?@irhm(13UI=ii57uE9ZZgBIP?NDtklvhP(?}z`*!;)NdF$-W!^wf?9@3lo{6gbR zd?%KV<*oGSR;`qmYNFn-}?tvhX1uU}MkO2vMLPXH_FA1L-X1 z|3cWqy`5EK{8aTn!9cCtd>wzyro$CMN%Yo|Jfc1EHL6ph^sgAU;aga3B!rFF?Hb3XgFegD0Ftej!Mdvd$WHE9eCPc6_;o0Cl4 z6U{BR`1yg6D<9{7piY%aU1itDxb*abqY;XVH2wBJhh(` z=hvplrOYkWrFpOC3Lo-I42eXSiiz&oQb)%!o-`Wr(fCvj{MgnCcT!s?`2Q*A=ndtT z)C%>@b3(uMYzG!}z3p+^W2Qi0Fp-ve>JQz+G3M+GLg>8v6NVotVV&o)c2Opy4?Ak8 z{W-Hp&cI~hE1AA7@2fOIg!{AC>3EUCfPKKhU7UfezDf<3e=MNjtIZ1oI9NENF6{k_ z7+nv8C3E-~v>{TGPz(DNcCm3$SZjODEd3__q|dwYX$3ADU#e>wyP0wU zPn_XqOkpEwnAkhjv<~oY0|x3I2A?&n3ZP=d4&tyU8Ey-%CuRKvn%y-d;bbPe-!T2A z;wOHamXldy+&SK;k&fskenu>4k)`!e5i@lenE!Gi?+e^-Iox|hkJ(_x?%kE!o{3F7 zi&wEsa6R2^zw6@AwHf{?H;x%|{X4lK<7TB|L}~PB1o8fWzZA_O=uGXn-S;o*|B4$g z$Xj6h#Dx`fL~hk@A_w|VqpC%DHT061+euzFL3*5WMI=qwKdjOa=J|~Az^Vl}nujTf z$teAbvR;7Q>{P31Nzz$)G;D_+JMY^i?n@mEr~Fo9(74^#bO%Vip=_E84S7#~`qK>t zp5^||m85YTb0KifWSnEYc#L0S(Ky#ASssf+}-GE0`e&hLvNq(||1*PY3fzg7*f^bSqU&hrq1f z%-7MCeCZ7j9&!0s=18wwnbxsJN?*qX!8MS9Big?JDuiov6Bc{?zsm$9E(q8TG}FWz`j?1g=dyZq~ao4F5r z`61{%R2s`asYV(SM3rMqDh|RddGWNq5D8zyAFyL13< zhc+SxeLMbH8WcIEfFpuUZl4m0Vg$O8t%1j(jQH+_P|*I^_hnGqziz7gB#D> zbVLsM|6fs89u8&OzDr435JJ|-5?MlGV#roxt3((}OeNWkeGFO3zVD2oD9N66tc46= z#+IG2&RAzK%wR0P`QF~|`_1tj^Y?S#$9-Mbd0x+XUbg^93m+3%GTC^Xt2=RYzxccM zjohKgXz8yT`fqPo#0S^lpSr=PKs6F1j(QengAVv@{t!HvKA} z%@(n#T30T!d3O1kIJ%O5JdtqOZ&t1#hqd;IuIyy9d=`RKh_3d9#u)@FwZ@bP!xh4j zASHC|`=&D^9@dE_rNSWCwe=>?gJeA1?_r9=q3?+dVpVB~Ka{hXtCH z2C-HdW1&Bdtta*}(dvqax0bEQZC5Hhn&vLBzhf zxbqB-ZTQ3hGmz(;3TZe`5Rig2GH)nB3r4PUwQ^4JP36D$LT$);kJH?#SLGhRO=f!)tUuT_ndv*bq zr8&7DC}(dM=XC^05S|v7w^Xi=bK8*3Naq8tBtN!Hg0yZ9D|iocbgv<)f$n-|ITkp6 zzEYqNyEMzW2A79(NkikK1Y))~=4#x3Xc?_t@KAX5)_{xR15L&LB3D=HqvfB7?AGK% zviW7z*WXrt?<3F)x9=C@YWw#YdDj|h(e4-kbU6CEtJH>Qh+$9`hI^eOuj-TgE#enm zG$1}lmU-}6)LT~9_pj3}LMrT!{r%UY(p^mAj0R6T)a}7FV7GWVa^3jdghtQ$jbceH z_Si5tjU@F>sS<06U6V)zQqG2c_)En>cfJj69PY^eNbXNCCA{Broo%&J2)}!hQS(mk ztR0)onyEwXfo?x$Y!`iX@YsyX>!Au0J3Sz`qTq9(cLkiCL{*Q|t^3FvU@)kMI+0Jc zbWGIwVB`DkFiWpQ#QGcTR5}Q%gY;OW>DVkzR|h!A;9p=t(R8?mvB$$w2+K zxasp{n)A(AMa)kNz969??&lGC7)4)@n&g|nOB4ns2*t9;0o5R?>_~2ML<8QNocg*{ zb|%@U^TF2gScuVF7P{sv`TLoOhEm8F%_4yvkz?m~G=iFh`_wl@29Qb$LsUlm);e$hk27{e2ewFwikTzFN=4K3`OxxLCd-)c^c z78c()O5jP{TO@F#iBpcSkYnt!wCfN*N}^|mO$sm7GQ@!5kj;yIj%7rxQa{zyXA6xz zV_3%nscOE-Z~9}=h=}voZHg_v#kWt3u#cL|U?#NCM}vQ^-sfWe?cPSONK@Dd*GGGez^ng}+yAEvEjXDh)Pz^hm0y{`*tTIuW=w`qgFb=H6rGwD ze)&E>wOa8XAlaD(#h%HRt*V~3D^zvqW|`|KavA>2!n1TfVRK9ZT6n`?WAA#?(+AyP zn;xP4U{HaT^HUqlISKiNJ<1s{()La!fRBB3Ck z*iUI)KB6pC9*YXjVGn+!vT*A%6gqohPGc)e=4VwP2mG47M@ORpSEJ4>=84HNnELv3 z|JLmWMkZeD*P4!sVuN-|`<+Kc@^(2;J0XFOj*O@`!%{Y2DG< zdqUJ^jK^y3+RxFfV(V82?2-&;A>6&Mcq`Z8SN!Pc75+A$@Jdny>BOr&uss=@T$}S=+rVx_|G)+I9j7c(K zAZX^n7iM%+Amj|X^!LhRZC^^yNte4`l_1HYGf{^7q#rNG=k{VUA^Q#Dm~7;f&-#}* z1Zux&2I8b0bfvA{zm2bU=Af45f8=*R;X8dKWeQE8NdPa3W89@ae-NpL3E{=BkGB$! zL}oZf47(LN13xaWagNZrYe*pDAMfkqKWcWo65)TYF;V*J6BP4kYCQc47)jVEkA*{X zjyT8IB+99lQ1zE6;13$33F^$DP-;^ZK(Ui=zq=kd_hIp(`|m@QXU&jg800K@iA z=E+A$+m-T{IpG6oC^YwNT}4}L%KXubzWP7b41w>Ah@hlXHzLo>G_$5T&LI;E^ygX= zyrHx^zhuNnz1>uT+mg)qsFE(6QH3TKT}(bot!NzPC7o)Es`1GLY&dZJ+SFeRZk`k+ zB5|_R@TbrX%Ho1e9GmKh6;s~|li7(4m_dl?_`wAINI?^$SX7?!Nx&{#Oc$ft#p-y_ zlE&${4M$P1?+O&Rlv;)F$aPGIWcvOQ3szEDL95v_EgwvJ3~YVj3~6}?0!Tzd6LfvE z8CZ>8!K8={=Nm?#Ux$b;K$@NIZ!n#y@gWUfd}2dypOLH4y@o4$r9nbM%v}RrVtaJ9)6cQ-= zR1PqJh4G;1Y2VD0{d@>Ri{IEaF+Ymx6~8#r#4{{R#8q@%G&ZNhr!BBVrR!=*biE7p zAPe7!YTyUE-i5{5MEI$t$HM|wY?Ju4CaKrwW1T>=rT#sWdID^a1i>}4x^82pbt%Pb zKC~Mw)OXWz{W{kxqm=L3ERKgV9ePwW-{0|)uu&2fcLl7(CQ!4A)|_?=LiOE0VVZvF z@x{K{maYiO3iTZwED&AJ<|>Xmed7LKghMk`z5CkO>Nk{YvW9)$oU1~Y$46x+px}6( zamCH9Lg4xD)}tHdxhk^nZtIok-t*o*=Qr~bNE%(a6|*A})Vx$VgkjYc5uWiHE4D99 z^g4)@yfcV+yB)K_qx*JnqKELBIRJNVl9c%_A~OR#&45t6Fz2b(+%kk<v9?(Os$G9Mw^YHPY~^HYG0ggzdgAUwUee=nwxZFG&2YaI>Bb`U#>7yKm1)aqK(ITIM^)6?aDdK%Y>cc>1p@-yqx?;ko-UTUzI4~-Ipf7k&H_=-siP%g%~LVDxpHomsEX4V-B zlzd+NvH%yTXYqWj*E&8Fci}c{{Va4|5dZO><<-ayzV8|;I@-Rm*@Z_UJJm~NsL;Jt z+d>&vc>8?iImbL)b#`xdbpp$%VN{~yEZ_>0f zj~grMCTDyp08w3JkSQ6;-_Z(zRi54}Sy77LO0pNYgmB-KLt&%WvcSPz$g`}D`*${l z=(Zz4I=4KljK_;c+Bjq+;X&TUb>aG^!ndogx)tHKvm@KzF3+ zA5Q8`JQ&QrKILXK{_XqTk{4}q=hj!Ehso66y4$#MSE2Z~YI=w}wnL!t=6KmY8Eu94 z4VN-}Idfr4bcG`aY$%vg!<^D?y>@(07!jDXz1-T%N%Uvksbr!CpT$X-Rh_b8rAwwi zf3#?uZgL)WIg)n6T5ipwl%xXr*H#??JH1uqS6YjtYbj|7j_FCoenV30s)^LBQGtru zRPJKr3^09?X758Pc0=M6uyMt$GP9h*++YR8@X{eJkU(&&_(^k3V2kDN)>oYX20?*Y zkGBdPKpmQH%rHWk{9^`kUCtVa?^V7U9{7bAkuvGgN7~yl7Ix`<=;Q1e-cT0+!K5ZR zf(U*hgbn^Keu5V_b!H@N{&1f90ZCI*|0Ro!qeqP%D-~4hkuOcE>tjBp7Lk1>pAg86 z4aa@cPW&HgHK@ZU|SE8Q@j0gx=powvItixCU(_5-ex11T{W8v;ZcOkI1k zZEgDzIfeBY*|0V8K=17+-rTvPX}t1$4scNREAF#RlNX?jln|qvf=1Ryb6H)T`Ryke zCZT(Lf3`A`7AQo&`&D~fTJEGK8N`dZ@#Lw9@;L>+qB^&&jVfngT?jT5pY*1V*Bz%) zD;j(vlblgk9a)>!p{zX@Gi)`>6)i>AxewgZlWs9i@CYToA$J4qC0Pl?*13MNxCK7% ziWeK{LRrvG_NT`3&qLA1o3lX5MT6}H;ZJ*d2RoZpVyz_19mxCgPbnLsvSiUR%m91-=z9LW!hD5S?zJO z-r*VDAiXie4u~)jiN)7FG)F)mV;s!1u^$&_WPL`w<1kZ%{mR7d?=JAd>Nfs2v7`yafc=V~G*$-aYAm!1lGp&{#U$~~! zyVX%&&<~}WUz_-V4x|TGpjt`Zl1H_Q1l?lArcw%+!Z}uA5II#MdeVhM#k`KOb}Tcy{C7dA~tJSefzpL$?~Qv29F^F znIt9L;*s^*$MPcc;vW*uf=lUn{|ow7&hBzM&&e}SNJ)xb)8fPZ_$uE7flXhQmoxo= zPFLZf8On7)iVJfk%YNFLgB>AYcP7X*s!&E*;+MP;c5eH_wsRqOe{2PK4X(K&NMg!F zWqln8j{#N39hDT>pkh67aH%}{Eq=|7swL}5Zo0C&Ij$$;4#e_(QZo*REgIsiF0>P( zr7jG-Rq%Fnix>3A*nqI1$krHXOn9DKK<6h+1TQ18@rnslE*rNB=n5RsP=vE~G)Xwb zbnx;VZ7r=DccZIN9dH2ldA*Hnp(@vn_A%(y|E2a?wQpQGz{ShXM!U0kU!hHoG)U0R zNpL)Jbk!PXG1H)l4LW=LVKRMu>`H22i^T z;=c3}?$4Ukp-F2|X zDFdWQ=@GUg6P4?v3_v1y79vYri18219kO;kuvUzuv?@?04^G;JCy&DDXXNPGi`e^a z5T-oBYqGzlvrvrumN^qeOn5am&nCG3f3a&bh4|(1iC7bxr+g=n^~inf23P7F z+x~qeUuO^3(nbO=xx(NLVKyJ5vS2!YQ=r74Up-Dr$zDl#=ACfH?ToJi!T{TKIOs<6 zx~R&KQg?E<3dFdmJ}K0RI>xY4>Ks z(sQpm%?A@%_u!6zMTR~-rlfaZgU+h>z9tAL+5NfA8)4vR5D5f0?nMT3Z&qU|FmD!N^{|X&sIX|;K z`k9p|yLtR*idki3rn}Ka=H41;!|Nb^X(sDU5{d!D4~3e!d`?;h9Tufp*iHRzKsIG- zFYC`Izo_5gIsg4Bebw@0JKC~n^qBU2Pt>%RhvoaFRE*mj+?1kj@9qPfL1(VYuJL(_ z@f%-$SECbk!Eyk`eFv3(R;X9u_o;d<1jTkmGev~U47gCD95cNbaL`zhAh-}4BX8R9 zpihw!E%9Rv0VzZ%bsUM$|GLLc(>;6$t9rfUoj~%F`ovuQ!2^1Aw1dBzGpEJnZY)cu z^iKy&z0Urlvhef{!gJ@V1P7jLtsnn>LjDg-&5f=m@_U28B`Cunmh9rF)*mz8et#4} zc#ijbkEVhJqIh)c?D>q$$fZ-gl>j2czG-w0QW#&G7v4!}iPR%$SR}0Nyf?`sm<1yx zOvU6@;CokeA8+jc;@0gFW8S*I9E{mem<5~!BEF zcS?9JKvB}m$Ti=(ED0627V*t71O2*x-0 z2ru!I6z%xlA5LDZp7N;h>uLRojWisA-if{euISzWoZWizBW%iz0N{Xo<@@zxR7S)# zVpGoG^Fi#=bHDk?UmogW9<{ABPIO7%tc%W_xwrf;r=SV_2GYWLe~cX+uzZ&NDm?WW zik2mpiOnzu1wBp1o=suO(olghD*s_{f{s@;@lfBw7Oli*1b6xt(DiMC+oFVX!d)V> z`pcmiq2lmk9!a{iFd(jC{S3{uR2uo_#&_;XJtq&M#k36ml&%vX4F)Jz9Z_=>Sxj(< zJ#u}4e!DjIXq^PPe_VEL2;=wTBpF?aORc*TJ%#N;?s6^DTkz5E~=N$ zkUE}O^cAQ?)RptCgAYFqqm^ti$a1fk=q&0oKP(%4iV;rBlx1_H;ONn*_e8RcgTXy+dyc;d{?`H#Kw3q*4-BUM0b}zI!-hk}GT+y|~Z9!!9 z%+6}2h4*^x!OjM}7h1aHe=%O-kmIBWA~oZNk3Lgw%WQeqc_&Jq*xJpj*|b0DB$F>n z7l)bxC%aQB!|JK;K~Pym?YVxRZ+%)_w8<Jr<;eyE&552P_b1P2wJBPqgr@dV z>ubF`>)8K?3(04acgyIA3{qDdIA1i_-Dw)V?kxcAWS+^S%)rd zu@CVKI=$iLY&)QurVzvgOFw9%yOlU(lrc5Y-m3!k%SRonBe3|k?>1_#N654nv`2nZCW zkI9u;P^bI_TQ;~X(X&F_L9>G0P)(v+b93DxeAcJ{jhDTizC9D&Kfq>GGo{>qN(zbj?AG}loE=yj~il^1nS^&P#^^ewIDB4%I zkuIkE&42sD)86#BwD8YgW_zYAOvU2QAYbpPKPHjCO={jr^MEKdkvSCh5Iuwh-*$eV z^(o?otsf-mwR3hDoRy8qqCr50Ph|0fGFDM%z`5A3@~`_Y?9`SF37n@sGUN-1@AFFt z8y^WX&7JdlJgW2%QK-!y)8>@(VsxSAc<+0M$(iLUn=4$YF`)I!7k8c(`E?i8gx0_< z0)u#KT^4=Z{C~dI0`5qh!?)m>4(D0wISx$uLS>KO41wZVq~$oLjvU~7r}72GZxL|s z=6@8^f2&0`uDQRS+$a+|>mmnFv+Z_k{K&ze13vVDZvIBxx!C`{ z^z6?Hmg&l1`PcjzKVCfFbu5h)-0l?#Rx~dxA2h!)SM@H3a)xbCX(nvSad%9rfM*d| z@%{xEBz&Ljc7Z;wr-2Wd+E5z?h-^ApK8|M}Y#vtpU^v8HEo-UuIL1znlxXt)>dd?CPs<<5-)#3S#w zM$KYws?q&R6T4skM?X%|oc*rbl_l)>l~Djjlwl+5-897&2DGV5R{{Kp1ri>%`Vsko zX2rYhdjN1<3~d4uP6_p^_{JDWd}q!IY0@(UXmRzZbez9m!K*<$v>UmcKK9$M96ED0 z37AdSOf@PP=6`XpPV&3gfEVy}`8^bbpAzk|REdzuuBC2f5ZnUug z>N14Rwme8pX~mQI?AHCd4jABsBtZheHGcm~&`5BkV6Z_w;QY{Z%>v=M-vcJI{HPwQ-NC%Xo%g)#AuhgHzBq`n$ zWO&2c%4y}V8W2RT#@sc0yXy6LAc{O&Dg8;-pFu3u@gP9}q{FHsr{;%cbU0_gF3Fff zewO|2v0N=fx+=?)*YH|alU$pltaxTo;&!mEE4uN{wWAID>A?#nodS>wPNQS{0-yQJ9egS3l5)d#%0BVX7*R zfNImEUsZ8^&AlS)+91-{kbL?Z5dpeI&W*ownxMa?w8k>&V&rC1 zH>_-2uZ#Bu;eGX)LF`3ZPX1i{{5$XlPIQx8U#C#4+M|gdG!WhRA^a(cspS32dZglY z)e_B!@2)SoR2KO%Ix0Cc#uTn@YBxz!XTjxCD8;s7FTd{;pu@DHH}eSzVDS|>Fnn;D zv@Wl1-*Ti6cMLTawse07TR$y??X)rIy#~tu>2Q6Y5-7RG;h}%qJ}GXOk`9YmaSd@Z zs2`L$546`%wQ4p0?S*NnE@1F=T z9RxpE$tXc^jhFZs&}m_zPP#WDjMJr~h^IQs#O&bUIELa^e5N@sfS63I$ro@8*IG9z z#>D=TGTVJVrr_MLd#I`Lb&13P7oX_#n;%Vb+N@v|W#tdsBHs=vT&rAXb*K$^5o|Nb zzIz>CN$A+bnwBIrCe*r93|T^X^KaW{hE?xc&StENTL)ZOev(iOyFtItKG!>lu*2xa zibMn6*E>cBB#3UswzwLW43QpS=?%X+N-lMs+Mo>2rR9Px{;bTGu}W`kb=|QT2)$Vs zsZ(jVaCKGt2|MjMsWdaH3}3Hr^?w$ly%ofdzq^LkXczGMME;V8A7A}e9J{B-JA|`Z%6!ALK!$sXW@pdCN!P+9 z40|lS6U8WdW{GK9{S9;iar+||WZdJZ-^ngzIs-ZvVJy2(dZT&OMnn4bIbJ2EZI88> ze-up1(H7V`pjxvKA}4N%I_X(o(DU|+n>^OUYbUBQuy0nE$ZrY0QR@*RoKc-hx(cT} zmb})&I{#Zc`#eKKg?kUy32X+-0gN78&#_xwMve>0Fa&$Hm# zf)eUeanrISAenvUQ#Ry0?K;=-4g>a0NU!;yz9+8F=TxalJcPb)(%)nvz0s++@#7mG zbm4^uV9K%q%Qu6;tRAn4XfKQQKQgjBXJt#N-|~Au-J>k+9i}wNG^K&Yeoml92i~i? zNkKUDdng<7`S+w2B>U=bSdG`QacYU-m}$NAA^gOAMVxV7@+dK3i_e>l;0?!CeQf4W zE`rs*^l*A!!1+%|1ZaFj-48>)1GxEXD#R zg>&l1U33`zE%r4hqkF-Il-WCXM+YVt)xT7-8}TNO&~I@~R?kK?oYNzuPBjA1cgqz= zjnw-TAW~&K@2fz?+hoPaUV*$pWWJ*%)T0bn5f7C&7hDTxA;>Rkz&}j}8iINKTLj+l zoq&_JmW1WMCtK)!bml*_64((xYaf`aTa5(|inmcZ+_sX@AIv!hfJaHQB=W8ftM%1zm ztzBE*v3r`Sk(QxfojDc#pV+;rT4dI`!{gHp^vXXC#w zgx`@@oZK6#d|gvtd@LLX^RSo@UpEmobSG%BgtdloSk4bH-;$s|frrAb50`!p;Het1 ziLkZ~rOSo>f{in+`SppHxx-HSgCTW}UxBMdS~hV~&u#AI2Sh;m;Cg)gv@tK^@y6`sWyf1Y3AMf=oe1zquWt!X>| zEFdbEceHntolL3vQ^}B-{D=OQf6R0HR_c{w*GCt863c3h-&Ewbg}?-RYWmy^-l|00 zN-XGbz+Qn1nV*Jal{%Ijxi~G4S!|O9zHOksY6<=+B zL=-7q^q&4$m5_}oy*&LGG;GnH@got&KBuyO`e=TEI`hi4ki{NqG3(lmeP-{+IS$29 z+;c=@{>_j=TkC)pxnc2Id*H^chCMx>2bB@RhKBvdzC%&o(O@-Cj(fM{ME`^Xre{$F2 zXAXUi3t?4kA7K<9lkz!-*m@v?`aHcV`|(rHi1arjDDO7|*)piaG*+PLWTkn*u&BZ= z@WblkuME+?-+k2re-!OkSy9M;cT|Q2!!%cR@RrdI1Qky2XV>c%=waW2po_A>Q&Y?y ze-@tTtxlG#vU0I+yv_AMyVF^Fnm)7`oD+4b>K-ycq$n8!e{m;AL7qb)r3mWd_2j!% zz8u}`6Q-Wgy6aDuA!Wx8FE}0N3Q-oZY+==xAKuSSb55=PQngrAaqReYNacpaO5D9)b*rA4_Bojg;w;M_^lSxbAtpHTAi7B_VPY!R zy)~Wm)y8r`yQ9WE$n-TbFG}P#p)=8`vtCjtZ`jqnb}=!HO)A| zxL=^{X{^8Drw558YyG7&jPunPCm!Ovn}-i`%NQz~?a3&|&t&e}S=BQVigWyN52~0` z(=cEQqaHx~v#O&iqf2yFl!iyW&_LQy&R4A1Z;saAy_2V+oujs#%P;KydrqXLAZm|% z$PHCDt? z(#|dGR$$LzFHkB^&HY~225W>@Z$R)_>6aB+9LeQ4WX^b8GXy-x}or-We zEhGP}C-jv!;Eib2KLV&{E)TgKVod$SIuu;KD^$fS#MoZFhL5Ujm%6xH_vDNI)<6_m zLEl>J_30M-nCHqO_4&TT0qOd^7ds>^=cI{R`w~$K;Ue8@gR$159PU7s+Z7zX)(@c% zg5&N{od(rYvozfU*+}}VJ^<-h@Tc7!HvqS9{Os`)Wtv*4`W}eE^t1Q8mC;LeBpO{H+Z9pzTHBNUm&371=ht zxL@QZS37755dSFOw~*S?Y%O+T)a{KZoq}P>;1;HJ{~o-(TB&EY&HAl|I(Ky z0YQ=@yV})ig;(ZRqdBA#?0Du?F1X{Z1xf?O0VAJt&F96B#Se3?-a)lt7ZP>Rw$Q#) zOzu2*T{!=T8z?3R`uKtjY=>%h=`qd_3yQwE-Zt zoH_4OIbrpinmt;1;XsP(uka+5!TkDcZMM<2p_oB zZ2pVFO;bRbLN`z+k_#FqPkj?P#rpQiVsTi8vhyu8e9|LIjRSwIyJxE+*4Zf56Yaj` zIcX0>{(+Q=jlTNOI^ELw7A)Nj<8Mt{wilm2tsZyg;dGP>xo=-`8rU`FIa3TTmJ6$J zE3J$nZsC`Wu1$(<@QafV^E8_OsX?Fi1|nQGC4S9*;MR+BBNxYO zJwj)I$d!E^<~p5Me<|bmEuNHnMENGRC5Bkq!RfL!;)Rk&d3NhL%lH;bj9C@LW|)nI znq;3JeVaV8$_Y|_zbQyxvwZBjgH5`OcK@JX;=af}dFb>lcWbWV6NE{=8pXo;A^L{~ zfe_@lf`gSDXqQC887>|!+z>;y_K%VNeMizPIsd8`I^OhllposmQ~mhEpQ^w1ye&<|jz-&IHc`6lW;Q$|vciHe%DBSC`Ke7T*Kv4#{6hWelf6Z-m zVR~ia28hGE28pL-L%&`>jP}3jL{*KP;k9d=l049y+Lj|-1<&`d_l^nm*{Wq%;Lfk- zkR9#Qm2Ds>-O{sPG1%H*w{vi1Bnfl-@gYzfIOr|UxD~^PZSS?-vJZPt_k~~>7$r4* zLpCPSi-n&9F1*>TM2{volM7X;<5N;|PSIsD1of@so<(|C9VAzP+Ud0Y3Fn}8yR$Z*d5T_y`b)|iaOBLIWyYr6+Fizv ziEd>K*tl`e5kS#tO9wCxIzF@$u&C6$>wRG-XG-&Pq`Fa>$B1k0hAuS|YuAqqYB*6i zfnrZq?P0Z0oZr7SAllC8E~*j)FN$<+jwIfs(<%$*(h+J$tn?!F&BUH6_1!JkbZ3$S zHvvWu5Ptj+WMNfj@v%_vigoN-Lbct)CmXA1SZ-UUqjuTn7d~y1yUzA83%d(bR8^=o zUBBuxo!sc%^P1CV2*E=h6K#5GqkU8+jVx5oN8$pdwWlyq3()ERzn9q!JMbP(#MO^c zddyeD*)L=(JHk?EI6rdY z`UM=@JG_^pYFXR&_$77#TGF$+{~P`OdCT6W#c+dTKe>D7cg()WFEpif!e=wVy@t-6 zl{1uqZNs0X2a@8OweqFn+<#DxSq${Lhg;mM(Pca6r~H-;RvbG3Oxfs!R3ng|qhv`K za#88lfnajQlV{i&HH#m~C=DgCX%L1ei}%#CSMKy~1a#hW!EeqKY%-Kn)H=utEi{9r f6s>m_pm=zu1wef%gD7D-Mf>S$8)}tlya@R}d`6Qg 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 08998978b21b0f906eb46a983e8d7ef1e0d87441 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:14:08 +0200 Subject: [PATCH 07/93] 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 1dc36257dd61249d8789d513604b347061ea40ba Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:15:59 +0200 Subject: [PATCH 08/93] 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 453afc0a59b78f2910850dfa347c9c5cbe4b413e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:18:09 +0200 Subject: [PATCH 09/93] 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 442f2e472223d4177d4b49bfca101485c03f60cf Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:21:17 +0200 Subject: [PATCH 10/93] 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 1de2ce32bd79271048f4f4c1d3764c6eb92967cd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:28:53 +0200 Subject: [PATCH 11/93] 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 2b2bafb70f67c9b30f8ea78299e84b92a8dcb7a4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:30:30 +0200 Subject: [PATCH 12/93] 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 efdf6ae85cc5a743f4fdf2b2e87d3f8bd31d659b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:32:00 +0200 Subject: [PATCH 13/93] 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 4d590951eb2f98f4ae3ec7ea4d92a18a89daae40 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:32:40 +0200 Subject: [PATCH 14/93] 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 a289f7903c6e5653774d3d5ff7fae5256dd70f3a Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:34:10 +0200 Subject: [PATCH 15/93] 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 88ea97a8600877bcb2961d7a55f501629afa9cae Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:35:19 +0200 Subject: [PATCH 16/93] 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 25b1a24eafeb5ce3969cde091c84245b338147bc Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:42:37 +0200 Subject: [PATCH 17/93] 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 18eae6458037ffc1d1148617e9b3b27f7751cab0 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:43:47 +0200 Subject: [PATCH 18/93] 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 b5a5dc53610277cf0a8e54ac710222c1a600128b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:44:49 +0200 Subject: [PATCH 19/93] 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 81055b3f2a41e61b3b4941ee639b2576334d1e01 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:45:40 +0200 Subject: [PATCH 20/93] 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 ef9f53ba4ffa776753e812b4461af2450594fa54 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:45:52 +0200 Subject: [PATCH 21/93] 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 a14d66a708fae98603dcd50fb3d0abca20bfb456 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:52:20 +0200 Subject: [PATCH 22/93] 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 1500ef72d4b7b947a54b9b8876398b10e5fd753e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:54:05 +0200 Subject: [PATCH 23/93] 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 722c6586e6fd7fad6edd30b1cc599ab6dbf948c0 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 15:57:20 +0200 Subject: [PATCH 24/93] 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 cf1a57e92ff6cb29ff21e9a8ae40211de1536af1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:00:08 +0200 Subject: [PATCH 25/93] 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 26/93] 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 9cb8f5a131b14b7b00bbfd37f4661116eb37810d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:19:50 +0200 Subject: [PATCH 27/93] 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 5ddd2be755f5fef5e2c29b74bc09ca4400a354d3 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:20:28 +0200 Subject: [PATCH 28/93] 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 fb34025a85c4468aaf7b85fe16991fb75468743c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:21:15 +0200 Subject: [PATCH 29/93] 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 ba05ad4ffe04ccad2f0a95feb65073d47ddd46d1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:21:43 +0200 Subject: [PATCH 30/93] 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 9ad999d2a14f69fd75480d5cfcff9432d976b42b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:28:11 +0200 Subject: [PATCH 31/93] 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 0e05efa7f0f938529d011b7e6bf3c306a3c1cbc1 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:30:11 +0200 Subject: [PATCH 32/93] 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 53a3b21e7fbe93b723ec7d0828b55ef3e25dfb0e Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 22 May 2026 16:51:32 +0200 Subject: [PATCH 33/93] 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 68520f66c6d4cd818c0b59abec02f780faef8ecd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 15:14:12 +0200 Subject: [PATCH 34/93] 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 6c2e382369a0dbab5f9c21350a94521a73960c5b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 15:53:04 +0200 Subject: [PATCH 35/93] 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 384a9e7fc504c2c26ec18d73cb3116b2e89d0f08 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 26 May 2026 16:26:48 +0200 Subject: [PATCH 36/93] 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 244dbcc76bfc8505048af1ac1ee98a8f33b45130 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 11:44:07 +0200 Subject: [PATCH 37/93] 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 80892ef52d28dca1bef9a576ed04e0e75e38e229 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 12:16:01 +0200 Subject: [PATCH 38/93] 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 1a837cd095ba522abbd266816c375d1d5ddde9f1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:46:00 +0200 Subject: [PATCH 39/93] 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 652c0ab5b8bcc35d5fb48f41fc758bc298ccb591 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:47:43 +0200 Subject: [PATCH 40/93] 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 a2895b480af2492f249fad11baceb9bb61b3ee21 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:49:16 +0200 Subject: [PATCH 41/93] 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 0b9a936b401decf549f717d1c1a8bc42408fde5c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:52:20 +0200 Subject: [PATCH 42/93] 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 4dc4dd5ceeffc549bd2ea64a0aa5665de3b61e07 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:54:34 +0200 Subject: [PATCH 43/93] 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 58118f52b1a680a50a3fac428163a7dd6f373c1d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 13:56:32 +0200 Subject: [PATCH 44/93] 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 6bcc6bbd14e821c82ca0ce6af181b3470f7931e2 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:01:23 +0200 Subject: [PATCH 45/93] 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 30eaec9c6e4d23b482f5963ce2fb774c74d336a3 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:04:10 +0200 Subject: [PATCH 46/93] 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 b1721c22091873c54fb9076de71e374e86c581fe Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:08:04 +0200 Subject: [PATCH 47/93] 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 6f747e7decc43fc1e300cb59b3ec72d175c793c9 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:23:02 +0200 Subject: [PATCH 48/93] 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 49ffa62c154f89903e9bd1490a495b75f776678f Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 14:34:42 +0200 Subject: [PATCH 49/93] 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 47bb6c24464f2f82573e7919ec6afacde3c69812 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 15:05:33 +0200 Subject: [PATCH 50/93] 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 99a54b949214116298eba24c02670f6f2f91355b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 16:51:28 +0200 Subject: [PATCH 51/93] 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 49e44d3237d4e3dc6538a8dabade1f9d6749b3c9 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 16:58:22 +0200 Subject: [PATCH 52/93] 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 713e33511c0285f0b7ff5920751cc0df1e40c95c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:00:31 +0200 Subject: [PATCH 53/93] 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 db95b7d6d3b4dc7c62f6e840d28e89af7004808a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:08:28 +0200 Subject: [PATCH 54/93] 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 9f29dc30cf9fb6db52dfbbdd1579d10feecaa86b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:11:05 +0200 Subject: [PATCH 55/93] 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 61e0026db86cab9632aa99e0596c2d64b2e112e5 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:11:59 +0200 Subject: [PATCH 56/93] 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 65e97db1594b63ba315a886e23ec6af0a7fcac32 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 17:54:44 +0200 Subject: [PATCH 57/93] 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 e398ccffb0eaa36f9e58bd5d5f523404e6f2cfb7 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:21:28 +0200 Subject: [PATCH 58/93] 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 f7e6778119f73e349631d172473830d695d92bb4 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:24:54 +0200 Subject: [PATCH 59/93] 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 a046317d4bc587d7e516b31bb8ac4aa836e44d31 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:30:12 +0200 Subject: [PATCH 60/93] 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 6e5d2b8bfcf09cd0d28ef8b0ee4a20031bd11ea1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:33:33 +0200 Subject: [PATCH 61/93] 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 8a5afeae5d808ee5552480c158c190840781fe86 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:36:07 +0200 Subject: [PATCH 62/93] 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 1f57e924715ca604996927f2eb973cd23c917cbf Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:46:36 +0200 Subject: [PATCH 63/93] 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 dd76d166f571e29616bca5fa3c18013c59a24c76 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:56:48 +0200 Subject: [PATCH 64/93] 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 9d4ed0dea31a3fb4d3b539d4e9bd36a6f22a9f33 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 18:59:21 +0200 Subject: [PATCH 65/93] 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 06f820b018195c6e785ad2424ca2382bb7130ea0 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:01:15 +0200 Subject: [PATCH 66/93] 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 cbee591bc330f84aead6c67d29dba3797a033180 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:13:48 +0200 Subject: [PATCH 67/93] 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 308b3d34c74f2d468347e7c5bd6bb6d0f4404a5d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:16:32 +0200 Subject: [PATCH 68/93] 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 76fadb324c6e2c183ea2c02dbdeafa0827d97bf9 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:19:26 +0200 Subject: [PATCH 69/93] 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 13464e6810ce796f809561988dbbd5d4318dcf1d Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:21:20 +0200 Subject: [PATCH 70/93] 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 a5a75ce9d5d31b61fdac6f4efbc294e38ee2b9ed Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:25:03 +0200 Subject: [PATCH 71/93] 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 28fa9c377fa97ff6069daad4c5138cca5886de0c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:28:33 +0200 Subject: [PATCH 72/93] 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 1d7fc9710a8a3f41295294e7c88c37b80734030c Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:32:34 +0200 Subject: [PATCH 73/93] 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 a9566276f6b6b831c9494be269949e85c7e77623 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:42:44 +0200 Subject: [PATCH 74/93] 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 a3530c9b63dd6aa079aefa73619196b5002f632a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:50:26 +0200 Subject: [PATCH 75/93] 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 0a9fdd1547166c69db60a7a44e77a0eee7da2ada Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:54:11 +0200 Subject: [PATCH 76/93] 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 fc797e560ca99fa3271d4a10cf5066674ff97851 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:54:52 +0200 Subject: [PATCH 77/93] 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 b8916d33a7ac6767dc786d7d9f9c9aee8fb783c1 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:56:11 +0200 Subject: [PATCH 78/93] 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 c49f330350da9f60cf318c855758ed19f2d76412 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:57:00 +0200 Subject: [PATCH 79/93] 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 97e66b8e97f9689ca034cf57bd746b0b8d68991e Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:58:21 +0200 Subject: [PATCH 80/93] 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 5035485b4de449253ca0d787908476bbbd685aea Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 19:58:47 +0200 Subject: [PATCH 81/93] 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 05d56360b9f9ab40e8cb8443d9efe12df417ddc9 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 20:48:01 +0200 Subject: [PATCH 82/93] 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 05cb25fe1224221986b8cece5ede8a514ae20c45 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:09:57 +0200 Subject: [PATCH 83/93] 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 fa8646fd4b8e76442fc0fa8ac32c560e401c6368 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:12:38 +0200 Subject: [PATCH 84/93] 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 c0f0f77025da555c7b203c18b5f988ab032e0a46 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:14:54 +0200 Subject: [PATCH 85/93] 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 8ecce7035a7945f4f1ae4668b4a722e188bcf389 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:15:46 +0200 Subject: [PATCH 86/93] 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 a9e18efa3cb996db9ccce0221c57cb5045243b65 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:17:56 +0200 Subject: [PATCH 87/93] 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 e40f453014fcc6f5fb3aa9011dd87753c82e4d3a Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:27:17 +0200 Subject: [PATCH 88/93] 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 93507ca1be8171e91f9fbe4f2c7cafe207cb6b52 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:29:01 +0200 Subject: [PATCH 89/93] 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 68f51402634da3914cf744aa584bdee5a6805bf4 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:30:13 +0200 Subject: [PATCH 90/93] 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 c6ed3354db11b7b1d0f1344a105c0bf20d73f7a9 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:31:06 +0200 Subject: [PATCH 91/93] 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 ccc9da0155d92b98d18ddcb1fca0ae3d7ef35b8b Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:31:45 +0200 Subject: [PATCH 92/93] 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 e1f896a7c193cc4a65864a1fb51c7c5be8519c35 Mon Sep 17 00:00:00 2001 From: Lu Nelson <ln@hash.ai> Date: Wed, 27 May 2026 21:33:00 +0200 Subject: [PATCH 93/93] 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,