diff --git a/memory/SPEC.md b/memory/SPEC.md index 61dd7511..688c10ed 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -132,7 +132,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - Tooling exception: the worktree helper extension now lives outside this repository under the user Pi agent tree (`~/.pi/agent/extensions/worktree/index.ts`) for direct Pi sessions only. It is not a Brunch product extension, is not imported by `src/.pi/brunch-pi-extensions.ts`, and does not weaken the sealed Brunch Pi settings/extensions boundary; Brunch-launched product sessions continue to disable ambient `.pi/` discovery unless deliberately imported. The extension may register direct-Pi `/worktree:switch` / `switch_worktree` and `/worktree:create` / `create_worktree` affordances, but Brunch does not test, package, or document it as a product extension. - **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection, `src/projections/session/runtime-policy.ts` owns operational-mode/role policy plus shared grade legality tables, and `src/projections/session/affordances.ts` owns the pure `(resolvedState, readinessGrade) → legal options + default-on-switch` derivation for goal/strategy/lens. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). **Posture switches (durable `reason: "switch"` entries) are a user/system authority: the foreground agent never emits a posture switch.** The agent's only in-axis freedom is `AUTO` (per-turn implicit selection from the D58-L manifest); what it actually chose each turn is legible downstream via per-emission facet stamping (D25-L), not via runtime-state — so runtime-state is the *frame/constraints* while emitted facets carry the agent's per-turn choice. User-mutable axes are `op_mode`, `strategy`, and `lens`; `goal` is internal/grade-derived and not part of the user posture-change surface for now (D59-L). On a parent switch that invalidates a child axis, the child defaults to `AUTO`. The `source: "agent"` entry value is reserved — no current path emits it; it is parked for a future execute-mode orchestrator that might legitimately steer sub-postures. `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. - **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/commands/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 value 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 discovered project name, selected spec, and real activated session id/label, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. 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 product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. +- **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 value 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 discovered project name, selected spec, real activated session id/label, launch activation kind for new-session startup headers, and app-supplied live sidecar URL when present, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. New `newSpec` / `newSession` launches keep Pi `quietStartup` but install a Brunch-owned expandable header through the chrome wrapper; resume/open launches stay quiet. 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 sidecar/widget-compatible string arrays and title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. - **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, query bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`, and `db/` may import the drizzle-free taxonomy leaf `graph/schema/kinds.ts` — the single sanctioned `db/`→`graph/` edge (D73-L); `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Refined by: D73-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. - **D73-L — Domain enum taxonomy is owned by a drizzle-free `src/graph/schema/kinds.ts` leaf; `db/` is a consumer, not the source.** The closed enum `const` arrays that define graph vocabulary — node kinds (`INTENT_KINDS`, `ORACLE_KINDS`, `DESIGN_KINDS`, `PLAN_KINDS`), `NODE_PLANES` (`intent`/`oracle`/`design`/`plan`), `NODE_BASES`, `EDGE_CATEGORIES`, `EDGE_STANCES`, `READINESS_GRADES`, `READINESS_BANDS`, `LENS_AFFINITIES`, `ELICITATION_BACKLOG_STATUSES` — live in `graph/schema/kinds.ts`, a pure constants leaf that imports nothing (no drizzle, no `graph/atoms`). Both `db/schema.ts` (for `text({ enum })` column constraints, including the previously-inlined `plane` columns) and `graph/` domain modules import the arrays from this leaf; `graph/index.ts` re-exports them from the leaf so non-graph layers still avoid importing `db/` directly (I26-L). Derivations stay where they are read: `NODE_KIND_METADATA`, `formatGraphNodeCode`, `parseGraphNodeCode`, and `intentKindCategory` remain in `graph/schema/nodes.ts` (D62-L). The motivating defect: because `db/schema.ts` eagerly evaluates `sqliteTable(...)` and `verbatimModuleSyntax` emits even type-only imports at runtime, any value-import path from `web/` into the old taxonomy location pulled Drizzle into the browser bundle. Locating taxonomy in a drizzle-free leaf makes the `web/` build target structurally Drizzle-free (I44-L) and corrects the ownership direction so the domain, not the persistence layer, owns its vocabulary. Depends on: D16-L, D52-L, D54-L, D62-L, D63-L, D64-L; I26-L. Supersedes: `db/schema.ts` owning the shared enum `const` arrays and the "enum literals flow outward from `db/schema.ts`" posture; the triplicated inline `['intent','oracle','design','plan']` plane literals. @@ -258,9 +258,9 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D67-L — Brunch tracks the latest pi release; dev iterates against pi source via a gated runtime alias.** Brunch keeps `@earendil-works/pi-*` current with upstream rather than pinning to an old line; version bumps are routine adaptation work, not deferred migrations. Local vite/vitest development aliases `@earendil-works/pi-ai`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-tui`, and `@earendil-works/pi-coding-agent` to the sibling `pi-mono` `src/` checkout via an explicit `PI_SOURCE` runtime flag so cross-package iteration needs no rebuild in those loops; published builds, TypeScript, editors, and default runtime resolve the normal installed `dist`. Base `tsconfig.json` deliberately carries no pi source `paths` because paths cannot be env-gated; if a `tsx` real-provider loop later needs no-rebuild pi source, add an opt-in `tsconfig.dev.json` rather than weakening the default. Inaugural bump: `^0.75.5 → 0.79.0`. Depends on: A25-L, D39-L. Supersedes: pinning Brunch to a fixed older pi line, treating pi upgrades as discrete migration projects, or making a personal source checkout the unconditional type/default resolution path. - **D68-L — Development feedback loops are first-class DX, consolidated behind one front door, distinct from product-verification probes.** Brunch maintains three named developer loops: (1) **faux loop** — deterministic, in-process `AgentSession` over the pi faux provider + `.inMemory()` services, the inner/middle-loop substrate for wrapper logic and regressions; (2) **real-provider TUI/CLI loop** — `tsx`-run Brunch source against a live model for interactive use, with pi-source resolution opt-in per D67-L only when needed; (3) **introspection loop** — real provider plus payload/manifest capture (D69-L). These loops live behind a single consolidated dev front door (`src/dev/`) that owns the dev launchers and the shared faux-harness factory; ad hoc per-file faux setup is absorbed into that factory. The dev loops are the *means of building and iterating on* Brunch and are distinct from `src/probes/` **probe runs**, which are durable *product-verification* artifacts (`.fixtures/runs/`, `docs/architecture/probes-and-transcripts.md`); where a dev loop produces durable evidence it does so as a probe run rather than a parallel artifact path. Depends on: D39-L, D67-L; the probe/transcript model. Supersedes: scattered, unnamed dev-iteration scripts and ad hoc faux-provider wiring as the wrapper's test substrate. -- **D69-L — Agent-input introspection is one read-only, dev-gated Brunch extension; mechanical and conversational modes are separate planes.** A single Brunch-owned extension family, wired through `brunch-pi-extensions.ts` (never ambient discovery), provides **mechanical** introspection two ways: (a) a passive `before_provider_request`/`before_agent_start` tap that records *exactly the final payload the model receives* (system prompt, tool JSON schemas, D58-L prompt-resource manifest), and (b) an on-demand `/introspect` command that reports the **base** system-prompt inputs via `ctx.getSystemPromptOptions()` (base inputs only — `getSystemPromptOptions` returns pi's `_baseSystemPromptOptions`, so it does *not* reflect later `before_agent_start`/`before_provider_request` mutations) and the latest passive capture. The extension returns every payload unchanged so it observes but never shapes product behavior (D39-L sealing); because `before_provider_request` is a registration-ordered transformation chain in pi, the introspection tap must be registered *after* all Brunch prompt/tool/policy mutators to record the post-mutation payload. **Conversational** introspection is the sibling read-only query-tool plane: under the same `BRUNCH_DEV`/`introspection.enabled` gate, `brunch_session_query` reads `ctx.sessionManager.getBranch()` and `brunch_introspect_query` reads the captured provider payload plus base prompt options. Both tools project exact values with the shared capped dot/`[n]`/`[*]` grammar, truncate/spill large output, and rely on the agent's normal chat reply to echo/discuss the returned bytes. The D40-L active-tool allow-list explicitly unions this dev query-tool set only when the factory's dev gate is on, subtracts blocked tools, and intersects registered tools; registration alone is not advertisement. Tool-description nudges are the only prompt surface; no product prompt resource or fixed self-report schema is added. Subjective live interrogation remains an injected turn driven from the dev front-door launcher (`session.prompt`) or typed interactively, not a separate slash command. Captured scratch runs still write under `.fixtures/scratch/introspection//` (D70-L) so "what was sent" and "how the model read it" stay correlated. The launcher performs no global environment mutation; any future real-provider offline-default lift belongs at the session-construction site with save/restore scoping (D71-L). Direct diagnostic for the "Prompt-resource discretionary loading" blind spot (I38-L). Depends on: D39-L, D40-L, D58-L, D68-L, D70-L; I38-L. Supersedes: treating "how the model sees our tools/skills" as an outer-loop-only, non-instrumented concern, and the fixed structured self-report schema as the default conversational surface. +- **D69-L — Agent-input introspection is one read-only, dev-gated Brunch extension; mechanical and conversational modes are separate planes.** A single Brunch-owned extension family, wired through `brunch-pi-extensions.ts` (never ambient discovery), provides **mechanical** introspection two ways: (a) a passive `before_provider_request`/`before_agent_start` tap that records *exactly the final payload the model receives* (system prompt, tool JSON schemas, D58-L prompt-resource manifest), and (b) an on-demand `/introspect` command that reports the **base** system-prompt inputs via `ctx.getSystemPromptOptions()` (base inputs only — `getSystemPromptOptions` returns pi's `_baseSystemPromptOptions`, so it does *not* reflect later `before_agent_start`/`before_provider_request` mutations) and the latest passive capture. The extension returns every payload unchanged so it observes but never shapes product behavior (D39-L sealing); because `before_provider_request` is a registration-ordered transformation chain in pi, the introspection tap must be registered *after* all Brunch prompt/tool/policy mutators to record the post-mutation payload. **Conversational** introspection is the sibling read-only query-tool plane: under the same `BRUNCH_DEV`/`introspection.enabled` gate, `brunch_session_query` reads `ctx.sessionManager.getBranch()` and `brunch_introspect_query` reads the captured provider payload plus base prompt options. Both tools project exact values with the shared capped dot/`[n]`/`[*]` grammar, truncate/spill large output, and rely on the agent's normal chat reply to echo/discuss the returned bytes. The D40-L active-tool allow-list explicitly unions this dev query-tool set only when the factory's dev gate is on, subtracts blocked tools, and intersects registered tools; registration alone is not advertisement. Tool-description nudges are the only prompt surface; no product prompt resource or fixed self-report schema is added. Subjective live interrogation remains an injected turn driven from the dev front-door launcher (`session.prompt`) or typed interactively, not a separate slash command. Captured scratch runs still write under `.fixtures/scratch/introspection//` (D70-L) so "what was sent" and "how the model read it" stay correlated. The launcher performs no global environment mutation; real TUI launches keep Pi startup update suppression scoped at the session-construction site with save/restore scoping (D71-L). Direct diagnostic for the "Prompt-resource discretionary loading" blind spot (I38-L). Depends on: D39-L, D40-L, D58-L, D68-L, D70-L; I38-L. Supersedes: treating "how the model sees our tools/skills" as an outer-loop-only, non-instrumented concern, and the fixed structured self-report schema as the default conversational surface. - **D70-L — `.fixtures/` is a four-role tree (seeds / workbenches / runs / scratch); dev-loop artifacts decouple operating-cwd from artifact-root.** `.fixtures/` separates four lifecycles, each with its own git policy: **`seeds/`** — tracked, reusable explicit-basis starting truth loaded via `npm run seed` (INPUT); **`workbenches/`** — launchable Brunch workspaces whose `.brunch/` is gitignored local state (the directories a dev `--cwd` targets, D71-L); **`runs/`** — tracked, *curated/promoted* probe evidence under `//`, probe-first per D68-L (EVIDENCE); **`scratch/`** — gitignored, ephemeral live dev-loop output under `//` (SCRATCH). Dev launchers (faux/introspection) must resolve their artifact root to the package-relative repo `.fixtures/scratch/`, **not** to the operating `cwd` — the same operating-cwd-vs-`fixtureRoot` decoupling the probe layer already uses (`mkdtemp` ephemeral cwd + repo-resolved `fixtureRoot`). This removes the `join(cwd, '.fixtures', …)` nesting defect where launching against a workbench would write `/.fixtures/…`. An exploratory scratch run becomes durable evidence only by explicit promotion (move `scratch///` → `runs///`, then track it), keeping curated `runs/` clean. `.fixtures/scratch/` is the chosen scratch home (over reusing `tmp/`) so promotion is a move within one tree. Depends on: D52-L, D68-L; the probe/transcript model. Supersedes: pinning dev-run artifacts to the operating cwd; treating all `.fixtures/runs/` output as tracked evidence; leaving the `workbenches/` role undocumented. -- **D71-L — One `BRUNCH_DEV` switch gates all dev affordances; the main CLI accepts `--cwd`; introspection is present-but-dead in prod.** The over-specific `BRUNCH_DEV_RPC` env var is generalized to a single `BRUNCH_DEV` switch that, when set, enables every dev affordance at once: dev RPC methods (`dev.*`), registration of the read-only introspection extension (D69-L), routing of dev-loop artifacts to `.fixtures/scratch/` (D70-L), and the scoped real-provider offline-default lift. `runBrunchCli` parses a `--cwd ` flag (defaulting to `process.cwd()`) so a dev session can target a `.fixtures/workbenches/` workspace without `cd`. Two independent prod-safety gates hold: (1) `src/dev/**` is build-excluded by `tsconfig.build.json`, so launchers/harness/alias never ship; (2) the introspection extension, though compiled into `dist` under `src/.pi/`, only *registers* when `createBrunchPiExtensions(..., { introspection: { enabled } })` opts in — and the TUI call site sets `enabled` from `BRUNCH_DEV` only, so absent the switch it is present-but-dead, never wired, honoring D39-L explicit-opt-in sealing (no ambient discovery). The offline-default lift is scoped with save/restore at the session-construction site, never a naked global `process.env` mutation. Depends on: D39-L, D67-L, D68-L, D69-L, D70-L. Supersedes: the `BRUNCH_DEV_RPC`-only dev gate; relying on the operating cwd to choose the dev workspace; the assumption that the introspection extension needs build-exclusion (runtime opt-in suffices). +- **D71-L — One `BRUNCH_DEV` switch gates all dev affordances; the main CLI accepts `--cwd`; introspection is present-but-dead in prod.** The over-specific `BRUNCH_DEV_RPC` env var is generalized to a single `BRUNCH_DEV` switch that, when set, enables dev affordances together: dev RPC methods (`dev.*`), registration of the read-only introspection extension (D69-L), and routing of dev-loop artifacts to `.fixtures/scratch/` (D70-L). `runBrunchCli` parses a `--cwd ` flag (defaulting to `process.cwd()`) so a dev session can target a `.fixtures/workbenches/` workspace without `cd`. Two independent prod-safety gates hold: (1) `src/dev/**` is build-excluded by `tsconfig.build.json`, so launchers/harness/alias never ship; (2) the introspection extension, though compiled into `dist` under `src/.pi/`, only *registers* when `createBrunchPiExtensions(..., { introspection: { enabled } })` opts in — and the TUI call site sets `enabled` from `BRUNCH_DEV` only, so absent the switch it is present-but-dead, never wired, honoring D39-L explicit-opt-in sealing (no ambient discovery). Brunch-launched TUI sessions keep Pi startup update suppression on in both product and `BRUNCH_DEV` runs by scoping `PI_OFFLINE=1` through `InteractiveMode.run()` unless the user already set a value; prior `PI_OFFLINE` / `PI_SKIP_VERSION_CHECK` state is restored in `finally`, never as a leaked global `process.env` mutation. Depends on: D39-L, D67-L, D68-L, D69-L, D70-L. Supersedes: the `BRUNCH_DEV_RPC`-only dev gate; relying on the operating cwd to choose the dev workspace; the assumption that the introspection extension needs build-exclusion (runtime opt-in suffices); lifting Pi offline mode in `BRUNCH_DEV` TUI sessions merely to enable live-provider behavior. - **D59-L — `goal` is a grade-derived, AUTO-able objective axis, distinct from strategy.** A *goal* is what the session agent currently pursues; a *strategy* is the reusable interaction shape used to pursue it — a goal is pursued *via* a strategy *through* a lens (three orthogonal axes). The goal set is derived/gated by `spec.readiness_grade`: `grounding-advance` (fill grounding and advance the grade), `elicit-expand` (expand the elicited specification graph while ambiguity remains productive), `commit-converge` (reduce / lock down reviewable commitments), plus an always-on `capture-posture` (capture or confirm dev `posture`, D45-L). `goal` defaults to the grade-derived objective, may be pinned, or left `AUTO`; in either case D58-L manifests advertise the legal resource(s) rather than injecting the whole goal body. For now `goal` is **internal/grade-derived and not part of the user posture-change surface** (it is too contingent to expose as a user-mutable axis); the pin affordance is reserved for system/internal logic, and unlike `strategy`/`lens` the user does not switch it (D40-L, Q4). `elicit-expand` and `commit-converge` intentionally form the diverge/converge pair for the elicitation diamond; `elicit-I` / `elicit-II` are retired because they were phase-like labels, not objectives. "Advance the grade" is a goal, not a strategy — though the `grounding-advance` goal may carry a dedicated default interaction pattern. Depends on: D45-L, D57-L, D58-L. Supersedes: conflating the elicit-lifecycle objective with strategy selection. - **D66-L — `freestyle` is a structure-optional elicitation strategy; it and generalized free-text capture are one slice.** `freestyle` joins the strategy axis (D25-L) as a fifth value alongside `step-wise-decision-tree`, `step-wise-disambiguate`, `propose-graph`, and `project-graph`. The four existing strategies impose structured-exchange turn discipline (offer-first `present_*`/`request_*` ritual, D37-L); `freestyle` makes that discipline *optional* — the turn may be ordinary user-driven chat, structured-exchange tools remain available (not prohibited), and user-invoked slash/skill-commands are ergonomic here precisely because no pending structured exchange is consuming the turn. It is **initiative/interaction-style, not authority**: it is not a new `op_mode`, adds no tool authority, and `op_mode`-gated tool policy (D40-L) is unchanged. Because freestyle has no mandatory exchange, the only way it grows graph truth is **generalized capture**, so the two land together: post-exchange capture (D18-L) is now wired onto the ordinary-message path (`session.submitMessage`, D49-L) over the same `session exchange` unit — which already spans plain user text — routing high-confidence directly-stated facts through `CommandExecutor.mutateGraph({createBasis: explicit, ops})` exactly as the structured-response capture tracer does, while low-confidence implications stay in preface / `capture_*` analysis (D47-L, D50-L) and never become graph truth. Freestyle therefore *composes with*, and does not replace, the `goal` (D59-L) and `lens` (D25-L) axes: the user still pursues `grounding-advance` / `elicit-expand` / etc., just through free chat, and freestyle capture can both resolve and spawn `elicitation_backlog` entries (D65-L). **AUTO must not select `freestyle`** — it is an explicit user pin only (a "let me just talk" escape hatch); the runtime manifest now omits it under AUTO while still allowing explicit pins, so spontaneous AUTO entry cannot silently abandon the offer-first product thesis (R16). Remaining open quality questions are limited to capture scope beyond directly-labeled facts (fitness evidence under A22-L, materially harder without a structured prompt), whether capture eventually runs on every freestyle turn or on demand, and the exact slash/skill-command surface (the Q6 method-vs-command question). Depends on: D18-L, D25-L, D26-L, D40-L, D45-L, D49-L, D50-L, D59-L, D63-L, D65-L. Refines: R16. Supersedes: treating offer-first (R16) as a universal per-turn session invariant; treating freestyle as a new operational mode or authority posture. - **D60-L — Agent context splits into pull / projection / render / surface, distinguishes graph-truth from active-context reads, and keeps `workspace.state` separate.** **Agent context** = content the agent reasons over: `cwd` (filesystem kickoff heuristic — `.brunch?`, session count/length, README/markdown sizes, file counts), `graph` (overview/list/query), or `node` (variable-hop neighborhood). **PULL** is typed, read-only data access owned by the data layer (`graph/queries.ts` for graph/node; `session/` for cwd) and bypasses `CommandExecutor` (reads only); the typed value *is* the JSON form. Graph pulls must make the read projection explicit: `graph_truth` includes accepted truth records, while `active_context` hides superseded predecessors and must also omit edges whose endpoints are hidden so active-context reads do not contain dangling references. The graph read family should support the observed query shapes without becoming a generic records API: list nodes by kind(s), list nodes by D64-L readiness band(s), find nodes related to anchor node(s) by edge category/direction/hop depth, and find class-members lacking an edge of a given category in a given direction (gap query — a single named absence shape, not a generic NOT-predicate language). `src/graph/README.md` owns the consumer coverage ledger: `read_graph` exposes the six agent shapes, while RPC and web deliberately expose only overview + neighborhood until a scoped feature promotes another shape. **PROJECTION** is optional info-preserving shaping for reusable DTOs; when multiple adapters need the same structured view, it belongs in `projections/`, but many callers can consume the typed read directly. **RENDER** turns a typed or projected value into either an LLM-friendly string or JSON (trivial serialization). Reusable lossy text/markdown rendering belongs in `renderers/`; `.pi/agents/contexts/` owns the agent-context orchestration decision — which typed pull to expose, how much detail to include, and how lens-plane/grade-depth shape the prompt-facing string — and may call reusable renderers. Rendered projected stable node codes (D62-L) remain the primary handles. **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (`read_graph`, `read_workspace_context`, `read_session_context` wrap the relevant reads/renderers — markdown in `toolResult.content`, typed JSON in `toolResult.details` per I33-L), or *rpc/ui*. The separate **workspace projection** (`workspace.state` — workspace/session/spec/chrome product state) is a different subject and keeps that name. Depends on: D35-L, D52-L, D53-L, D62-L, D64-L. Supersedes: pre-rendering context strings in the pull layer, scattering context-build logic across `graph/`, `.pi/agents/contexts/`, and tool adapters, or silently mixing graph-truth and active-context reads. @@ -310,7 +310,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I39-L | Every graph node in a spec has exactly one stable projected human reference code derived from `kind` + `kind_ordinal`; `(spec_id, plane, kind, kind_ordinal)` is unique; ordinals are monotonic per `(spec_id, plane, kind)` and are not reused after deletion or supersession. | partially covered (`graph-tool-resilience` added `nodes.kind_ordinal`, `node_kind_counters`, DB uniqueness, CommandExecutor allocation for single-node/batch writes, rollback protection, `GraphNode.kindOrdinal` row mapping, globally unique 1–3 letter labels with readiness-band metadata, projected-code parsing, selected-spec adapter resolution before `CommandExecutor`, code-only `mutate_graph` / `read_graph` schemas, and code-primary prompt/tool rendering; remaining slice still needs deletion/supersession no-reuse coverage) | D54-L, D62-L; I1-L, I11-L | | I40-L | Accepted graph nodes and edges use only `basis ∈ explicit | implicit`; review-set approval and direct user statements produce `explicit`, `propose-graph` concept-level materialization produces `implicit`, and the mutation path is recoverable from `change_log` rather than from a persisted basis enum value such as `accepted_review_set`. | covered (`graph-tool-resilience` replaced the persisted basis enum with `explicit | implicit`, made `mutateGraph` apply one batch create-basis to all created nodes/edges, made single-node `createNode` reject retired basis values before LSN/counter/node/change-log allocation, made `propose-graph` adapter commits implicit, made review-set translation explicit, rejected retired `accepted_review_set`, and records `change_log.operation` independently; `capture-response-to-graph` proves direct structured text responses commit explicit-basis graph nodes through `CommandExecutor`; `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves full review-cycle approval creates explicit-basis graph truth) | D26-L, D27-L, D53-L, D63-L | | I41-L | Same-spec `supersession` edges form an acyclic directed graph; every edge-creation path validates proposed supersession edges together with existing supersession edges before committing. | covered (`command-executor/commit-graph-batch.test.ts` rejects existing-cycle closure, intra-batch cycles, and mixed existing+batch cycles through the shared dry-run/commit planner before batch writes; rejected cycles roll back or avoid batch nodes/edges/change_log; acyclic supersession commits remain covered by query/CommandExecutor success paths) | D51-L, D53-L; I34-L | -| I42-L | Dev-only substrate never affects product/prod behavior: `src/dev/**` is build-excluded from `dist`; the introspection extension registers and advertises its query tools only when `BRUNCH_DEV` opts it in (default product sessions never register or advertise the tap, `/introspect`, `brunch_session_query`, `brunch_introspect_query`, or any `before_provider_request` observer); dev-loop artifacts land only under gitignored `.fixtures/scratch/`, never tracked `runs/` or the operating cwd; and any offline-default lift is save/restore-scoped, never a leaked global `process.env` mutation. | covered for the current DX substrate (`src/.pi/__tests__/introspection.test.ts` proves default-off registration + last-position ordering when enabled, including absence/presence and active-tool advertisement of `brunch_session_query` / `brunch_introspect_query`; `src/.pi/agents/state.test.ts` proves the injected dev tool set is unioned only before blocked-tool subtraction and registered-tool intersection; `src/.pi/extensions/session-query/index.test.ts` and `src/.pi/extensions/introspect-query/index.test.ts` cover read-only find/project/truncation behavior; `src/app/brunch-tui.test.ts` proves the real TUI launch path threads `BRUNCH_DEV` into introspection registration, keeps the registrar last, asserts `tsconfig.build.json` excludes `src/dev`, and proves the offline lift/default is save/restore-scoped through `finally`; `src/dev/introspection-launcher.test.ts` proves scratch artifact routing is repo-rooted and independent of workspace cwd; `.fixtures/README.md` + `.gitignore` document/guard scratch). | D39-L, D40-L, D68-L, D69-L, D70-L, D71-L | +| I42-L | Dev-only substrate never affects product/prod behavior: `src/dev/**` is build-excluded from `dist`; the introspection extension registers and advertises its query tools only when `BRUNCH_DEV` opts it in (default product sessions never register or advertise the tap, `/introspect`, `brunch_session_query`, `brunch_introspect_query`, or any `before_provider_request` observer); dev-loop artifacts land only under gitignored `.fixtures/scratch/`, never tracked `runs/` or the operating cwd; and Pi startup update suppression / any offline-default lift is save/restore-scoped through TUI launch, never a leaked global `process.env` mutation. | covered for the current DX substrate (`src/.pi/__tests__/introspection.test.ts` proves default-off registration + last-position ordering when enabled, including absence/presence and active-tool advertisement of `brunch_session_query` / `brunch_introspect_query`; `src/.pi/agents/state.test.ts` proves the injected dev tool set is unioned only before blocked-tool subtraction and registered-tool intersection; `src/.pi/extensions/session-query/index.test.ts` and `src/.pi/extensions/introspect-query/index.test.ts` cover read-only find/project/truncation behavior; `src/app/brunch-tui.test.ts` proves the real TUI launch path threads `BRUNCH_DEV` into introspection registration, keeps the registrar last, asserts `tsconfig.build.json` excludes `src/dev`, and proves `PI_OFFLINE` startup update suppression plus prior `PI_OFFLINE` / `PI_SKIP_VERSION_CHECK` values are save/restore-scoped through `finally`; `src/dev/introspection-launcher.test.ts` proves scratch artifact routing is repo-rooted and independent of workspace cwd; `.fixtures/README.md` + `.gitignore` document/guard scratch). | D39-L, D40-L, D68-L, D69-L, D70-L, D71-L | | I43-L | The web client's accent presentation map is exhaustive over `NodePlane` (intent/oracle/design/plan); every plane renders with a defined accent, and node reference-code labels remain canonical via `NODE_KIND_METADATA` + `kindOrdinal` (no fallthrough default that silently swallows an unmapped plane). | met (compile-time `satisfies Record` exhaustiveness check on `PLANE_ACCENT` in `src/web/components/node-card.tsx`; breaks the build when a new `NodePlane` is added without an accent) | D72-L; I36-L | | I44-L | Domain enum taxonomy lives in the drizzle-free leaf `src/graph/schema/kinds.ts` (zero imports), `db/schema.ts` owns no enum `const` array (it imports them from the leaf), and the `web/` build target transitively contains no Drizzle/persistence code. The only sanctioned `db/`→`graph/` import is from `db/schema.ts` to `graph/schema/kinds.ts`. | covered (`src/graph/architecture.test.ts` guards leaf purity, db→graph import confinement, absence of enum const arrays in `db/schema.ts`, and post-`build:web` absence of `drizzle`/`sqliteTable` in the dist-web bundle; `src/db/README.md` and `src/graph/README.md` record the taxonomy leaf topology) | D52-L, D73-L; I26-L | @@ -598,12 +598,12 @@ Verification oracles prove Brunch's *product* claims; development loops are how | Dev loop | Tier served | What it accelerates | Built on | | --- | --- | --- | --- | | Faux loop | inner / middle | wrapper logic, regressions, structured-exchange permutations | pi faux provider + `.inMemory()` services | -| Real-provider TUI/CLI | outer | interactive use, UX feel | `tsx`-run Brunch source, non-offline; pi source opt-in only when needed | +| Real-provider TUI/CLI | outer | interactive use, UX feel | `tsx`-run Brunch source; Brunch TUI scopes `PI_OFFLINE` only to suppress Pi startup update checks; pi source opt-in only when needed | | Introspection loop | outer (diagnostic) | "what did the model see, and how did it read our tools/skills" (I38-L) | D69-L read-only capture extension | The vite/vitest-backed loops can run against pi *source* via the D67-L `PI_SOURCE` alias, so no rebuild is needed there to pick up either Brunch or pi edits. `tsx`-run real-provider loops intentionally keep default `dist` resolution until an opt-in dev tsconfig is needed. -Dev-loop artifacts route to gitignored `.fixtures/scratch///`, resolved to the repo root rather than the operating cwd, and decoupled from the `--cwd` workspace a dev session targets (D70-L); a single `BRUNCH_DEV` switch gates every dev affordance at once (D71-L). The introspection loop's live wiring into the real TUI, the four-role `.fixtures/` topology, and conversational self-report (the agent reporting in chat on tool I/O, understandability, errors, and skill activation — A26-L) are the `dx-introspection-live` follow-on; `dx-feedback-loops` built the capture machinery but left it dormant and writing under `runs/introspection/`. +Dev-loop artifacts route to gitignored `.fixtures/scratch///`, resolved to the repo root rather than the operating cwd, and decoupled from the `--cwd` workspace a dev session targets (D70-L); a single `BRUNCH_DEV` switch gates dev affordances while Brunch TUI launch keeps Pi startup update checks suppressed (D71-L). The introspection loop's live wiring into the real TUI, the four-role `.fixtures/` topology, and conversational self-report (the agent reporting in chat on tool I/O, understandability, errors, and skill activation — A26-L) are the `dx-introspection-live` follow-on; `dx-feedback-loops` built the capture machinery but left it dormant and writing under `runs/introspection/`. ### Oracle Strategy by Loop Tier diff --git a/src/.pi/__tests__/chrome.test.ts b/src/.pi/__tests__/chrome.test.ts index b400dcbe..c889c6e8 100644 --- a/src/.pi/__tests__/chrome.test.ts +++ b/src/.pi/__tests__/chrome.test.ts @@ -1,8 +1,10 @@ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'; +import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import type { WorkspaceSessionReadyState } from '../../session/workspace-session-coordinator.js'; -import { +import { BrunchStartupHeader } from '../components/chrome-header.js'; +import chromeExtension, { chromeStateForWorkspace, projectBrunchChromeFooterLines, renderBrunchChrome, @@ -39,12 +41,14 @@ describe('Brunch chrome projection', () => { session: { id: 'session-1', label: 'Interview #1' }, phase: 'elicitation' as const, chatMode: 'responding-to-elicitation' as const, + webSidecarUrl: 'http://127.0.0.1:49152/spec/1', }; expect(projectBrunchChromeFooterLines(state)).toEqual([ '/tmp/project no model', 'no branch ctx ──────────── ?% ?/0', 'proj: project | spec: Spec One | mode: not reported | strategy: not reported | lens: not reported', + 'web-ui: http://127.0.0.1:49152/spec/1', '', ]); }); @@ -117,15 +121,7 @@ describe('Brunch chrome projection', () => { 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') => {}, - }; + const ui = fakeChromeUi(calls); renderBrunchChrome(ui, { cwd: '/tmp/project', @@ -140,6 +136,98 @@ describe('Brunch chrome projection', () => { expect(calls.some((call) => call.method === 'setStatus')).toBe(false); expect(calls.find((call) => call.method === 'setTitle')?.args).toEqual(['brunch — project · Spec One']); }); + + it('installs the full startup header only when chrome state requests it', async () => { + const calls: FakeUiCall[] = []; + + renderBrunchChrome(fakeChromeUi(calls), { + cwd: '/tmp/project', + project: { name: 'Project One', slug: 'project-one' }, + spec: { id: 1, title: 'Spec One' }, + session: { id: 'session-1', label: 'Spec One — session 1' }, + phase: 'elicitation', + chatMode: 'responding-to-elicitation', + webSidecarUrl: 'http://127.0.0.1:49152/spec/1', + startupHeader: { decision: 'newSession' }, + }); + + const headerFactory = calls.find((call) => call.method === 'setHeader')?.args[0]; + expect(headerFactory).toEqual(expect.any(Function)); + + const component = (headerFactory as (tui: unknown, theme: FakeTheme) => BrunchStartupHeader)( + undefined, + fakeTheme, + ); + const collapsedLines = component.render(120); + expect(collapsedLines.slice(0, 6)).toEqual(['', '', '', '', '', '']); + expect(collapsedLines.join('\n')).toContain('brunch v0.1.0'); + expect(collapsedLines.join('\n')).toContain('/brunch switch'); + expect(collapsedLines.join('\n')).toContain('web-ui: http://127.0.0.1:49152/spec/1'); + expect(collapsedLines.join('\n')).not.toContain('Press ctrl+o'); + expect(collapsedLines.join('\n')).not.toContain('Spec One — session 1'); + component.setExpanded(true); + expect(component.render(120).join('\n')).toContain('Current session: Spec One — session 1'); + expect(component.render(120).join('\n')).toContain('web-ui: http://127.0.0.1:49152/spec/1'); + expect(component.render(120).join('\n')).toContain('Graph capture'); + + const resumedCalls: FakeUiCall[] = []; + renderBrunchChrome(fakeChromeUi(resumedCalls), { + cwd: '/tmp/project', + spec: { id: 1, title: 'Spec One' }, + session: { id: 'session-1' }, + phase: 'elicitation', + chatMode: 'responding-to-elicitation', + }); + expect(resumedCalls.some((call) => call.method === 'setHeader')).toBe(false); + }); + + it('installs dev fallback header through the src/.pi extension entrypoint', async () => { + const calls: FakeUiCall[] = []; + const sessionStart: Array<(event: unknown, ctx: { ui: FakeExtensionUi }) => Promise | void> = []; + + chromeExtension({ + on: (event: string, handler: never) => { + if (event === 'session_start') sessionStart.push(handler); + }, + } as never); + + expect(sessionStart).toHaveLength(1); + await sessionStart[0]!({}, { ui: fakeChromeUi(calls) }); + + expect(calls.map((call) => call.method)).toEqual(['setFooter', 'setHeader', 'setTitle']); + }); + + it('keeps startup header text width-safe and newline-safe', () => { + const component = new BrunchStartupHeader( + { + project: 'Project\nOne', + spec: 'Spec\rOne', + session: 'Session\tOne', + sidecarUrl: 'http://127.0.0.1:49152/spec/1\nignored', + }, + fakeTheme, + ); + + expect(component.render(36).every((line) => !/[\r\n\t]/.test(line))).toBe(true); + expect(component.render(36).every((line) => visibleWidth(line) <= 36)).toBe(true); + component.setExpanded(true); + expect(component.render(36).every((line) => !/[\r\n\t]/.test(line))).toBe(true); + }); + + it('does not project the active web sidecar URL into an upper widget', async () => { + const calls: FakeUiCall[] = []; + + renderBrunchChrome(fakeChromeUi(calls), { + cwd: '/tmp/project', + spec: { id: 1, title: 'Spec One' }, + session: { id: 'session-1' }, + phase: 'elicitation', + chatMode: 'responding-to-elicitation', + webSidecarUrl: 'http://127.0.0.1:49152/spec/1\nignored', + }); + + expect(calls.some((call) => call.method === 'setWidget')).toBe(false); + }); }); function readyWorkspace(cwd: string, sessionId: string, sessionName?: string): WorkspaceSessionReadyState { @@ -168,6 +256,25 @@ interface FakeUiCall { args: unknown[]; } +function fakeChromeUi(calls: FakeUiCall[]): FakeExtensionUi { + return { + 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') => {}, + }; +} + +const fakeTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +type FakeTheme = typeof fakeTheme; + type FakeExtensionUi = Pick< ExtensionUIContext, 'setFooter' | 'setHeader' | 'setStatus' | 'setWidget' | 'setWorkingIndicator' | 'setTitle' | 'notify' diff --git a/src/.pi/__tests__/extension-registry.test.ts b/src/.pi/__tests__/extension-registry.test.ts index 9ae675c1..2f0cf569 100644 --- a/src/.pi/__tests__/extension-registry.test.ts +++ b/src/.pi/__tests__/extension-registry.test.ts @@ -52,6 +52,14 @@ describe('Brunch explicit Pi extension registry', () => { } }); + it('keeps the src/.pi chrome entrypoint activated for direct Pi iteration', async () => { + const settings = JSON.parse(await readFile(join(projectRoot(), 'src/.pi/settings.json'), 'utf8')) as { + extensions?: unknown; + }; + + expect(settings.extensions).toContain('extensions/chrome/index.ts'); + }); + it('registers product extensions from the shell in explicit order', async () => { const recording = createRecordingExtensionApi(); diff --git a/src/.pi/__tests__/tui-lab-cycle.test.ts b/src/.pi/__tests__/tui-lab-cycle.test.ts new file mode 100644 index 00000000..319a8417 --- /dev/null +++ b/src/.pi/__tests__/tui-lab-cycle.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEMO_MODEL_SEGMENTS, + nextSegmentIndex, + normalizeActiveIndex, + previousSegmentIndex, + renderSegmentTrack, + trackVisibleWidth, + type LabTheme, +} from '../components/tui-lab/index.js'; +import { TuiStyleLabComponent } from '../extensions/tui-lab/index.js'; + +const theme = createTheme(); + +describe('TUI style lab segment track', () => { + it('renders the active segment as a solid chip and inactive labels as colored text', () => { + const track = renderSegmentTrack(theme, DEMO_MODEL_SEGMENTS, 1); + + expect(track).toContain('\x1b[48;5;33m\x1b[30m default '); + expect(track).toContain('\x1b[38;5;34msmol\x1b[39m'); + expect(track).toContain('\x1b[38;5;220mslow\x1b[39m'); + }); + + it('accepts arbitrary segment labels and colors', () => { + const track = renderSegmentTrack( + theme, + [ + { label: 'ask', color: 'accent' }, + { label: 'shape', color: 'customMessageLabel' }, + { label: 'lock', color: 'success' }, + ], + 2, + ); + + expect(track).toContain('ask'); + expect(track).toContain('shape'); + expect(track).toContain('\x1b[48;5;34m\x1b[30m lock '); + }); + + it('keeps visible width within the requested maximum', () => { + const track = renderSegmentTrack(theme, DEMO_MODEL_SEGMENTS, 1, 14); + + expect(trackVisibleWidth(track)).toBeLessThanOrEqual(14); + }); + + it('wraps active indexes forward and backward', () => { + expect(normalizeActiveIndex(4, 3)).toBe(1); + expect(normalizeActiveIndex(-1, 3)).toBe(2); + expect(nextSegmentIndex(2, 3)).toBe(0); + expect(previousSegmentIndex(0, 3)).toBe(2); + }); +}); + +describe('TUI style lab cycle demo component', () => { + it('cycles only local demo state and requests no model mutation API', () => { + let closed = false; + const component = new TuiStyleLabComponent(theme, () => { + closed = true; + }); + + expect(component.render(80).join('\n')).toContain('default'); + component.handleInput?.('\x1b[C'); + expect(component.render(80).join('\n')).toContain('\x1b[48;5;220m\x1b[30m slow '); + component.handleInput?.('\x1b[D'); + expect(component.render(80).join('\n')).toContain('\x1b[48;5;33m\x1b[30m default '); + component.handleInput?.('\x1b'); + expect(closed).toBe(true); + }); +}); + +function createTheme(): LabTheme { + const colorCodes: Record = { + accent: '\x1b[38;5;33m', + success: '\x1b[38;5;34m', + warning: '\x1b[38;5;220m', + error: '\x1b[38;5;196m', + muted: '\x1b[38;5;244m', + dim: '\x1b[38;5;240m', + text: '\x1b[39m', + customMessageLabel: '\x1b[38;5;99m', + toolTitle: '\x1b[38;5;69m', + syntaxKeyword: '\x1b[38;5;141m', + }; + return { + fg: (color, text) => `${colorCodes[color]}${text}\x1b[39m`, + inverse: (text) => `\x1b[7m${text}\x1b[27m`, + getFgAnsi: (color) => colorCodes[color], + }; +} diff --git a/src/.pi/__tests__/tui-lab-style.test.ts b/src/.pi/__tests__/tui-lab-style.test.ts new file mode 100644 index 00000000..e78d18b6 --- /dev/null +++ b/src/.pi/__tests__/tui-lab-style.test.ts @@ -0,0 +1,131 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; +import { + lineVisibleWidths, + makeSolidBadge, + renderStylePalettePreview, + type LabTheme, +} from '../components/tui-lab/index.js'; +import { BRUNCH_TUI_STYLE_LAB_COMMAND, registerBrunchTuiLab } from '../extensions/tui-lab/index.js'; + +const theme = createTheme(); + +describe('TUI style lab palette', () => { + it('maps Brunch style roles onto current Pi theme tokens', () => { + const lines = renderStylePalettePreview(theme, 120); + + expect(lines.join('\n')).toContain('primary'); + expect(lines.join('\n')).toContain('validated / ready'); + expect(lines.join('\n')).toContain('structured token'); + }); + + it('renders text style samples and safely resets each preview line', () => { + const lines = renderStylePalettePreview(theme, 120); + + expect(lines.join('\n')).toContain('\x1b[1mbold\x1b[22m'); + expect(lines.join('\n')).toContain('\x1b[3mitalic\x1b[23m'); + expect(lines.join('\n')).toContain('\x1b[4munderline\x1b[24m'); + expect(lines.join('\n')).toContain('\x1b[9mstrike\x1b[29m'); + expect(lines.every((line) => line.endsWith('\x1b[0m'))).toBe(true); + }); + + it('renders solid badges by converting foreground ANSI to background ANSI', () => { + const badge = makeSolidBadge(theme, 'solid', 'accent'); + + expect(badge).toContain('\x1b[48;5;33m'); + expect(badge).toContain('\x1b[39m\x1b[49m'); + expect(visibleWidth(`${badge} tail`)).toBe(' solid tail'.length); + }); + + it('keeps preview lines within visible width', () => { + const lines = renderStylePalettePreview(theme, 32); + + expect(lineVisibleWidths(lines).every((width) => width <= 32)).toBe(true); + }); +}); + +describe('TUI style lab extension registration', () => { + it('registers only when explicitly enabled', () => { + const off = createCommandRecorder(); + registerBrunchTuiLab(off.api); + expect(off.commandNames).toEqual([]); + + const on = createCommandRecorder(); + registerBrunchTuiLab(on.api, { enabled: true }); + expect(on.commandNames).toEqual([BRUNCH_TUI_STYLE_LAB_COMMAND]); + }); + + it('does not enter the product extension bundle by default', async () => { + const recording = createProductRecorder(); + + await createBrunchPiExtensions( + { + cwd: '/tmp/brunch', + chatMode: 'responding-to-elicitation', + phase: 'elicitation', + spec: { id: 1, title: 'Spec' }, + session: { id: 'session', label: 'Session' }, + }, + undefined, + { coordinator: {} as never, graphMentionSource: { listMentionCandidates: () => [] } }, + )(recording.api); + + expect(recording.commandNames).not.toContain(BRUNCH_TUI_STYLE_LAB_COMMAND); + }); +}); + +function createTheme(): LabTheme { + const colorCodes: Record = { + accent: '\x1b[38;5;33m', + success: '\x1b[38;5;34m', + warning: '\x1b[38;5;220m', + error: '\x1b[38;5;196m', + muted: '\x1b[38;5;244m', + dim: '\x1b[38;5;240m', + text: '\x1b[39m', + customMessageLabel: '\x1b[38;5;99m', + toolTitle: '\x1b[38;5;69m', + syntaxKeyword: '\x1b[38;5;141m', + }; + return { + fg: (color, text) => `${colorCodes[color]}${text}\x1b[39m`, + bold: (text) => `\x1b[1m${text}\x1b[22m`, + italic: (text) => `\x1b[3m${text}\x1b[23m`, + underline: (text) => `\x1b[4m${text}\x1b[24m`, + strikethrough: (text) => `\x1b[9m${text}\x1b[29m`, + inverse: (text) => `\x1b[7m${text}\x1b[27m`, + getFgAnsi: (color) => colorCodes[color], + }; +} + +function createCommandRecorder() { + const commandNames: string[] = []; + return { + commandNames, + api: { + registerCommand(name: string) { + commandNames.push(name); + }, + } as never, + }; +} + +function createProductRecorder() { + const commandNames: string[] = []; + return { + commandNames, + api: { + on() {}, + registerTool() {}, + registerCommand(name: string) { + commandNames.push(name); + }, + registerShortcut() {}, + registerMessageRenderer() {}, + getAllTools: () => [], + setActiveTools() {}, + } as never, + }; +} diff --git a/src/.pi/components/chrome-header.ts b/src/.pi/components/chrome-header.ts new file mode 100644 index 00000000..1ada1c80 --- /dev/null +++ b/src/.pi/components/chrome-header.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import type { Theme } from '@earendil-works/pi-coding-agent'; +import { type Component, truncateToWidth } from '@earendil-works/pi-tui'; + +import { formatBrunchProductIdentity, readBrunchAnsiLogo } from './brunch-identity.js'; + +export interface BrunchStartupHeaderFacts { + project: string; + spec: string; + session: string; + sidecarUrl?: string; +} + +const HEADER_TOP_PADDING_LINES = 6; +const MIN_WIDTH = 20; +const ASSET_DIR = new URL('./workspace-dialog/assets/', import.meta.url); +const PACKAGE_JSON_URL = new URL('../../../package.json', import.meta.url); +const LOCAL_BUILD_TIME = formatBuildTime(new Date()); + +export class BrunchStartupHeader implements Component { + private expanded = false; + + constructor( + private readonly facts: BrunchStartupHeaderFacts, + private readonly theme: Pick, + ) {} + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(MIN_WIDTH, width); + const lines = this.expanded ? this.expandedLines() : this.collapsedLines(); + return lines.map((line) => truncateToWidth(line, safeWidth, '...')); + } + + private collapsedLines(): string[] { + return [ + ...this.topPaddingLines(), + ...this.identityLines(), + '', + this.shortcutHelpLine(), + this.webOrExpandHelpLine(), + ]; + } + + private expandedLines(): string[] { + return [ + ...this.topPaddingLines(), + ...this.identityLines(), + '', + this.shortcutHelpLine(), + this.webOrExpandHelpLine(), + '', + `Project: ${sanitizeText(this.facts.project)}`, + `Selected spec: ${sanitizeText(this.facts.spec)}`, + `Current session: ${sanitizeText(this.facts.session)}`, + 'Graph capture: mention graph items with #codes; accepted graph truth flows through Brunch commands.', + 'Runtime posture: use Brunch mode/strategy/lens controls; AUTO choices stay within the active manifest.', + 'Help: use /brunch to switch spec/session; use structured prompts or chat to continue elicitation.', + ]; + } + + private topPaddingLines(): string[] { + return Array.from({ length: HEADER_TOP_PADDING_LINES }, () => ''); + } + + private identityLines(): string[] { + return formatBrunchProductIdentity({ + logoLines: readBrunchAnsiLogo({ assetUrl: ASSET_DIR, truecolor: true }), + version: brunchVersion(), + theme: this.theme, + }); + } + + private shortcutHelpLine(): string { + return this.theme.fg( + 'dim', + 'escape interrupt · ctrl+c/ctrl+d clear/exit · /brunch switch · # mention · ! bash · ctrl+o more', + ); + } + + private webOrExpandHelpLine(): string { + if (this.facts.sidecarUrl) { + return this.theme.fg('dim', `web-ui: ${sanitizeText(this.facts.sidecarUrl)}`); + } + return this.theme.fg('dim', 'Press ctrl+o to show full Brunch startup help and active surfaces.'); + } +} + +interface PackageJson { + version?: unknown; + private?: unknown; +} + +function brunchVersion(): { version: string; dev: string | null } { + const pkg = readPackage(); + const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + const isLocalDev = pkg.private === true || version === '0.0.0'; + return { + version: `v${version}`, + dev: isLocalDev ? `(dev @ ${LOCAL_BUILD_TIME})` : null, + }; +} + +function readPackage(): PackageJson { + try { + return JSON.parse(readFileSync(fileURLToPath(PACKAGE_JSON_URL), 'utf8')) as PackageJson; + } catch { + return {}; + } +} + +function formatBuildTime(date: Date): string { + return date + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC'); +} + +function sanitizeText(value: string): string { + return value + .replace(/[\r\n\t]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} diff --git a/src/.pi/components/tui-lab/index.ts b/src/.pi/components/tui-lab/index.ts new file mode 100644 index 00000000..829e22e8 --- /dev/null +++ b/src/.pi/components/tui-lab/index.ts @@ -0,0 +1,19 @@ +export { + BRUNCH_STYLE_ROLES, + lineVisibleWidths, + makeSolidBadge, + renderStylePalettePreview, + safeLines, + type LabTheme, + type LabThemeColor, + type PaletteRole, +} from './style-palette.js'; +export { + DEMO_MODEL_SEGMENTS, + nextSegmentIndex, + normalizeActiveIndex, + previousSegmentIndex, + renderSegmentTrack, + trackVisibleWidth, + type TrackSegment, +} from './segment-track.js'; diff --git a/src/.pi/components/tui-lab/segment-track.ts b/src/.pi/components/tui-lab/segment-track.ts new file mode 100644 index 00000000..fc3bd06b --- /dev/null +++ b/src/.pi/components/tui-lab/segment-track.ts @@ -0,0 +1,48 @@ +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; + +import { type LabTheme, type LabThemeColor, makeSolidBadge } from './style-palette.js'; + +export interface TrackSegment { + readonly label: string; + readonly color?: LabThemeColor; +} + +export function normalizeActiveIndex(activeIndex: number, length: number): number { + if (length <= 0) return 0; + return ((activeIndex % length) + length) % length; +} + +export function nextSegmentIndex(activeIndex: number, length: number): number { + return normalizeActiveIndex(activeIndex + 1, length); +} + +export function previousSegmentIndex(activeIndex: number, length: number): number { + return normalizeActiveIndex(activeIndex - 1, length); +} + +export function renderSegmentTrack( + theme: LabTheme, + segments: readonly TrackSegment[], + activeIndex: number, + width = Number.POSITIVE_INFINITY, +): string { + if (segments.length === 0) return ''; + const active = normalizeActiveIndex(activeIndex, segments.length); + const line = segments + .map((segment, index) => { + const color = segment.color ?? 'accent'; + return index === active ? makeSolidBadge(theme, segment.label, color) : theme.fg(color, segment.label); + }) + .join(theme.fg('dim', ' | ')); + return Number.isFinite(width) ? truncateToWidth(line, Math.max(1, width)) : line; +} + +export function trackVisibleWidth(track: string): number { + return visibleWidth(track); +} + +export const DEMO_MODEL_SEGMENTS: readonly TrackSegment[] = [ + { label: 'smol', color: 'success' }, + { label: 'default', color: 'accent' }, + { label: 'slow', color: 'warning' }, +] as const; diff --git a/src/.pi/components/tui-lab/style-palette.ts b/src/.pi/components/tui-lab/style-palette.ts new file mode 100644 index 00000000..18f2ad00 --- /dev/null +++ b/src/.pi/components/tui-lab/style-palette.ts @@ -0,0 +1,87 @@ +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; + +export type LabThemeColor = + | 'accent' + | 'success' + | 'warning' + | 'error' + | 'muted' + | 'dim' + | 'text' + | 'customMessageLabel' + | 'toolTitle' + | 'syntaxKeyword'; + +export interface LabTheme { + fg(color: LabThemeColor, text: string): string; + bg?(color: string, text: string): string; + bold?(text: string): string; + italic?(text: string): string; + underline?(text: string): string; + inverse?(text: string): string; + strikethrough?(text: string): string; + getFgAnsi?(color: LabThemeColor): string; +} + +export interface PaletteRole { + readonly name: string; + readonly color: LabThemeColor; + readonly sample: string; +} + +export const BRUNCH_STYLE_ROLES: readonly PaletteRole[] = [ + { name: 'primary', color: 'accent', sample: 'brunch product accent' }, + { name: 'good', color: 'success', sample: 'validated / ready' }, + { name: 'warn', color: 'warning', sample: 'needs attention' }, + { name: 'bad', color: 'error', sample: 'blocked / invalid' }, + { name: 'quiet', color: 'muted', sample: 'secondary context' }, + { name: 'code', color: 'syntaxKeyword', sample: 'structured token' }, +] as const; + +const RESET = '\x1b[0m'; +const FG_RESET = '\x1b[39m'; +const BG_RESET = '\x1b[49m'; + +export function makeSolidBadge(theme: LabTheme, label: string, color: LabThemeColor): string { + const fgAnsi = theme.getFgAnsi?.(color); + const bgAnsi = fgAnsi?.replace(new RegExp(String.raw`\u001b\[38;`, 'g'), '\u001b[48;'); + if (bgAnsi && bgAnsi !== fgAnsi) { + return `${bgAnsi}\u001b[30m ${label} ${FG_RESET}${BG_RESET}`; + } + return theme.inverse ? theme.inverse(` ${label} `) : `[${theme.fg(color, label)}]`; +} + +export function renderStylePalettePreview(theme: LabTheme, width: number): string[] { + const safeWidth = Math.max(1, width); + const styles = [ + theme.bold?.('bold') ?? 'bold', + theme.italic?.('italic') ?? 'italic', + theme.underline?.('underline') ?? 'underline', + theme.strikethrough?.('strike') ?? 'strike', + theme.inverse?.(' inverse ') ?? ' inverse ', + ].join(' '); + + return safeLines( + [ + theme.fg('accent', 'Brunch TUI style lab'), + ...BRUNCH_STYLE_ROLES.map( + (role) => `${makeSolidBadge(theme, role.name, role.color)} ${theme.fg(role.color, role.sample)}`, + ), + `${theme.fg('muted', 'text styles')} ${styles}`, + `${makeSolidBadge(theme, 'solid', 'customMessageLabel')} ${theme.fg('dim', 'badges reset before ordinary trailing text')}`, + ], + safeWidth, + ); +} + +export function safeLines(lines: readonly string[], width: number): string[] { + return lines.map((line) => ensureReset(truncateToWidth(line, width))); +} + +export function lineVisibleWidths(lines: readonly string[]): number[] { + return lines.map((line) => visibleWidth(line)); +} + +function ensureReset(line: string): string { + return line.endsWith(RESET) ? line : `${line}${RESET}`; +} diff --git a/src/.pi/extensions/README.md b/src/.pi/extensions/README.md index 956169f4..d40ca6a7 100644 --- a/src/.pi/extensions/README.md +++ b/src/.pi/extensions/README.md @@ -1,6 +1,6 @@ # .pi/extensions/ — Pi adapter registrars -SPEC decisions: D34-L, D35-L, D37-L, D39-L, D40-L, D52-L, D69-L +SPEC decisions: D34-L, D35-L, D37-L, D39-L, D40-L, D52-L, D69-L, D71-L ## Owns @@ -20,7 +20,7 @@ Pi-facing registration and adaptation only: lifecycle hooks, agent tool definiti extensions/ ├── README.md ├── AUDIT.md temporary audit note; do not treat as topology source -├── chrome/ TUI title/footer/chrome projection +├── chrome/ TUI header/title/footer/sidecar-widget chrome projection ├── commands/ /brunch:* commands, shortcut, branch/tree policy ├── compaction/ auto-compaction anchor contract and future hook ├── context/ snapshot/context Pi tools @@ -50,6 +50,39 @@ rules: renderers/ x> .pi/, rpc/, app/, web/ [no transport/UI imports] ``` +## TUI launch chrome + +`chrome/` is the only product extension that should install Brunch's persistent TUI shell chrome. It receives launch facts from `src/app/brunch-tui.ts` through `BrunchChromeState`; it does not read web host, workspace, or activation state itself. + +```pseudo tree +launch facts -> BrunchChromeState +├── cwd/spec/session -> footer + terminal title +├── webSidecarUrl? -> header + footer `web-ui:` line +└── startupHeader? [continue|openSession|newSpec|newSession] + -> ctx.ui.setHeader(...) + -> .pi/components/chrome-header.ts +``` + +```pseudo chain +runBrunchTui + -> chooseSpecSessionActivationDecision + -> activateWorkspace + -> start web sidecar + -> decide browser auto-open [BRUNCH_DEV defaults off, explicit option wins] + -> launchPiInteractive(context) + -> createBrunchPiExtensions(chromeStateForWorkspace(...)) + -> registerBrunchChrome + -> session_start + -> renderBrunchChrome(ctx.ui, chrome) +``` + +Chrome-specific rules: + +- Keep raw `setHeader`, `setFooter`, and `setTitle` calls inside the chrome wrapper unless a later SPEC decision names another owner. +- The web sidecar URL is chrome state rendered as a `web-ui:` line in the startup header and footer, not a `setStatus` contribution, upper widget, or transport concern for `.pi/extensions/`. +- The startup header is TUI-only, non-transcript chrome shown on Brunch-activated TUI launches (`continue`, `openSession`, `newSpec`, or `newSession`) so the product shell does not fall back to Pi's quiet empty header. +- `chrome/` may delegate reusable component rendering to `.pi/components/`, but `.pi/components/` must not register Pi hooks. + ## Migration notes `exchanges/schemas/` is the intentional current exception to "adapter-only": it owns the Zod-authored structured-exchange details schema per D37-L/D41-L until a separate schema-ownership slice moves or names that seam. Zod-to-Pi `TSchema` conversion is confined to two per-plane adapters: `exchanges/pi-schema.ts` (structured-exchange) and `shared/pi-tool-schema.ts` (dev-gated query tools). Both export JSON Schema draft 2020-12 (`z.toJSONSchema`), which strict provider validators require. diff --git a/src/.pi/extensions/chrome/index.ts b/src/.pi/extensions/chrome/index.ts index 4bb2cd10..a25ce4c5 100644 --- a/src/.pi/extensions/chrome/index.ts +++ b/src/.pi/extensions/chrome/index.ts @@ -23,6 +23,7 @@ import type { WorkspaceSessionChromeState, WorkspaceSessionReadyState, } from '../../../session/workspace-session-coordinator.js'; +import { BrunchStartupHeader } from '../../components/chrome-header.js'; type BrunchChromeStage = 'idle' | 'streaming' | 'observer-review'; type BrunchChromeWorkerStatus = 'idle' | 'queued' | 'running' | 'blocked'; @@ -61,6 +62,10 @@ interface BrunchChromeModelTelemetry { contextWindow?: number; } +interface BrunchChromeStartupHeaderState { + decision: 'continue' | 'openSession' | 'newSpec' | 'newSession'; +} + export interface BrunchChromeFooterTelemetry { gitBranch?: string | null; statuses?: ReadonlyMap; @@ -83,6 +88,8 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { id: string; label?: string; }; + webSidecarUrl?: string; + startupHeader?: BrunchChromeStartupHeaderState; runtime?: BrunchChromeRuntimeState; build?: BrunchChromeBuildState; contextUsage?: BrunchChromeContextUsage; @@ -93,7 +100,7 @@ export interface BrunchChromeState extends WorkspaceSessionChromeState { coherence?: BrunchChromeCoherenceVerdict; } -export type BrunchChromeUi = Pick; +export type BrunchChromeUi = Pick; type BrunchChromeTheme = Pick; @@ -127,6 +134,9 @@ export function projectBrunchChromeFooterLines( branchLine, truncateChromeLine(renderBrunchStatusLine(chrome, telemetry, theme), available, theme), ]; + if (chrome.webSidecarUrl) { + lines.push(truncateChromeLine(formatWebUiLine(chrome.webSidecarUrl, theme), available, theme)); + } if (statuses.length > 0) { lines.push(truncateChromeLine(statuses.join(' '), available, theme)); } @@ -147,6 +157,10 @@ function sanitizeStatusText(text: string): string { .trim(); } +function formatWebUiLine(url: string, theme: BrunchChromeTheme | undefined): string { + return style(theme, 'dim', `web-ui: ${sanitizeStatusText(url)}`); +} + function alignChromeColumns(left: string, right: string, width: number): string { if (!Number.isFinite(width)) return `${left} ${right}`; @@ -169,13 +183,18 @@ function truncateChromeLine(text: string, width: number, theme: BrunchChromeThem return Number.isFinite(width) ? truncateToWidth(text, width, style(theme, 'dim', '...')) : text; } -export function chromeStateForWorkspace(workspace: WorkspaceSessionReadyState): BrunchChromeState { +export function chromeStateForWorkspace( + workspace: WorkspaceSessionReadyState, + options: { webSidecarUrl?: string; startupHeader?: BrunchChromeStartupHeaderState } = {}, +): BrunchChromeState { return { ...workspace.chrome, session: { id: workspace.session.id, label: workspace.session.name ?? workspace.session.id, }, + ...(options.webSidecarUrl ? { webSidecarUrl: options.webSidecarUrl } : {}), + ...(options.startupHeader ? { startupHeader: options.startupHeader } : {}), }; } @@ -207,6 +226,20 @@ export function renderBrunchChrome( }, }; }); + if (chrome.startupHeader) { + ui.setHeader( + (_tui, theme) => + new BrunchStartupHeader( + { + project: formatProject(chrome), + spec: formatSpec(chrome), + session: chrome.session.label ?? chrome.session.id, + ...(chrome.webSidecarUrl ? { sidecarUrl: chrome.webSidecarUrl } : {}), + }, + theme, + ), + ); + } ui.setTitle(formatChromeTitle(chrome)); } @@ -233,7 +266,16 @@ export function registerBrunchChrome(pi: ExtensionAPI, chrome: BrunchChromeState }); } -export default function brunchChrome(_pi: ExtensionAPI): void {} +export default function brunchChrome(pi: ExtensionAPI): void { + registerBrunchChrome(pi, { + cwd: process.cwd(), + spec: null, + session: { id: 'direct-pi' }, + phase: 'select_spec', + chatMode: 'select-spec', + startupHeader: { decision: 'continue' }, + }); +} function footerTelemetryFromContext(ctx: ExtensionContext, pi: ExtensionAPI): BrunchChromeFooterTelemetry { const liveContextUsage = ctx.getContextUsage(); diff --git a/src/.pi/extensions/tui-lab/index.ts b/src/.pi/extensions/tui-lab/index.ts new file mode 100644 index 00000000..ac10ce9d --- /dev/null +++ b/src/.pi/extensions/tui-lab/index.ts @@ -0,0 +1,78 @@ +import { type ExtensionAPI } from '@earendil-works/pi-coding-agent'; + +import { + DEMO_MODEL_SEGMENTS, + nextSegmentIndex, + previousSegmentIndex, + renderSegmentTrack, + renderStylePalettePreview, + safeLines, + type LabTheme, +} from '../../components/tui-lab/index.js'; + +export const BRUNCH_TUI_STYLE_LAB_COMMAND = 'brunch:tui-style-lab'; + +export interface BrunchTuiLabOptions { + readonly enabled?: boolean; +} + +interface Component { + render(width: number): string[]; + handleInput?(data: string): void; + invalidate(): void; +} + +export function registerBrunchTuiLab(pi: ExtensionAPI, options: BrunchTuiLabOptions = {}): void { + if (!options.enabled) return; + + pi.registerCommand(BRUNCH_TUI_STYLE_LAB_COMMAND, { + description: 'Preview Brunch dev-only Pi TUI style patterns', + handler: async (_args, ctx) => { + await ctx.ui.custom( + (_tui, theme, _keybindings, done) => { + const component = new TuiStyleLabComponent(theme, done); + return component; + }, + { overlay: true }, + ); + }, + }); +} + +export class TuiStyleLabComponent implements Component { + #activeSegment = 1; + + constructor( + private readonly theme: LabTheme, + private readonly done: (result?: unknown) => void, + ) {} + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + return [ + ...renderStylePalettePreview(this.theme, safeWidth), + renderSegmentTrack(this.theme, DEMO_MODEL_SEGMENTS, this.#activeSegment, safeWidth), + ...safeLines( + [this.theme.fg('dim', '←/→ cycle local demo state · esc closes · does not mutate Pi models')], + safeWidth, + ), + ]; + } + + handleInput(data: string): void { + if (data === '\x1b' || data === 'q') { + this.done(); + return; + } + if (data === '\x1b[C' || data === 'l') { + this.#activeSegment = nextSegmentIndex(this.#activeSegment, DEMO_MODEL_SEGMENTS.length); + } + if (data === '\x1b[D' || data === 'h') { + this.#activeSegment = previousSegmentIndex(this.#activeSegment, DEMO_MODEL_SEGMENTS.length); + } + } + + invalidate(): void {} +} + +export default registerBrunchTuiLab; diff --git a/src/.pi/settings.json b/src/.pi/settings.json index e8f1f285..1b285bd2 100644 --- a/src/.pi/settings.json +++ b/src/.pi/settings.json @@ -1,3 +1,7 @@ { - "extensions": ["-extensions/runtime/index.ts", "-extensions/commands/policy.ts"] + "extensions": [ + "extensions/chrome/index.ts", + "-extensions/runtime/index.ts", + "-extensions/commands/policy.ts" + ] } diff --git a/src/app/brunch-tui.test.ts b/src/app/brunch-tui.test.ts index 98f59af0..4bb0f2f4 100644 --- a/src/app/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -48,6 +48,7 @@ import { createBrunchAgentSessionRuntimeFactory, runBrunchTui, runWithScopedBrunchOfflineDefault, + startupHeaderForActivation, } from './brunch-tui.js'; describe('Brunch TUI boot', () => { @@ -221,7 +222,7 @@ describe('Brunch TUI boot', () => { } }); - it('runs inspect, preflight, and activation before launching interactive mode', async () => { + it('runs inspect, preflight, activation, and decision propagation before launching interactive mode', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); @@ -254,12 +255,33 @@ describe('Brunch TUI boot', () => { sessionFile: workspace.session.file, }; }, - launchInteractive: async ({ workspace: launched }) => { - events.push(`launch:${launched.session.id}`); + launchInteractive: async ({ workspace: launched, activationDecision }) => { + expect(activationDecision).toBeDefined(); + events.push(`launch:${launched.session.id}:${activationDecision?.action}`); }, }); - expect(events).toEqual(['inspect', 'preflight', 'activate:continue', 'launch:session-ready']); + expect(events).toEqual(['inspect', 'preflight', 'activate:continue', 'launch:session-ready:continue']); + }); + + it('requests startup header chrome for every activated launch decision', () => { + expect( + startupHeaderForActivation({ action: 'continue', specId: 1, sessionFile: '/s/one.jsonl' }), + ).toEqual({ + decision: 'continue', + }); + expect( + startupHeaderForActivation({ action: 'openSession', specId: 1, sessionFile: '/s/two.jsonl' }), + ).toEqual({ + decision: 'openSession', + }); + expect(startupHeaderForActivation({ action: 'newSession', specId: 1 })).toEqual({ + decision: 'newSession', + }); + expect(startupHeaderForActivation({ action: 'newSpec', title: 'New spec' })).toEqual({ + decision: 'newSpec', + }); + expect(startupHeaderForActivation({ action: 'cancel' })).toBeUndefined(); }); it('starts a web sidecar on the active spec route with the shared update publisher before interactive mode', async () => { @@ -431,8 +453,8 @@ describe('Brunch TUI boot', () => { expect(events.at(-1)).toBe('before_provider_request'); }); - it('scopes the Brunch offline default and restores PI_OFFLINE in finally', async () => { - const productEnv: { PI_OFFLINE?: string } = {}; + it('scopes Pi startup update suppression and restores update-check env in finally', async () => { + const productEnv: { PI_OFFLINE?: string; PI_SKIP_VERSION_CHECK?: string } = {}; await expect( runWithScopedBrunchOfflineDefault({ dev: false, @@ -443,19 +465,37 @@ describe('Brunch TUI boot', () => { }), ).resolves.toBeUndefined(); expect(productEnv.PI_OFFLINE).toBeUndefined(); + expect(productEnv.PI_SKIP_VERSION_CHECK).toBeUndefined(); - const devEnv: { PI_OFFLINE?: string } = { PI_OFFLINE: '1' }; + const devEnv: { PI_OFFLINE?: string; PI_SKIP_VERSION_CHECK?: string } = {}; await expect( runWithScopedBrunchOfflineDefault({ dev: true, env: devEnv, run: async () => { - expect(devEnv.PI_OFFLINE).toBeUndefined(); + expect(devEnv.PI_OFFLINE).toBe('1'); + }, + }), + ).resolves.toBeUndefined(); + expect(devEnv.PI_OFFLINE).toBeUndefined(); + + const overriddenEnv: { PI_OFFLINE?: string; PI_SKIP_VERSION_CHECK?: string } = { + PI_OFFLINE: 'already-offline', + PI_SKIP_VERSION_CHECK: 'user-skip', + }; + await expect( + runWithScopedBrunchOfflineDefault({ + dev: true, + env: overriddenEnv, + run: async () => { + expect(overriddenEnv.PI_OFFLINE).toBe('already-offline'); + expect(overriddenEnv.PI_SKIP_VERSION_CHECK).toBe('user-skip'); throw new Error('prove finally restore'); }, }), ).rejects.toThrow('prove finally restore'); - expect(devEnv.PI_OFFLINE).toBe('1'); + expect(overriddenEnv.PI_OFFLINE).toBe('already-offline'); + expect(overriddenEnv.PI_SKIP_VERSION_CHECK).toBe('user-skip'); }); it('keeps src/dev build-excluded', async () => { @@ -515,6 +555,122 @@ describe('Brunch TUI boot', () => { 'sidecar-close', ]); }); + it('defaults browser auto-open off under BRUNCH_DEV while still advertising the sidecar route', async () => { + const previous = process.env.BRUNCH_DEV; + const events: string[] = []; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + + try { + process.env.BRUNCH_DEV = '1'; + await runBrunchTui({ + cwd: '/tmp/project', + coordinator: { + inspectWorkspace: async () => ({ + cwd: '/tmp/project', + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + }), + activateWorkspace: async () => workspace, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceDialogPreflight: async () => ({ + action: 'continue', + specId: workspace.spec.id, + sessionFile: workspace.session.file, + }), + webSidecarRunner: async () => ({ + url: 'http://127.0.0.1:49152', + async close() { + events.push('sidecar-close'); + }, + }), + advertiseWebSidecar: (url) => { + events.push(`advertise:${url}`); + }, + openBrowser: async (url) => { + events.push(`open:${url}`); + }, + launchInteractive: async ({ webSidecarUrl }) => { + events.push(`launch:${webSidecarUrl}`); + }, + }); + } finally { + if (previous === undefined) { + delete process.env.BRUNCH_DEV; + } else { + process.env.BRUNCH_DEV = previous; + } + } + + expect(events).toEqual([ + 'advertise:http://127.0.0.1:49152/spec/1', + 'launch:http://127.0.0.1:49152/spec/1', + 'sidecar-close', + ]); + }); + + it('honors explicit browser auto-open under BRUNCH_DEV', async () => { + const previous = process.env.BRUNCH_DEV; + const events: string[] = []; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + + try { + process.env.BRUNCH_DEV = '1'; + await runBrunchTui({ + cwd: '/tmp/project', + autoOpen: true, + coordinator: { + inspectWorkspace: async () => ({ + cwd: '/tmp/project', + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + }), + activateWorkspace: async () => workspace, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceDialogPreflight: async () => ({ + action: 'continue', + specId: workspace.spec.id, + sessionFile: workspace.session.file, + }), + webSidecarRunner: async () => ({ + url: 'http://127.0.0.1:49152', + async close() { + events.push('sidecar-close'); + }, + }), + advertiseWebSidecar: (url) => { + events.push(`advertise:${url}`); + }, + openBrowser: async (url) => { + events.push(`open:${url}`); + }, + launchInteractive: async () => { + events.push('launch'); + }, + }); + } finally { + if (previous === undefined) { + delete process.env.BRUNCH_DEV; + } else { + process.env.BRUNCH_DEV = previous; + } + } + + expect(events).toEqual([ + 'advertise:http://127.0.0.1:49152/spec/1', + 'open:http://127.0.0.1:49152/spec/1', + 'launch', + 'sidecar-close', + ]); + }); + it('can disable browser auto-open while still advertising the active spec sidecar route', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); diff --git a/src/app/brunch-tui.ts b/src/app/brunch-tui.ts index 058258fe..5cfea4e8 100644 --- a/src/app/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -70,6 +70,8 @@ export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState; coordinator: BrunchTuiCoordinator; productUpdates?: ProductUpdatePublisher; + webSidecarUrl?: string; + activationDecision?: SpecSessionActivationDecision; dev?: BrunchTuiDevOptions; } @@ -121,7 +123,7 @@ export async function runBrunchTui(options: BrunchTuiOptions = {}): Promise } | undefined { + return decision && decision.action !== 'cancel' ? { decision: decision.action } : undefined; +} + async function chooseSpecSessionActivationDecision( inventory: WorkspaceLaunchInventory, options: BrunchTuiOptions, @@ -312,35 +329,43 @@ export function createBrunchAgentSessionRuntimeFactory( const bindCurrentWorkspace = async (replacementSessionManager: typeof sessionManager) => { currentWorkspace = await coordinator.bindCurrentSpecToReplacementSession(replacementSessionManager); }; + const startupHeader = startupHeaderForActivation(context.activationDecision); const profile = createBrunchPiSettings({ cwd, agentDir: runtimeAgentDir, extensionFactories: [ - createBrunchPiExtensions(chromeStateForWorkspace(currentWorkspace), bindCurrentWorkspace, { - coordinator, - graph: graphDeps, - ...(context.dev ? { introspection: context.dev.introspection } : {}), - promptContext: () => { - const specId = currentWorkspace.spec.id; - const selectedSpec = graph.commandExecutor.getSpec(specId); - if (!selectedSpec) { - throw new Error(`No selected spec found for Brunch prompt context: ${specId}`); - } - return { - spec: { - id: selectedSpec.id, - name: selectedSpec.name, - readinessGrade: selectedSpec.readinessGrade, - }, - workspace: { cwd }, - session: { - id: currentWorkspace.session.id, - ...(currentWorkspace.session.name ? { label: currentWorkspace.session.name } : {}), - }, - graphReads: graphDeps.reads, - }; + createBrunchPiExtensions( + chromeStateForWorkspace(currentWorkspace, { + ...(context.webSidecarUrl ? { webSidecarUrl: context.webSidecarUrl } : {}), + ...(startupHeader ? { startupHeader } : {}), + }), + bindCurrentWorkspace, + { + coordinator, + graph: graphDeps, + ...(context.dev ? { introspection: context.dev.introspection } : {}), + promptContext: () => { + const specId = currentWorkspace.spec.id; + const selectedSpec = graph.commandExecutor.getSpec(specId); + if (!selectedSpec) { + throw new Error(`No selected spec found for Brunch prompt context: ${specId}`); + } + return { + spec: { + id: selectedSpec.id, + name: selectedSpec.name, + readinessGrade: selectedSpec.readinessGrade, + }, + workspace: { cwd }, + session: { + id: currentWorkspace.session.id, + ...(currentWorkspace.session.name ? { label: currentWorkspace.session.name } : {}), + }, + graphReads: graphDeps.reads, + }; + }, }, - }), + ), ], }); const services = await createAgentSessionServices({ @@ -412,24 +437,27 @@ async function launchPiInteractive(context: BrunchTuiLaunchContext): Promise Promise; }): Promise { const env = options.env ?? process.env; - const previous = env.PI_OFFLINE; - const hadPrevious = Object.hasOwn(env, 'PI_OFFLINE'); + const previousOffline = env.PI_OFFLINE; + const previousSkipVersionCheck = env.PI_SKIP_VERSION_CHECK; + const hadPreviousOffline = Object.hasOwn(env, 'PI_OFFLINE'); + const hadPreviousSkipVersionCheck = Object.hasOwn(env, 'PI_SKIP_VERSION_CHECK'); try { - if (options.dev) { - delete env.PI_OFFLINE; - } else { - applyBrunchOfflineDefault(env); - } + applyBrunchOfflineDefault(env); await options.run(); } finally { - if (hadPrevious && previous !== undefined) { - env.PI_OFFLINE = previous; + if (hadPreviousOffline && previousOffline !== undefined) { + env.PI_OFFLINE = previousOffline; } else { delete env.PI_OFFLINE; } + if (hadPreviousSkipVersionCheck && previousSkipVersionCheck !== undefined) { + env.PI_SKIP_VERSION_CHECK = previousSkipVersionCheck; + } else { + delete env.PI_SKIP_VERSION_CHECK; + } } }