From 1487880896034916bd6d1aceef930b53071466b4 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 14:45:45 +0200 Subject: [PATCH 01/21] review skill promotions --- .agents/skills/ln-review/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/skills/ln-review/SKILL.md b/.agents/skills/ln-review/SKILL.md index 0ebc2473..9c3591e3 100644 --- a/.agents/skills/ln-review/SKILL.md +++ b/.agents/skills/ln-review/SKILL.md @@ -60,6 +60,7 @@ Concrete cues to look for: - A magic check inferring readiness/state from an object's incidental shape instead of a named constant or predicate. Repair: name the predicate against the canonical constant. - Ordering or position encoded by a numeric index/splice rather than by structure. Repair: make the order declarative. - A type alias or name that implies a wider contract than it points at. Repair: point it at the real union, or rename. +- A method-shaped read/cache surface added without the matching update path (RPC method, query key, publisher topic, client invalidator, and README ledger entry drifting apart). Repair: thread the method-shaped topic through the whole publish/invalidate path, and lock it with a narrow invalidation/publisher test. Collect findings as numbered items (category: `contract`). Frame each as: the assumed contract in one sentence, the failure mode when it breaks, and which of the three repairs applies. Most are concrete fixes (`ln-scope`/`ln-build`); clusters across a seam route to `ln-refactor`. From 78e5008cf9ffd00ee9436d36a93bd1f7a2ef79b6 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 14:45:58 +0200 Subject: [PATCH 02/21] cards for accumulated review fixes --- ...introspection-live--preflight-hardening.md | 171 ++++++++++++++++++ ...eb-design-system-port--review-hardening.md | 160 ++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 memory/cards/dx-introspection-live--preflight-hardening.md create mode 100644 memory/cards/web-design-system-port--review-hardening.md diff --git a/memory/cards/dx-introspection-live--preflight-hardening.md b/memory/cards/dx-introspection-live--preflight-hardening.md new file mode 100644 index 00000000..5d8f4c34 --- /dev/null +++ b/memory/cards/dx-introspection-live--preflight-hardening.md @@ -0,0 +1,171 @@ +# DX introspection live — preflight hardening + +Frontier: dx-introspection-live | n/a +Status: active +Mode: chain +Created: 2026-06-09 + +## Orientation + +- **Containing seam:** `dx-introspection-live` over `src/dev/`, `.fixtures/`, and the dev-gated introspection extension. This is preliminary hardening before conversational/live TUI work, not a new frontier. +- **Frontier:** `dx-introspection-live` (PLAN §Frontier Definitions), building on completed `dx-feedback-loops` / FE-825. +- **Posture:** proving (inherited from `dx-introspection-live`) — the work is dev-substrate, and each slice should prove the substrate is portable, gated, and buildable through real entrypoints. +- **Main open risk:** treating dev convenience as product behavior. Keep every affordance behind explicit dev gates and keep scratch output separate from curated probe evidence. + +Frontier-level cross-cutting obligations this slice carries: + +- Preserve D39-L sealing: dev instrumentation observes only and never becomes ambient product behavior. +- Preserve D67-L: default runtime/types resolve installed `dist`; pi source aliasing is opt-in and runtime-gated. +- Preserve D68-L/D70-L: dev loops are iteration loops; durable evidence is curated under `.fixtures/runs/`, while exploratory dev output belongs under `.fixtures/scratch/`. +- Preserve I42-L: dev-only substrate must not affect product/prod behavior or leak global environment changes. + +--- + +## Card 1 — Make the `PI_SOURCE` alias portable and exact · status: next + +### Objective + +The dev-only `PI_SOURCE` alias has no user-local default baked into code/docs and resolves package roots separately from package subpaths. + +### Cold-start reads + +- `memory/SPEC.md` — D67-L; A25-L; I42-L +- `memory/PLAN.md` — frontiers: `dx-feedback-loops`, `dx-introspection-live` +- `src/dev/README.md` — dev-loop front door and source-alias behavior + +### Acceptance Criteria + +```txt +✓ `DEFAULT_PI_SOURCE_ROOT` is derived portably (for example from `os.homedir()` or a repo-relative convention) and remains overrideable by `PI_SOURCE_ROOT`; no `/Users/lunelson/...` default remains in code or docs. +✓ Root package aliases are exact matches, and subpath imports such as `@earendil-works/pi-ai/oauth`, `@earendil-works/pi-coding-agent/hooks`, and generic package subpaths resolve to their intended source files. +✓ Tests cover root and subpath alias behavior, including that the alias stays inert unless `PI_SOURCE=1` and the checkout exists. +✓ Docs state the vite/vitest-only alias boundary accurately; do not claim `tsx` uses the alias unless this slice actually implements a tsx dev tsconfig path. +``` + +### Verification Approach + +- Inner: `src/dev/pi-source-alias.test.ts` with root/subpath cases. +- Inner: `npm run check` for type/lint/format drift in touched files. + +### Cross-cutting obligations + +- Do not add unconditional `tsconfig.json` paths; D67-L explicitly keeps editor/default type resolution on installed packages. +- Do not require a personal checkout path for ordinary contributors. + +### Assumption dependency + +Depends on: A25-L — already partially validated by FE-825; this slice hardens the dev alias without changing product behavior. + +### Expected touched paths (tentative) + +```txt +src/dev/ +├── pi-source-alias.ts ~ +├── pi-source-alias.test.ts ~ +└── README.md ~ +memory/PLAN.md ? +``` + +--- + +## Card 2 — Keep structured-exchange proof buildable outside `src/dev` · status: next + +### Objective + +`structured-exchange-ordering-proof` remains runnable from built/package contexts without importing build-excluded `src/dev/**` files from its generated extension. + +### Cold-start reads + +- `memory/SPEC.md` — D68-L; I42-L +- `memory/PLAN.md` — frontiers: `dx-feedback-loops`, `dx-introspection-live` +- `src/dev/README.md` — dev harness ownership boundary +- `src/probes/README.md` or nearest probe docs if present — probe-vs-dev-loop distinction (omit if absent) + +### Acceptance Criteria + +```txt +✓ The generated ordering-proof extension no longer imports `src/dev/faux-harness.ts` by absolute source path. +✓ The fix preserves the D68-L distinction: product-verification probes do not depend on build-excluded dev-only modules at runtime. +✓ A focused test or source assertion fails if a probe-generated extension imports `src/dev/**` again. +✓ The probe keeps using the same faux model/provider behavior; no real provider, network, key, or token is introduced. +``` + +### Verification Approach + +- Inner: focused vitest/source assertion around `structured-exchange-ordering-proof` generated extension content. +- Inner: `npm run build` or the relevant probe test if available, because the failure mode is dist/package buildability. + +### Cross-cutting obligations + +- If shared faux wiring must move, move only the minimum build-included helper that probes need; do not turn `src/dev` into product build surface. +- Preserve `tsconfig.build.json` exclusion of `src/dev/**`. + +### Assumption dependency + +Depends on: A25-L only indirectly through the pi faux-provider substrate; no new unvalidated assumption. + +### Expected touched paths (tentative) + +```txt +src/probes/structured-exchange-ordering-proof.ts ~ +src/probes/*ordering*.test.ts ? +src/dev/faux-harness.ts ? +src/dev/README.md ? +tsconfig.build.json ? (only to assert exclusion, not to remove it) +``` + +--- + +## Card 3 — Route introspection artifacts to scratch, not cwd-local runs · status: next + +### Objective + +`runBrunchIntrospectionTurn` writes exploratory introspection artifacts under repo-root `.fixtures/scratch/introspection//`, independent of the workspace cwd it targets. + +### Cold-start reads + +- `memory/SPEC.md` — D69-L; D70-L; D71-L; I38-L; I42-L +- `memory/PLAN.md` — frontier: `dx-introspection-live` +- `.fixtures/README.md` — four-role fixture topology if present; otherwise this card may create/update it only for scratch semantics +- `src/dev/README.md` — introspection-loop artifact contract +- `src/.pi/extensions/introspection/README.md` — introspection extension contract + +### Acceptance Criteria + +```txt +✓ `.fixtures/scratch/` is gitignored and documented as ephemeral dev-loop output; `.fixtures/runs/` remains curated/promoted evidence only. +✓ `runBrunchIntrospectionTurn` (or a narrow artifact-path helper it calls) resolves artifact output to repo-root `.fixtures/scratch/introspection//`, not `join(cwd, '.fixtures', 'runs', ...)`. +✓ A test launches/constructs the launcher with a workbench-like cwd and proves no `/.fixtures/...` path is produced. +✓ SPEC/PLAN/dev README wording is reconciled so D69-L/D70-L/PLAN agree on scratch vs runs and on the current `tsx` alias boundary. +``` + +### Verification Approach + +- Inner: `src/dev/introspection-launcher.test.ts` path-resolution assertion. +- Inner: `.gitignore` / docs source assertion if an existing fixture-topology test exists; otherwise focused review plus `npm run check`. + +### Cross-cutting obligations + +- Do not promote scratch output into tracked `.fixtures/runs/` automatically. +- Do not implement the whole live TUI wiring or conversational self-report surface in this preflight card. +- Do not use naked global environment mutation for any offline/default lift. + +### Assumption dependency + +Depends on: A26-L only as future context; this card does not attempt conversational self-report and should not claim to validate A26-L. + +### Expected touched paths (tentative) + +```txt +.fixtures/ +├── README.md ~ +└── scratch/ + (gitignored directory path only, no tracked run artifacts) +.gitignore ~ +src/dev/ +├── introspection-launcher.ts ~ +├── introspection-launcher.test.ts ~ +└── README.md ~ +src/.pi/extensions/introspection/README.md ? +memory/SPEC.md ? +memory/PLAN.md ? +``` diff --git a/memory/cards/web-design-system-port--review-hardening.md b/memory/cards/web-design-system-port--review-hardening.md new file mode 100644 index 00000000..d52518e6 --- /dev/null +++ b/memory/cards/web-design-system-port--review-hardening.md @@ -0,0 +1,160 @@ +# Web design-system port — review hardening + +Frontier: web-design-system-port | n/a +Status: active +Mode: chain +Created: 2026-06-09 + +## Orientation + +- **Containing seam:** `src/web` read-only React sidecar plus its method-shaped RPC cache contract. The web client may cache named Brunch RPC reads, but must refetch via `brunch.updated` hints rather than inventing a store. +- **Frontier:** `web-design-system-port` is already done; this file is review-comment hardening for the current stacked branch, not a new frontier or Linear issue. Current branch note: these fixes may land on `ln/fe-825-dx-introspection-live` because that branch contains the stacked web changes. +- **Posture:** earned (inherited from `web-design-system-port`) — the design-system shape is settled; these cards close contract drift and copied-component defects. +- **Main open risk:** completionist web polish. Keep to review-sampled defects: cache invalidation, copied disclosure behavior, canonical citations/anchors. + +Frontier-level cross-cutting obligations this slice carries: + +- Preserve D19-L: `brunch.updated` is a process-local invalidation hint only; clients refetch canonical projections through named RPC methods. +- Preserve D72-L/I43-L: web presentation may use the ported primitives, but node labels/accent exhaustiveness remain canonical, not web-local folklore. +- Keep the web surface read-only; do not add `workspace.activate` UI or web write paths while fixing cache refresh. + +--- + +## Card 1 — Thread `workspace.selectionState` through web invalidation · status: next + +### Objective + +`workspace.selectionState` behaves like a first-class method-shaped read in the web cache: it has the server status union, is published on workspace activation/inventory changes, and is invalidated by `brunch.updated`. + +### Cold-start reads + +- `memory/SPEC.md` — D19-L; I22-L +- `memory/PLAN.md` — frontier: `web-design-system-port`; branch context: `dx-introspection-live` +- `src/rpc/README.md` — `brunch.updated` topics and RPC-method-to-query-key ledger +- `src/web/README.md` — web query/subscription topology + +### Acceptance Criteria + +```txt +✓ `WorkspaceSelectionState.status` reuses the server-side `WorkspaceSessionState['status']` union (or an exported projection type), not `string`. +✓ workspace activation/inventory-changing paths publish `workspace.selectionState` together with the relevant workspace/session update hints. +✓ `useBrunchUpdateSubscription` invalidates `queryKeys.workspace.selectionState()` for both product-update entries and legacy topic arrays. +✓ A web/RPC test proves a `brunch.updated` notification for `workspace.selectionState` refetches `workspace.selectionState` without requiring a page reload. +✓ `src/rpc/README.md` no longer says the web query key is merely a target/not implemented. +``` + +### Verification Approach + +- Inner: focused vitest for `src/web/app.test.tsx` / subscription invalidation and `src/rpc` publisher behavior. +- Inner: type-aware lint/build catches impossible status widening if the server union changes. + +### Cross-cutting obligations + +- Do not add a generic event spine or cache store; this remains method-shaped invalidation. +- Do not make the read-only sidecar capable of workspace activation. + +### Assumption dependency + +None — D19-L already authorizes the named method/update-topic shape. + +### Expected touched paths (tentative) + +```txt +src/rpc/ +├── product-updates.ts ~ +├── methods/workspace.ts ? +├── handlers.test.ts ? +├── web-host.test.ts ? +└── README.md ~ +src/web/ +├── app.test.tsx ~ +├── queries/workspace.ts ~ +├── subscriptions/brunch-updates.ts ~ +└── README.md ? +``` + +--- + +## Card 2 — Make `DrawerCard`'s disclosure contract executable · status: next + +### Objective + +`DrawerCard` can expand whenever it has drawer content, treats ReactNode presence by nullishness rather than truthiness, and exposes disclosure state to assistive tech. + +### Cold-start reads + +- `memory/SPEC.md` — D72-L +- `memory/PLAN.md` — frontier: `web-design-system-port` +- `memory/cards/web-design-system-port--restyle.md` — original ported primitive scope + +### Acceptance Criteria + +```txt +✓ A collapsed `DrawerCard` with `children` and no `summary` renders a clickable header and expands to show the drawer. +✓ `children={0}` / `summary=""`-style valid ReactNode values are not treated as absent solely because they are falsy. +✓ Toggle buttons expose `aria-expanded`; add `aria-controls` if a stable content id is introduced without speculative API growth. +✓ Existing graph/session view rendering remains unchanged except for the corrected disclosure behavior. +``` + +### Verification Approach + +- Inner: component/unit test for collapsed-with-children, nullish ReactNode handling, and `aria-expanded`. +- Inner: existing web tests continue to pass. + +### Cross-cutting obligations + +- Keep the primitive local to `src/web/components`; no new component library dependency. + +### Assumption dependency + +None. + +### Expected touched paths (tentative) + +```txt +src/web/components/ +├── drawer-card.tsx ~ +└── drawer-card.test.tsx + +src/web/app.test.tsx ? +``` + +--- + +## Card 3 — Correct review-sampled citations and markdown anchors · status: next + +### Objective + +Review-sampled comments and card-template anchors point to the canonical decision/invariant they name, without duplicate heading ambiguity. + +### Cold-start reads + +- `memory/SPEC.md` — D67-L, D72-L, I42-L, I43-L +- `memory/PLAN.md` — frontiers: `web-design-system-port`, `dx-introspection-live` +- `.agents/skills/ln-scope/SKILL.md` — full/light card template headings + +### Acceptance Criteria + +```txt +✓ `src/web/components/node-card.tsx` cites D72-L for plane-organized web accents and I43-L for `NodePlane` accent exhaustiveness. +✓ `.agents/skills/ln-scope/SKILL.md` has unambiguous full-card and light-card cold-start heading anchors; existing self-links resolve to the intended section or are phrased without relying on duplicate generated anchors. +✓ No unrelated SPEC/PLAN renumbering or prose rewrite is included. +``` + +### Verification Approach + +- Inner: `npm run check` or focused markdown/text review; no runtime test needed beyond existing lint/format. + +### Cross-cutting obligations + +- Preserve canonical-doc pointers; do not inline SPEC/PLAN content into card templates. + +### Assumption dependency + +None. + +### Expected touched paths (tentative) + +```txt +src/web/components/node-card.tsx ~ +.agents/skills/ln-scope/SKILL.md ~ +``` From 81899a60cd5197bb39cb2685e6917f625a1cbcc2 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 14:57:30 +0200 Subject: [PATCH 03/21] Harden pi source alias --- memory/PLAN.md | 8 +-- ...introspection-live--preflight-hardening.md | 6 ++- src/dev/README.md | 2 +- src/dev/pi-source-alias.test.ts | 50 ++++++++++++++++--- src/dev/pi-source-alias.ts | 23 +++++---- 5 files changed, 66 insertions(+), 23 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index a919ee67..b5bdb93e 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -441,12 +441,12 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Status:** done - **Certainty:** proving - **Retires:** A25-L — first validation that tracking the latest `pi-coding-agent` line (via dep bump + dev source-alias) lands without sealed-profile regression. -- **Lights up:** A consolidated `src/dev/` front door exposing three named end-to-end loops (faux / real-provider / introspection) that did not exist as a first-class iteration surface, all running against pi *source* with no rebuild. +- **Lights up:** A consolidated `src/dev/` front door exposing three named end-to-end loops (faux / real-provider / introspection) that did not exist as a first-class iteration surface, with vite/vitest able to run against pi *source* with no rebuild. - **Stabilizes:** The DX-loop seam (D68-L) and the read-only introspection capture contract (D69-L) that future contributors aim from. -- **Objective:** Make working over the pi harness fast and observable. (1) Bump `@earendil-works/pi-*` to latest (`0.79.0`) and add a dev source-alias resolving those packages to the sibling `pi-mono` `src/` checkout in `tsx` + `vitest` + `vite`, mirroring pi's own alias list, while published builds keep resolving `dist` (D67-L). (2) Consolidate three loops behind one `src/dev/` front door owning the launchers plus a shared faux-harness factory; migrate ad hoc faux wiring onto the factory (D68-L). (3) Add one read-only, dev-gated introspection extension wired through `brunch-pi-extensions.ts` that captures exactly what the model receives — mechanical via passive `before_provider_request`/`before_agent_start` tap + on-demand `/introspect` (`ctx.getSystemPromptOptions()`), subjective via launcher `session.prompt` — both writing one `.fixtures/runs/introspection//` run (D69-L). +- **Objective:** Make working over the pi harness fast and observable. (1) Bump `@earendil-works/pi-*` to latest (`0.79.0`) and add a dev source-alias resolving those packages to the sibling `pi-mono` `src/` checkout in `vitest` + `vite`, mirroring pi's own alias list, while published builds keep resolving `dist`; `tsx` source mode remains an explicit future opt-in via a dev tsconfig, not the default path (D67-L). (2) Consolidate three loops behind one `src/dev/` front door owning the launchers plus a shared faux-harness factory; migrate ad hoc faux wiring onto the factory (D68-L). (3) Add one read-only, dev-gated introspection extension wired through `brunch-pi-extensions.ts` that captures exactly what the model receives — mechanical via passive `before_provider_request`/`before_agent_start` tap + on-demand `/introspect` (`ctx.getSystemPromptOptions()`), subjective via launcher `session.prompt` — both writing one `.fixtures/runs/introspection//` run (D69-L). - **Why now / unlocks:** The only fast iteration path today is ad hoc faux wiring scattered across `src/probes/`; the user has elevated DX loops to first-class. This is a substrate that accelerates every later frontier, and its version-bump+alias slice is a shared unblocker best landed before the trio's pi-facing churn. Not POC-ship-critical. - **Acceptance:** - - pi deps are at latest and a dev source-alias resolves `@earendil-works/pi-{ai,agent-core,tui,coding-agent}` to the `pi-mono` `src/` checkout in `tsx`, `vitest`, and `vite`; the published/`dist` resolution path is unchanged. + - pi deps are at latest and a dev source-alias resolves `@earendil-works/pi-{ai,agent-core,tui,coding-agent}` to the `pi-mono` `src/` checkout in `vitest` and `vite`; the published/`dist` resolution path is unchanged, and `tsx` source mode is deferred to an opt-in dev tsconfig if a later real-provider loop needs it. - A single `src/dev/` front door owns the faux, real-provider, and introspection launchers plus one shared faux-harness factory; existing ad hoc faux setup (e.g. `src/probes/structured-exchange-ordering-proof.ts`, `src/.pi/brunch-pi-settings.ts`) is migrated onto the factory or explicitly justified in place. - The faux launcher boots an in-memory `AgentSession` over the pi faux provider and runs a scripted turn end-to-end with no network, keys, or tokens. - One read-only, dev-gated introspection extension loads only through the explicit `brunch-pi-extensions.ts` bundle, returns every captured payload unchanged, and produces a well-formed paired `.fixtures/runs/introspection//` run (mechanical payload + subjective answer correlated by turn). @@ -484,7 +484,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Topology materialization:** `.fixtures/scratch/` (gitignored) joins `seeds/`/`workbenches/`/`runs/`; `--cwd` parsing lands in `src/app/brunch.ts` / `runBrunchCli`; `BRUNCH_DEV` gating and the introspection `{ enabled }` wire-up land in `src/app/brunch-tui.ts`; the `tool_call`/`tool_result` observation + self-report schema + in-chat surface extend `src/.pi/extensions/introspection/`; the launcher artifact-root fix lands in `src/dev/introspection-launcher.ts`; `.gitignore`, `.fixtures/README.md`, `src/dev/README.md`, and `src/.pi/extensions/README.md` reconcile to the new topology and gate. - **Traceability:** D39-L, D58-L, D67-L, D68-L, D69-L, D70-L, D71-L; A26-L; I38-L, I42-L. - **Design docs:** `memory/SPEC.md` §Development Feedback Loops and D69-L–D71-L, A26-L, I42-L; `.fixtures/README.md`; `src/dev/README.md`; `src/.pi/extensions/introspection/README.md`. -- **Current execution pointer:** Not started. Ready for a scoping thread: SPEC is locked (D70-L, D71-L, A26-L, I42-L); slices are sequenced 1→2→3 (topology+`--cwd` first, then `BRUNCH_DEV` unification + live wiring, then conversational expansion). Slices 1 and 2 are the unblockers; slice 3 carries the load-bearing A26-L unknown (reliable *structured* self-report vs narration, and the in-chat-vs-artifact surface choice). +- **Current execution pointer:** Active scope file: `memory/cards/dx-introspection-live--preflight-hardening.md`. Preflight Card 1 hardened the D67-L vite/vitest source alias: portable default root, exact root aliases, explicit subpath aliases, and docs that keep `tsx` source mode deferred. Remaining cards continue with probe buildability and scratch artifact routing before live TUI/conversational work. ### dev-seed-fixtures diff --git a/memory/cards/dx-introspection-live--preflight-hardening.md b/memory/cards/dx-introspection-live--preflight-hardening.md index 5d8f4c34..9fa05b5f 100644 --- a/memory/cards/dx-introspection-live--preflight-hardening.md +++ b/memory/cards/dx-introspection-live--preflight-hardening.md @@ -21,7 +21,7 @@ Frontier-level cross-cutting obligations this slice carries: --- -## Card 1 — Make the `PI_SOURCE` alias portable and exact · status: next +## Card 1 — Make the `PI_SOURCE` alias portable and exact · status: done ### Objective @@ -66,6 +66,10 @@ src/dev/ memory/PLAN.md ? ``` +### Result + +Done 2026-06-09. `DEFAULT_PI_SOURCE_ROOT` is derived from `homedir()`, root aliases are exact regex matches, subpath aliases cover the intended source files, and docs/PLAN now state the vite/vitest-only boundary with `tsx` source mode deferred to an opt-in dev tsconfig. + --- ## Card 2 — Keep structured-exchange proof buildable outside `src/dev` · status: next diff --git a/src/dev/README.md b/src/dev/README.md index 8e8faf9a..9022399e 100644 --- a/src/dev/README.md +++ b/src/dev/README.md @@ -7,7 +7,7 @@ This directory owns Brunch-only development feedback loops. These helpers are no Brunch tracks the latest published `@earendil-works/pi-*` line. Two resolution concerns are kept strictly separate: - **Types + default resolution → installed `dist`.** The published packages ship their own `dist/index.d.ts`, so `tsc`, `tsx`, the editor/LSP, oxlint type-aware lint, and ordinary runtime all resolve pi from `node_modules`. There are deliberately **no `paths` in `tsconfig.json`** — adding them would make a personal source checkout the unconditional default for everyone (tsconfig paths cannot be env-gated) and is unnecessary because the dist `.d.ts` are version-matched to the declared deps. -- **No-rebuild source iteration → runtime alias, gated by `PI_SOURCE`.** When you want edits in a sibling `pi-mono` checkout to take effect without rebuilding, set `PI_SOURCE=1`. `vite.config.ts`'s `piSourceAlias()` then redirects all four packages (`pi-ai`, `pi-agent-core`, `pi-coding-agent`, `pi-tui`) to `pi-mono` source for `vite` and `vitest`. `PI_SOURCE_ROOT` overrides the default checkout path (`/Users/lunelson/.pi/pi-mono`); the alias is inert if the checkout does not exist. +- **No-rebuild source iteration → runtime alias, gated by `PI_SOURCE`.** When you want edits in a sibling `pi-mono` checkout to take effect without rebuilding, set `PI_SOURCE=1`. `vite.config.ts`'s `piSourceAlias()` then redirects all four packages (`pi-ai`, `pi-agent-core`, `pi-coding-agent`, `pi-tui`) to `pi-mono` source for `vite` and `vitest`. `PI_SOURCE_ROOT` overrides the default checkout path (`join(os.homedir(), '.pi', 'pi-mono')`); the alias is inert if the checkout does not exist. `pi-agent-core` is aliased even though Brunch never imports it directly: `pi-coding-agent`'s source imports it, so a partial alias would produce a mixed source/dist module graph. diff --git a/src/dev/pi-source-alias.test.ts b/src/dev/pi-source-alias.test.ts index 5580dabb..63df2c96 100644 --- a/src/dev/pi-source-alias.test.ts +++ b/src/dev/pi-source-alias.test.ts @@ -1,6 +1,9 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; -import { piSourceAlias } from './pi-source-alias.js'; +import { DEFAULT_PI_SOURCE_ROOT, piSourceAlias } from './pi-source-alias.js'; function loadViteAlias(env: { PI_SOURCE?: string; PI_SOURCE_ROOT?: string }) { const previous = { PI_SOURCE: process.env.PI_SOURCE, PI_SOURCE_ROOT: process.env.PI_SOURCE_ROOT }; @@ -20,6 +23,10 @@ function loadViteAlias(env: { PI_SOURCE?: string; PI_SOURCE_ROOT?: string }) { } describe('pi source alias', () => { + it('derives the default checkout path portably', () => { + expect(DEFAULT_PI_SOURCE_ROOT).toBe(join(homedir(), '.pi', 'pi-mono')); + }); + it('types and default resolution stay on installed dist packages', () => { // The published 0.79.0 packages ship their own dist/index.d.ts, so types and // default runtime resolution come from node_modules — no tsconfig paths needed. @@ -31,7 +38,7 @@ describe('pi source alias', () => { expect(loadViteAlias({})).toEqual([]); }); - it('points vite at the pi-mono source checkout only behind PI_SOURCE', () => { + it('points vite root aliases at pi-mono source only behind PI_SOURCE', () => { // Use the current process cwd as a guaranteed-existing PI_SOURCE_ROOT so the // existsSync guard passes on any machine; assert the alias mirrors that root. const root = process.cwd(); @@ -39,13 +46,44 @@ describe('pi source alias', () => { expect(alias).toEqual( expect.arrayContaining([ - { find: '@earendil-works/pi-ai', replacement: `${root}/packages/ai/src/index.ts` }, - { find: '@earendil-works/pi-agent-core', replacement: `${root}/packages/agent/src/index.ts` }, + { find: /^@earendil-works\/pi-ai$/, replacement: `${root}/packages/ai/src/index.ts` }, + { find: /^@earendil-works\/pi-agent-core$/, replacement: `${root}/packages/agent/src/index.ts` }, { - find: '@earendil-works/pi-coding-agent', + find: /^@earendil-works\/pi-coding-agent$/, replacement: `${root}/packages/coding-agent/src/index.ts`, }, - { find: '@earendil-works/pi-tui', replacement: `${root}/packages/tui/src/index.ts` }, + { find: /^@earendil-works\/pi-tui$/, replacement: `${root}/packages/tui/src/index.ts` }, + ]), + ); + }); + + it('resolves package subpaths to their source files', () => { + const root = process.cwd(); + const alias = loadViteAlias({ PI_SOURCE: '1', PI_SOURCE_ROOT: root }); + + expect(alias).toEqual( + expect.arrayContaining([ + { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: `${root}/packages/ai/src/oauth.ts` }, + { + find: /^@earendil-works\/pi-ai\/(.*)$/, + replacement: `${root}/packages/ai/src/$1.ts`, + }, + { + find: /^@earendil-works\/pi-coding-agent\/hooks$/, + replacement: `${root}/packages/coding-agent/src/core/hooks/index.ts`, + }, + { + find: /^@earendil-works\/pi-coding-agent\/(.*)$/, + replacement: `${root}/packages/coding-agent/src/$1.ts`, + }, + { + find: /^@earendil-works\/pi-agent-core\/(.*)$/, + replacement: `${root}/packages/agent/src/$1.ts`, + }, + { + find: /^@earendil-works\/pi-tui\/(.*)$/, + replacement: `${root}/packages/tui/src/$1.ts`, + }, ]), ); }); diff --git a/src/dev/pi-source-alias.ts b/src/dev/pi-source-alias.ts index 945a2008..16a2692c 100644 --- a/src/dev/pi-source-alias.ts +++ b/src/dev/pi-source-alias.ts @@ -1,9 +1,10 @@ import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; import { type AliasOptions } from 'vite'; -const DEFAULT_PI_SOURCE_ROOT = '/Users/lunelson/.pi/pi-mono'; +export const DEFAULT_PI_SOURCE_ROOT = join(homedir(), '.pi', 'pi-mono'); /** * Dev-only source alias for the pi packages (D67-L). @@ -26,30 +27,30 @@ export function piSourceAlias(): AliasOptions { if (process.env.PI_SOURCE !== '1' || !existsSync(piMonoRoot)) return []; return [ - { find: '@earendil-works/pi-ai', replacement: resolve(piMonoRoot, 'packages/ai/src/index.ts') }, - { find: '@earendil-works/pi-ai/oauth', replacement: resolve(piMonoRoot, 'packages/ai/src/oauth.ts') }, + { find: /^@earendil-works\/pi-ai$/, replacement: resolve(piMonoRoot, 'packages/ai/src/index.ts') }, + { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: resolve(piMonoRoot, 'packages/ai/src/oauth.ts') }, { find: /^@earendil-works\/pi-ai\/(.*)$/, replacement: resolve(piMonoRoot, 'packages/ai/src/$1.ts') }, { - find: '@earendil-works/pi-agent-core', + find: /^@earendil-works\/pi-agent-core$/, replacement: resolve(piMonoRoot, 'packages/agent/src/index.ts'), }, { find: /^@earendil-works\/pi-agent-core\/(.*)$/, - replacement: resolve(piMonoRoot, 'packages/agent/src/$1'), + replacement: resolve(piMonoRoot, 'packages/agent/src/$1.ts'), }, { - find: '@earendil-works/pi-coding-agent', + find: /^@earendil-works\/pi-coding-agent$/, replacement: resolve(piMonoRoot, 'packages/coding-agent/src/index.ts'), }, { - find: '@earendil-works/pi-coding-agent/hooks', + find: /^@earendil-works\/pi-coding-agent\/hooks$/, replacement: resolve(piMonoRoot, 'packages/coding-agent/src/core/hooks/index.ts'), }, { find: /^@earendil-works\/pi-coding-agent\/(.*)$/, - replacement: resolve(piMonoRoot, 'packages/coding-agent/src/$1'), + replacement: resolve(piMonoRoot, 'packages/coding-agent/src/$1.ts'), }, - { find: '@earendil-works/pi-tui', replacement: resolve(piMonoRoot, 'packages/tui/src/index.ts') }, - { find: /^@earendil-works\/pi-tui\/(.*)$/, replacement: resolve(piMonoRoot, 'packages/tui/src/$1') }, + { find: /^@earendil-works\/pi-tui$/, replacement: resolve(piMonoRoot, 'packages/tui/src/index.ts') }, + { find: /^@earendil-works\/pi-tui\/(.*)$/, replacement: resolve(piMonoRoot, 'packages/tui/src/$1.ts') }, ]; } From 20c401d202bf14b431655d4f256b7625e8e0035b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 15:01:08 +0200 Subject: [PATCH 04/21] Keep ordering proof buildable --- memory/PLAN.md | 2 +- ...introspection-live--preflight-hardening.md | 6 +- src/dev/README.md | 4 +- src/dev/faux-harness.ts | 66 ++++--------------- src/probes/faux-provider.ts | 59 +++++++++++++++++ ...structured-exchange-ordering-proof.test.ts | 15 ++++- .../structured-exchange-ordering-proof.ts | 14 ++-- 7 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 src/probes/faux-provider.ts diff --git a/memory/PLAN.md b/memory/PLAN.md index b5bdb93e..6fc03a8c 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -484,7 +484,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Topology materialization:** `.fixtures/scratch/` (gitignored) joins `seeds/`/`workbenches/`/`runs/`; `--cwd` parsing lands in `src/app/brunch.ts` / `runBrunchCli`; `BRUNCH_DEV` gating and the introspection `{ enabled }` wire-up land in `src/app/brunch-tui.ts`; the `tool_call`/`tool_result` observation + self-report schema + in-chat surface extend `src/.pi/extensions/introspection/`; the launcher artifact-root fix lands in `src/dev/introspection-launcher.ts`; `.gitignore`, `.fixtures/README.md`, `src/dev/README.md`, and `src/.pi/extensions/README.md` reconcile to the new topology and gate. - **Traceability:** D39-L, D58-L, D67-L, D68-L, D69-L, D70-L, D71-L; A26-L; I38-L, I42-L. - **Design docs:** `memory/SPEC.md` §Development Feedback Loops and D69-L–D71-L, A26-L, I42-L; `.fixtures/README.md`; `src/dev/README.md`; `src/.pi/extensions/introspection/README.md`. -- **Current execution pointer:** Active scope file: `memory/cards/dx-introspection-live--preflight-hardening.md`. Preflight Card 1 hardened the D67-L vite/vitest source alias: portable default root, exact root aliases, explicit subpath aliases, and docs that keep `tsx` source mode deferred. Remaining cards continue with probe buildability and scratch artifact routing before live TUI/conversational work. +- **Current execution pointer:** Active scope file: `memory/cards/dx-introspection-live--preflight-hardening.md`. Preflight Cards 1–2 hardened the D67-L vite/vitest source alias and moved ordering-proof faux-provider wiring out of build-excluded `src/dev/**` into `src/probes/faux-provider.ts`. Remaining preflight work routes introspection artifacts to repo-root scratch before live TUI/conversational work. ### dev-seed-fixtures diff --git a/memory/cards/dx-introspection-live--preflight-hardening.md b/memory/cards/dx-introspection-live--preflight-hardening.md index 9fa05b5f..ab880c17 100644 --- a/memory/cards/dx-introspection-live--preflight-hardening.md +++ b/memory/cards/dx-introspection-live--preflight-hardening.md @@ -72,7 +72,7 @@ Done 2026-06-09. `DEFAULT_PI_SOURCE_ROOT` is derived from `homedir()`, root alia --- -## Card 2 — Keep structured-exchange proof buildable outside `src/dev` · status: next +## Card 2 — Keep structured-exchange proof buildable outside `src/dev` · status: done ### Objective @@ -118,6 +118,10 @@ src/dev/README.md ? tsconfig.build.json ? (only to assert exclusion, not to remove it) ``` +### Result + +Done 2026-06-09. The generated ordering-proof extension now imports build-included `src/probes/faux-provider.ts` instead of `src/dev/faux-harness.ts`; `src/dev/faux-harness.ts` re-exports the shared provider config for dev callers. A focused source assertion guards generated extension content against `src/dev/**` imports, and `npm run build` passes while `tsconfig.build.json` continues excluding `src/dev/**`. + --- ## Card 3 — Route introspection artifacts to scratch, not cwd-local runs · status: next diff --git a/src/dev/README.md b/src/dev/README.md index 9022399e..8509ac15 100644 --- a/src/dev/README.md +++ b/src/dev/README.md @@ -17,13 +17,13 @@ Brunch tracks the latest published `@earendil-works/pi-*` line. Two resolution c ## Faux loop (D68-L) -`src/dev/index.ts` is the dev front door. It exports the shared faux-harness factory and the scripted faux launcher, plus the existing workspace RPC helper namespace. +`src/dev/index.ts` is the dev front door. It exports the shared faux-harness factory and the scripted faux launcher, plus the existing workspace RPC helper namespace. The tiny faux-provider config used by buildable probes lives in `src/probes/faux-provider.ts`; `src/dev/faux-harness.ts` re-exports it for dev-loop callers without making probes import build-excluded `src/dev/**` modules. - `createBrunchFauxHarness()` boots an in-memory Pi `AgentSession` with in-memory auth, model registry, session manager, settings manager, no active tools, and a deterministic faux provider. - `runBrunchFauxTurn()` is the smoke launcher: it scripts one prompt→assistant turn with no network I/O and returns the assistant text plus provider call count. - `brunchFauxProviderConfig()` defaults to the literal in-process dev key and accepts an explicit api-key override. Subprocess probes pass the pi 0.79 `$ENV` form themselves; the in-process harness does not mutate `process.env` to satisfy a subprocess concern. -Product probes may import the shared provider config when they need deterministic faux wiring, but they remain product-verification probes under `src/probes/`; they do not become dev loops merely because they share infrastructure. +Product probes may import `src/probes/faux-provider.ts` when they need deterministic faux wiring, but they remain product-verification probes under `src/probes/`; they do not become dev loops merely because they share infrastructure. ## Introspection loop (D69-L) diff --git a/src/dev/faux-harness.ts b/src/dev/faux-harness.ts index 129cd05c..b95883d8 100644 --- a/src/dev/faux-harness.ts +++ b/src/dev/faux-harness.ts @@ -1,6 +1,5 @@ import { registerFauxProvider, - streamSimple, type FauxProviderRegistration, type FauxResponseStep, } from '@earendil-works/pi-ai'; @@ -11,18 +10,22 @@ import { SessionManager, SettingsManager, type AgentSession, - type ProviderConfig, } from '@earendil-works/pi-coding-agent'; -export const BRUNCH_FAUX_HARNESS_API_KEY = 'brunch-faux-harness-key'; -export const BRUNCH_FAUX_HARNESS_ENV_API_KEY = '$BRUNCH_FAUX_HARNESS_API_KEY'; +import { + BRUNCH_FAUX_HARNESS_API_KEY, + brunchFauxProviderConfig, + defaultBrunchFauxModel, + type BrunchFauxModelOptions, +} from '../probes/faux-provider.js'; -export interface BrunchFauxModelOptions { - readonly provider: string; - readonly api: string; - readonly modelId: string; - readonly modelName: string; -} +export { + BRUNCH_FAUX_HARNESS_API_KEY, + BRUNCH_FAUX_HARNESS_ENV_API_KEY, + brunchFauxProviderConfig, + defaultBrunchFauxModel, + type BrunchFauxModelOptions, +} from '../probes/faux-provider.js'; export interface BrunchFauxHarnessOptions { readonly cwd?: string; @@ -37,49 +40,6 @@ export interface BrunchFauxHarness { dispose(): void; } -export function brunchFauxProviderConfig( - model: BrunchFauxModelOptions, - provider?: FauxProviderRegistration, - apiKey: string = BRUNCH_FAUX_HARNESS_API_KEY, -): ProviderConfig { - return { - api: model.api as never, - baseUrl: 'https://example.invalid', - apiKey, - ...(provider === undefined - ? {} - : { - streamSimple: (requestModel, context, streamOptions) => - streamSimple( - provider.getModel(requestModel.id) ?? provider.getModel(), - context as never, - streamOptions as never, - ), - }), - models: [ - { - id: model.modelId, - name: model.modelName, - api: model.api as never, - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - }, - ], - }; -} - -export function defaultBrunchFauxModel(options: BrunchFauxHarnessOptions = {}): BrunchFauxModelOptions { - return { - provider: options.model?.provider ?? 'brunch-faux', - api: options.model?.api ?? 'brunch-faux-api', - modelId: options.model?.modelId ?? 'brunch-faux-model', - modelName: options.model?.modelName ?? 'Brunch faux model', - }; -} - export async function createBrunchFauxHarness( options: BrunchFauxHarnessOptions = {}, ): Promise { diff --git a/src/probes/faux-provider.ts b/src/probes/faux-provider.ts new file mode 100644 index 00000000..4c273f87 --- /dev/null +++ b/src/probes/faux-provider.ts @@ -0,0 +1,59 @@ +import { streamSimple, type FauxProviderRegistration } from '@earendil-works/pi-ai'; +import { type ProviderConfig } from '@earendil-works/pi-coding-agent'; + +export const BRUNCH_FAUX_HARNESS_API_KEY = 'brunch-faux-harness-key'; +export const BRUNCH_FAUX_HARNESS_ENV_API_KEY = '$BRUNCH_FAUX_HARNESS_API_KEY'; + +export interface BrunchFauxModelOptions { + readonly provider: string; + readonly api: string; + readonly modelId: string; + readonly modelName: string; +} + +export interface BrunchFauxModelContainer { + readonly model?: Partial; +} + +export function brunchFauxProviderConfig( + model: BrunchFauxModelOptions, + provider?: FauxProviderRegistration, + apiKey: string = BRUNCH_FAUX_HARNESS_API_KEY, +): ProviderConfig { + return { + api: model.api as never, + baseUrl: 'https://example.invalid', + apiKey, + ...(provider === undefined + ? {} + : { + streamSimple: (requestModel, context, streamOptions) => + streamSimple( + provider.getModel(requestModel.id) ?? provider.getModel(), + context as never, + streamOptions as never, + ), + }), + models: [ + { + id: model.modelId, + name: model.modelName, + api: model.api as never, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }; +} + +export function defaultBrunchFauxModel(options: BrunchFauxModelContainer = {}): BrunchFauxModelOptions { + return { + provider: options.model?.provider ?? 'brunch-faux', + api: options.model?.api ?? 'brunch-faux-api', + modelId: options.model?.modelId ?? 'brunch-faux-model', + modelName: options.model?.modelName ?? 'Brunch faux model', + }; +} diff --git a/src/probes/structured-exchange-ordering-proof.test.ts b/src/probes/structured-exchange-ordering-proof.test.ts index b420a255..84d3fc5e 100644 --- a/src/probes/structured-exchange-ordering-proof.test.ts +++ b/src/probes/structured-exchange-ordering-proof.test.ts @@ -1,8 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { runStructuredExchangeOrderingProof } from './structured-exchange-ordering-proof.js'; +import { + orderingExtensionSource, + runStructuredExchangeOrderingProof, +} from './structured-exchange-ordering-proof.js'; describe('structured-exchange ordering proof', () => { + it('generates an extension without importing build-excluded dev modules', () => { + const source = orderingExtensionSource( + '/repo/src/.pi/extensions/exchanges/index.ts', + '/repo/src/probes/faux-provider.ts', + ); + + expect(source).toContain('/repo/src/probes/faux-provider.ts'); + expect(source).not.toContain('/src/dev/'); + }); + it('runs same-assistant-message present_options before request_choice with sequential tools', async () => { const proof = await runStructuredExchangeOrderingProof(); diff --git a/src/probes/structured-exchange-ordering-proof.ts b/src/probes/structured-exchange-ordering-proof.ts index a94ed046..d0581d4d 100644 --- a/src/probes/structured-exchange-ordering-proof.ts +++ b/src/probes/structured-exchange-ordering-proof.ts @@ -156,8 +156,14 @@ export async function runStructuredExchangeOrderingProof( async function writeOrderingExtension(cwd: string): Promise { const extensionPath = join(cwd, 'structured-exchange-ordering-extension.ts'); const adapterPath = resolve('src/.pi/extensions/exchanges/index.ts'); - const fauxHarnessPath = resolve('src/dev/faux-harness.ts'); - const content = ` + const fauxProviderPath = resolve('src/probes/faux-provider.ts'); + const content = orderingExtensionSource(adapterPath, fauxProviderPath); + await writeFile(extensionPath, content, 'utf8'); + return extensionPath; +} + +export function orderingExtensionSource(adapterPath: string, fauxProviderPath: string): string { + return ` import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { fauxAssistantMessage, @@ -165,7 +171,7 @@ async function writeOrderingExtension(cwd: string): Promise { registerFauxProvider, } from "@earendil-works/pi-ai" import { registerStructuredExchange } from ${JSON.stringify(adapterPath)} - import { BRUNCH_FAUX_HARNESS_ENV_API_KEY, brunchFauxProviderConfig, defaultBrunchFauxModel } from ${JSON.stringify(fauxHarnessPath)} + import { BRUNCH_FAUX_HARNESS_ENV_API_KEY, brunchFauxProviderConfig, defaultBrunchFauxModel } from ${JSON.stringify(fauxProviderPath)} export default function(pi: ExtensionAPI): void { registerStructuredExchange(pi) @@ -220,8 +226,6 @@ async function writeOrderingExtension(cwd: string): Promise { }) } `; - await writeFile(extensionPath, content, 'utf8'); - return extensionPath; } function orderingEvents(events: readonly unknown[]): string[] { From 2f8a0734ea14a249fd933ca279bb3fe458fc4d54 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 15:01:54 +0200 Subject: [PATCH 05/21] Thread workspace selection updates through web cache --- ...eb-design-system-port--review-hardening.md | 2 +- src/app/brunch.test.ts | 13 ++++- src/rpc/README.md | 4 +- src/rpc/handlers.test.ts | 1 + src/rpc/product-updates.ts | 1 + src/web/README.md | 14 ++++-- src/web/app.test.tsx | 49 +++++++++++++++++++ src/web/queries/workspace.ts | 7 ++- src/web/subscriptions/brunch-updates.ts | 9 ++++ 9 files changed, 89 insertions(+), 11 deletions(-) diff --git a/memory/cards/web-design-system-port--review-hardening.md b/memory/cards/web-design-system-port--review-hardening.md index d52518e6..58aabc7c 100644 --- a/memory/cards/web-design-system-port--review-hardening.md +++ b/memory/cards/web-design-system-port--review-hardening.md @@ -20,7 +20,7 @@ Frontier-level cross-cutting obligations this slice carries: --- -## Card 1 — Thread `workspace.selectionState` through web invalidation · status: next +## Card 1 — Thread `workspace.selectionState` through web invalidation · status: done ### Objective diff --git a/src/app/brunch.test.ts b/src/app/brunch.test.ts index 27482645..7c685ced 100644 --- a/src/app/brunch.test.ts +++ b/src/app/brunch.test.ts @@ -183,9 +183,20 @@ describe('Brunch CLI dispatch', () => { jsonrpc: '2.0', method: 'brunch.updated', params: { - topics: ['workspace.state', 'session.pendingExchange', 'session.exchanges', 'session.runtimeState'], + topics: [ + 'workspace.state', + 'workspace.selectionState', + 'session.pendingExchange', + 'session.exchanges', + 'session.runtimeState', + ], updates: [ { topic: 'workspace.state', specId: workspace.spec.id, sessionId: workspace.session.id }, + { + topic: 'workspace.selectionState', + specId: workspace.spec.id, + sessionId: workspace.session.id, + }, { topic: 'session.pendingExchange', specId: workspace.spec.id, diff --git a/src/rpc/README.md b/src/rpc/README.md index a0ec6e79..df1b3c4e 100644 --- a/src/rpc/README.md +++ b/src/rpc/README.md @@ -261,7 +261,7 @@ Current web code only uses the read sidecar. Write hooks are named here as the e ```pseudo query key families: workspace.state -> ['workspace.state'] - workspace.selectionState -> ['workspace.selectionState'] # target, not yet implemented in web queryKeys + workspace.selectionState -> ['workspace.selectionState'] session.pendingExchange -> ['session.pendingExchange', specId, sessionId] # target session.exchanges -> ['session.exchanges', specId, sessionId] # target session.runtimeState -> ['session.runtimeState', specId, sessionId] @@ -273,7 +273,7 @@ query key families: | --- | --- | --- | --- | | `rpc.discover` | `rpcDiscoveryQueryOptions(rpc)` | not implemented; optional debug/adaptive UI only | none | | `workspace.state` | `workspaceStateQueryOptions(rpc)` | implemented; root/spec loaders prime it | exact `workspace.state` | -| `workspace.selectionState` | `workspaceSelectionStateQueryOptions(rpc)` | target; picker route not built | `workspace.selectionState` or activation success | +| `workspace.selectionState` | `workspaceSelectionStateQueryOptions(rpc)` | implemented; root route reads picker inventory | exact `workspace.selectionState` or activation success | | `workspace.activate` | `activateWorkspaceMutationOptions(rpc)` | target full-host mutation; sidecar rejects | invalidates workspace + selected session resources | | `session.pendingExchange` | `pendingExchangeQueryOptions(rpc, target)` | target; no current web panel | `session.pendingExchange` | | `session.exchanges` | `sessionExchangesQueryOptions(rpc, target)` | target; no current web history panel | `session.exchanges` | diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index e4c96299..07a4c410 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -589,6 +589,7 @@ describe('JSON-RPC handlers', () => { expect(observed).toEqual([ { topic: 'workspace.state', specId: 1, sessionId: 'session-1' }, + { topic: 'workspace.selectionState', specId: 1, sessionId: 'session-1' }, { topic: 'session.pendingExchange', specId: 1, sessionId: 'session-1' }, { topic: 'session.exchanges', specId: 1, sessionId: 'session-1' }, { topic: 'session.runtimeState', specId: 1, sessionId: 'session-1' }, diff --git a/src/rpc/product-updates.ts b/src/rpc/product-updates.ts index f2a0870c..6bdc4890 100644 --- a/src/rpc/product-updates.ts +++ b/src/rpc/product-updates.ts @@ -73,6 +73,7 @@ export function selectedSessionProductUpdates(target?: { }): readonly ProductUpdate[] { return [ productUpdate('workspace.state', target), + productUpdate('workspace.selectionState', target), productUpdate('session.pendingExchange', target), productUpdate('session.exchanges', target), productUpdate('session.runtimeState', target), diff --git a/src/web/README.md b/src/web/README.md index 7368e574..c6ab6cef 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -39,7 +39,7 @@ web/ graph.nodeNeighborhood queries/ - workspace.ts -> workspace.state query options + workspace.ts -> workspace.state + workspace.selectionState query options session.ts -> session.runtimeState query options graph.ts -> graph overview/neighborhood query options @@ -285,15 +285,16 @@ current implemented hooks: query key: ['graph.nodeNeighborhood', specId, nodeId, hops] route status: query option exists; selection UI not yet wired + workspace.selectionState + workspaceSelectionStateQueryOptions(rpc) + query key: ['workspace.selectionState'] + route status: root route reads picker inventory + planned read hooks: rpc.discover rpcDiscoveryQueryOptions(rpc) Purpose: optional capability/schema introspection for debug panels and adaptive clients. - workspace.selectionState - workspaceSelectionStateQueryOptions(rpc) - Purpose: boot/picker inventory and whether explicit activation is required. - session.pendingExchange pendingExchangeQueryOptions(rpc, target) Purpose: current unresolved structured exchange. @@ -340,6 +341,9 @@ useBrunchUpdateInvalidation(rpc, queryClient) if topic == workspace.state: invalidate queryKeys.workspace.state + if topic == workspace.selectionState: + invalidate queryKeys.workspace.selectionState + if topic == session.pendingExchange: invalidate exact pendingExchange key diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index ac2e038e..d622c3ee 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -261,6 +261,55 @@ describe('Brunch React web app', () => { }); }); + it('invalidates workspace selection state from product updates and legacy topic arrays', () => { + const client = new QueryClient(); + const selectionKey = queryKeys.workspace.selectionState(); + client.setQueryData(selectionKey, emptySelectionState); + + invalidateBrunchUpdate(client, { + jsonrpc: '2.0', + method: 'brunch.updated', + params: { updates: [{ topic: 'workspace.selectionState' }] }, + }); + + expect(client.getQueryCache().find({ queryKey: selectionKey, exact: true })?.state.isInvalidated).toBe( + true, + ); + + client.setQueryData(selectionKey, emptySelectionState); + invalidateBrunchUpdate(client, { + jsonrpc: '2.0', + method: 'brunch.updated', + params: { topics: ['workspace.selectionState'] }, + }); + + expect(client.getQueryCache().find({ queryKey: selectionKey, exact: true })?.state.isInvalidated).toBe( + true, + ); + }); + + it('refetches workspace selection state after a brunch.updated selection notification', async () => { + const calls: RpcCall[] = []; + const listeners = new Set(); + const runtime = createBrunchWebRuntime({ + rpcClient: rpcClient({ calls, listeners, selectionState: populatedSelectionState }), + }); + + render(); + + await screen.findByText('Second spec'); + calls.length = 0; + for (const listener of listeners) { + listener({ + jsonrpc: '2.0', + method: 'brunch.updated', + params: { updates: [{ topic: 'workspace.selectionState' }] }, + }); + } + + await waitFor(() => expect(calls).toContainEqual({ method: 'workspace.selectionState' })); + }); + it('invalidates graph overview exactly and graph neighborhoods by selected-node prefix', () => { const client = new QueryClient(); const overviewKey = queryKeys.graph.overview(1); diff --git a/src/web/queries/workspace.ts b/src/web/queries/workspace.ts index 166624ac..f25c41cc 100644 --- a/src/web/queries/workspace.ts +++ b/src/web/queries/workspace.ts @@ -1,7 +1,10 @@ import { queryOptions } from '@tanstack/react-query'; import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; -import type { WorkspaceLaunchInventory } from '../../session/workspace-session-coordinator.js'; +import type { + WorkspaceLaunchInventory, + WorkspaceSessionState, +} from '../../session/workspace-session-coordinator.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; @@ -14,7 +17,7 @@ export function workspaceStateQueryOptions(rpcClient: WebSocketRpcClient) { /** Read-only workspace inventory: the spec/session list shown on the root route. */ export type WorkspaceSelectionState = WorkspaceLaunchInventory & { - status: string; + status: WorkspaceSessionState['status']; requiresSelection: boolean; }; diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts index ad27d070..d1e8a4fa 100644 --- a/src/web/subscriptions/brunch-updates.ts +++ b/src/web/subscriptions/brunch-updates.ts @@ -57,6 +57,10 @@ function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate invalidateExact(queryClient, queryKeys.workspace.state()); return; } + if (update.topic === 'workspace.selectionState') { + invalidateExact(queryClient, queryKeys.workspace.selectionState()); + return; + } if (update.topic === 'graph.overview' && typeof update.specId === 'number') { invalidateExact(queryClient, queryKeys.graph.overview(update.specId)); return; @@ -81,6 +85,10 @@ function invalidateTopic(queryClient: QueryClient, topic: string): void { invalidateExact(queryClient, queryKeys.workspace.state()); return; } + if (topic === 'workspace.selectionState') { + invalidateExact(queryClient, queryKeys.workspace.selectionState()); + return; + } if (topic === 'session.runtimeState') { void queryClient.invalidateQueries({ queryKey: ['session.runtimeState'] }); return; @@ -101,6 +109,7 @@ function invalidateExact(queryClient: QueryClient, queryKey: QueryKey): void { function isProductUpdate(value: unknown): value is ProductUpdate { if (!isRecord(value)) return false; if (value.topic === 'workspace.state') return true; + if (value.topic === 'workspace.selectionState') return true; if (value.topic === 'graph.overview') return typeof value.specId === 'number'; if (value.topic === 'graph.nodeNeighborhood') { return typeof value.specId === 'number' && typeof value.nodeId === 'number'; From 79fce02f88cd32f88d9ba2630ad900a5911649d5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 9 Jun 2026 15:04:04 +0200 Subject: [PATCH 06/21] Harden drawer card disclosure behavior --- ...eb-design-system-port--review-hardening.md | 2 +- src/web/components/drawer-card.test.tsx | 50 +++++++++++++++++++ src/web/components/drawer-card.tsx | 19 ++++--- 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 src/web/components/drawer-card.test.tsx diff --git a/memory/cards/web-design-system-port--review-hardening.md b/memory/cards/web-design-system-port--review-hardening.md index 58aabc7c..c8fc6e2a 100644 --- a/memory/cards/web-design-system-port--review-hardening.md +++ b/memory/cards/web-design-system-port--review-hardening.md @@ -75,7 +75,7 @@ src/web/ --- -## Card 2 — Make `DrawerCard`'s disclosure contract executable · status: next +## Card 2 — Make `DrawerCard`'s disclosure contract executable · status: done ### Objective diff --git a/src/web/components/drawer-card.test.tsx b/src/web/components/drawer-card.test.tsx new file mode 100644 index 00000000..61901332 --- /dev/null +++ b/src/web/components/drawer-card.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { DrawerCard } from './drawer-card.js'; + +afterEach(cleanup); + +describe('DrawerCard', () => { + it('lets a collapsed card with children and no summary expand from the header', () => { + render( + Expandable header}> +

Drawer body

+
, + ); + + const toggle = screen.getByRole('button', { name: 'Expandable header' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByText('Drawer body')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(screen.getByText('Drawer body')).toBeTruthy(); + }); + + it('treats falsy ReactNode values as present drawer content', () => { + render(Zero child}>{0}); + + const toggle = screen.getByRole('button', { name: 'Zero child' }); + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(screen.getByText('0')).toBeTruthy(); + }); + + it('treats an empty-string summary as present disclosure content', () => { + const { container } = render( + Empty summary} summary=""> + Full drawer + , + ); + + const toggle = screen.getByRole('button', { name: 'Empty summary' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[data-drawer-card-content]')).toBeTruthy(); + expect(screen.queryByText('Full drawer')).toBeNull(); + }); +}); diff --git a/src/web/components/drawer-card.tsx b/src/web/components/drawer-card.tsx index fd433d57..c9615927 100644 --- a/src/web/components/drawer-card.tsx +++ b/src/web/components/drawer-card.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useId, useState } from 'react'; // ── Drawer card — reusable card-with-collapsible-drawer ───────────── // @@ -29,11 +29,14 @@ export function DrawerCard({ /** Tighter padding for sidebar/compact contexts. */ compact?: boolean; }) { - const canToggle = !!children && !locked; + const hasDrawer = children !== undefined && children !== null; + const hasSummary = summary !== undefined && summary !== null; + const canToggle = hasDrawer && !locked; const [expanded, setExpanded] = useState(defaultExpanded); + const drawerId = useId(); - const showDrawer = expanded || !!summary; - const drawerContent = expanded && children ? children : summary; + const showDrawer = expanded ? hasDrawer : hasSummary; + const drawerContent = expanded ? children : summary; const headerPadding = compact ? 'p-2.5' : 'p-4'; const drawerPadding = compact ? 'px-2.5 pt-2 pb-2.5' : 'px-4 pt-3 pb-4'; @@ -43,6 +46,8 @@ export function DrawerCard({ + )} + + )} + +
+
+ {view === 'empty' && ( + + )} + {view === 'all-hidden' && ( + setHiddenKinds(new Set())} + className="border-rule hover:bg-wash text-ink mt-2 rounded-lg border bg-white px-3 py-1.5 text-sm" + > + Show all kinds + + } + /> + )} + {view === 'list' && + visibleSections.map((section) => ( + sectionRefs.current.set(section.kind, el)} + /> + ))} +
+
+ + ); +} + +function KindChip({ + section, + hidden, + onScroll, + onToggle, +}: { + section: KindSection; + hidden: boolean; + onScroll: () => void; + onToggle: () => void; +}) { + const accent = planeAccent(section.plane); + return ( + + ); +} + +function KindSectionBlock({ + section, + registerRef, +}: { + section: KindSection; + registerRef: (el: HTMLElement | null) => void; +}) { + const [open, setOpen] = useState(true); + return ( +
+
+

{section.label}

+ +
+ {open && ( +
+ {section.nodes.map((node) => ( + + ))} +
+ )} +
+ ); +} + +function ItemRow({ node }: { node: GraphNode }) { + return ( +
+
+ +

{node.title}

+
+ {node.body ?

{node.body}

: null} +
+ ); +} + +function EmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
+

{title}

+

{description}

+ {action} +
+ ); +} diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx index 8c5212ee..d860ac96 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -1,8 +1,7 @@ import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query'; import { Link, Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; -import type { ReactNode } from 'react'; -import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; +import { AppHeader } from '../components/app-header.js'; import { workspaceSelectionStateQueryOptions, workspaceStateQueryOptions, @@ -11,16 +10,13 @@ import { import type { WebSocketRpcClient } from '../rpc-client.js'; import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; -type SessionProjectionTarget = { - sessionId: string; - specId: number; -}; export interface BrunchWebRouterContext { queryClient: QueryClient; rpcClient: WebSocketRpcClient; } export const rootRoute = createRootRouteWithContext()({ + loader: ({ context }) => context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)), component: RootLayout, }); @@ -35,60 +31,52 @@ export const indexRoute = createRoute({ component: WorkspaceStatePage, }); -export function sessionProjectionTargetFromState( - state: WorkspaceState, - viewedSpecId?: number, -): SessionProjectionTarget | null { - if (!state.session || !state.spec) { - return null; - } - if (viewedSpecId !== undefined && state.spec.id !== viewedSpecId) { - return null; - } - return { sessionId: state.session.id, specId: state.spec.id }; -} - function RootLayout() { const { queryClient, rpcClient } = rootRoute.useRouteContext(); useBrunchUpdateSubscription(queryClient, rpcClient); - return ; + const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); + return ( +
+ +
+ +
+
+ ); } function WorkspaceStatePage() { const { rpcClient } = indexRoute.useRouteContext(); - const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); const { data: selection } = useSuspenseQuery(workspaceSelectionStateQueryOptions(rpcClient)); return ( -
-

Brunch workspace

- - - -
+
+
+ +
+
); } function SpecList(options: { specs: WorkspaceSelectionState['specs'] }) { return ( -