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`. diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index 3773b9ba..8b77afcb 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -74,7 +74,7 @@ Created: YYYY-MM-DD The `memory/cards/` directory is a scoping inbox where multiple agents can deposit independent scope files in parallel without colliding on a single shared file. Each file is the unit of work one `ln-build` invocation consumes. -The card does **not** inline canonical context — it points to it via [§Cold-start reads](#cold-start-reads). The full execution context is the card *plus* the canonical docs its Cold-start reads enumerate, which `ln-build` reloads on a fresh thread. A card therefore need not be self-contained to be cold-buildable; it must make its required reads explicit. "Free-standing enough for a separate builder thread" means *its Cold-start reads are complete*, not *its content is duplicated* — inlining SPEC/PLAN text into the card duplicates canonical truth and invites drift. +The card does **not** inline canonical context — it points to the Cold-start reads block in whichever card template it uses. The full execution context is the card *plus* the canonical docs its Cold-start reads enumerate, which `ln-build` reloads on a fresh thread. A card therefore need not be self-contained to be cold-buildable; it must make its required reads explicit. "Free-standing enough for a separate builder thread" means *its Cold-start reads are complete*, not *its content is duplicated* — inlining SPEC/PLAN text into the card duplicates canonical truth and invites drift. Multiple scope files per frontier are permitted — they represent independent concerns that happen to land on the same branch. They do **not** imply multiple Linear issues or multiple Graphite branches; the frontier item remains the tracker/branch boundary. @@ -183,7 +183,7 @@ If you cannot name the containing seam, the governing decision, or the live inva What is true when this slice is done? Single declarative sentence — observable, testable, no conjunctions. -### Cold-start reads +### Full-card cold-start reads The canonical context a fresh builder thread must resolve **before** building this card. Pointers, not copies — name the exact ids/paths to load; never restate their content here (that duplicates canonical truth and invites drift). @@ -297,7 +297,7 @@ src/legacy/observer.ts ? Single sentence: what this work changes for the user, operator, or codebase. -### Cold-start reads +### Light-card cold-start reads The canonical pointers a fresh builder must resolve before building — ids/paths, not copies. diff --git a/.fixtures/README.md b/.fixtures/README.md index 69a5ea5f..c45737dd 100644 --- a/.fixtures/README.md +++ b/.fixtures/README.md @@ -1,11 +1,12 @@ # `.fixtures/` -Current seed data plus probe artifacts and transcript evidence for the Brunch POC. -The active convention is **probe first, transcript-backed**: each committed run -must have a probe id, a run id, executable/reportable oracle output, and the -transcript artifact needed for human review. Brief-based golden fixtures may -return later, but they should be generated through this probe/transcript path -rather than a separate brief-library subsystem. +Current seed data, launchable workbenches, curated probe artifacts, and ephemeral +dev-loop scratch output for the Brunch POC. The active convention for committed +evidence is **probe first, transcript-backed**: each committed run must have a +probe id, a run id, executable/reportable oracle output, and the transcript +artifact needed for human review. Brief-based golden fixtures may return later, +but they should be generated through this probe/transcript path rather than a +separate brief-library subsystem. See [`docs/architecture/probes-and-transcripts.md`](../docs/architecture/probes-and-transcripts.md) for the current architecture. @@ -14,20 +15,31 @@ for the current architecture. ``` .fixtures/ -├── seeds/ +├── seeds/ # Tracked reusable explicit-basis inputs │ └── / │ ├── README.md -│ ├── .json # Reusable explicit-basis starting truth -│ └── _*.ts # Reproducible data-prep scripts, not product code -└── runs/ - └── / +│ ├── .json +│ └── _*.ts # Reproducible data-prep scripts, not product code +├── workbenches/ # Launchable local workspaces; .brunch/ is gitignored +│ └── / +├── runs/ # Tracked curated/promoted probe evidence +│ └── / +│ └── / +│ ├── session.jsonl # Source transcript / canonical run evidence +│ ├── transcript.md # Human-readable semantic rendering +│ ├── report.json # Probe report and artifact paths +│ └── graph-overview.json # Optional graph readback when graph truth is the proof target +└── scratch/ # Gitignored ephemeral dev-loop output + └── / └── / - ├── session.jsonl # Source transcript / canonical run evidence - ├── transcript.md # Human-readable semantic rendering - ├── report.json # Probe report and artifact paths - └── graph-overview.json # Optional graph readback when graph truth is the proof target ``` +Promote scratch to evidence only deliberately: move a reviewed +`scratch///` under `runs///`, add the missing +probe report/transcript artifacts, then track it. Dev launchers must resolve +scratch from the repo-root `.fixtures/scratch/`, independent of the workspace cwd +they target. + ## Current runs - `runs/public-rpc-parity/2026-05-29-public-rpc-parity/` — FE-744 public Brunch diff --git a/.gitignore b/.gitignore index 1595cce5..e115b4d4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ bun.lock .brunch/ brunch.db* todo.txt +.fixtures/scratch/ +.fixtures/workbenches/*/.brunch/ # Claude Code worktrees .claude/worktrees/ diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 024f713d..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,432 +0,0 @@ -# Handoff - -> Generated by `ln-handoff` at 2026-06-08T15:08:36Z. Read this file to resume work. -> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. - -## Goal - -Continue the context-pipeline PROJECT-stage redesign by fixing one level at a time: first graph PULL shape, then graph LLM renderings, then adapter/RPC/web consumers — without doing global reconciliation after each design move. - -## Session State - -- **Last completed skill**: `ln-design`-style walkthrough — produced a redesigned graph read surface and a level-by-level migration strategy. -- **Current skill**: `ln-handoff` — capturing volatile design state before compaction/new thread. -- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → [refactor] → [sync]` - - Current actual mode is unusual: interactive design/build hybrid. The user explicitly wants conversational walk-throughs (`dev-talkthrough` + `ln-grill` style), not red/green TDD and not global verification at every step. -- **Handoff trigger**: context/compaction risk after a large graph read API pass; next thread must preserve working style and not restart broad survey. - -## In-flight work - -> CRITICAL: These artifacts exist only in the prior conversation, not on disk or not yet reconciled canonically. - -### Working style / process constraint - -The user explicitly corrected the earlier implementation pattern: - -```pseudo -wrong rhythm - design one graph API idea - migrate every downstream layer immediately - run full verification - fix every layer just to stay green - -> too slow, hides design thinking, churns layers that are still under discussion - -current desired rhythm - choose one level - walk/talk through actual API shape with human - make the level coherent - run focused tests/checks only enough to survey breakage - move to next level intentionally - do not globally reconcile every consumer until its layer is being designed -``` - -This means the next thread should **not** immediately chase all remaining drift or run `npm run verify` as first action. The next step is design review of graph renderings. - -### Graph PULL design now implemented in unstaged files - -The graph read surface has been collapsed to two core reads: - -```pseudo -src/graph/queries.ts - queryGraph(filter?, options?) -> GraphSlice - asks: "what subgraph matches this graph/node predicate?" - filter: GraphFilter = { - kinds?: NodeKind[] - bands?: ReadinessBand[] - hasEdge?: EdgePresenceFilter - lacksEdge?: EdgePresenceFilter - } - options: GraphReadOptions = { - visibility?: active | all - } - returns: GraphSlice = { - nodes: GraphNode[] - edges: GraphEdge[] - lsn: Lsn - } - - getNodes(selectors, options?) -> NodeNeighborhood[] - asks: "for these explicit node identities, what node-local context exists?" - selectors: - - { id: number } - - { code: string } - options: GetNodesOptions = { - hops?: number - visibility?: active | all - } - returns per selector: - found: - selector - status: found - node: GraphNode - related: GraphNode[] - edges: GraphEdge[] - not_found: - selector - status: not_found - related: [] - edges: [] -``` - -Prior variants/permutations were deleted at graph PULL level: - -```pseudo -removed/absorbed - getGraphOverview -> queryGraph() - getGraphSliceByKinds -> queryGraph({ kinds }) - getGraphSliceByReadinessBands -> queryGraph({ bands }) - getGraphGaps -> queryGraph({ ..., lacksEdge }) - getNodeNeighborhood -> getNodes([{ id/code }], { hops }) - getRelatedNodes -> not a graph PULL primitive; adapter/render concern if retained -``` - -### Important semantic decision: edge direction - -Edge direction is mechanical and relative to candidate node, not category semantics: - -```pseudo -edge A -> B - -for candidate A: - outgoing - -for candidate B: - incoming - -both: - source == candidate OR target == candidate -``` - -For `queryGraph({ lacksEdge: { categories: ['proof'], direction: 'incoming' } })`, meaning is: - -```pseudo -candidate node N matches iff: - no visible edge E exists where: - E.category == proof - E.targetId == N.id -``` - -Do not let graph PULL reinterpret category semantics. Higher layers/prompt prose may say “claims lacking proof,” but PULL stays mechanical. - -### Projection/rendering reclassification - -The user pushed back on a dedicated `projections/graph/neighborhood.ts` module: - -```pseudo -old assumption - projectNeighborhood exists as reusable PROJECT DTO - -new assessment - NodeNeighborhood already preserves domain shape - only observed consumer is LLM rendering / flattening - flattening + deduping is renderer prep, not reusable projection yet - -current unstaged state - src/projections/graph/neighborhood.ts - temporary empty topology marker with export {} - - src/renderers/graph/neighborhood.ts - owns NodeNeighborhood -> LLM text flattening -``` - -This means `src/projections/README.md` is stale: it still says `graph/neighborhood` is a `●` survivor needing invariant. The new disposition is likely `○` temporary marker or delete/retire after README reconciliation, depending on the next design decision. - -### Layer model agreed with user - -```pseudo -PULL / graph - queryGraph -> GraphSlice - getNodes -> NodeNeighborhood[] - -RPC / web - should relay full read shapes - graph.overview should be GraphSlice-ish - graph.nodeNeighborhood should be NodeNeighborhood - web decides its own rendering later - -Pi TUI toolResult.details - may persist full data shapes - details can be GraphSlice or NodeNeighborhood[] - -Pi LLM text - wants flattened renders - .pi/extensions/graph tools need option surfaces similar to PULL but text output is flat - .pi/agents/context should use the same render functions as tools where possible -``` - -### Rendering survey / next design focus - -The next thread should get set up to examine renderings of **GraphSlice** and **NodeNeighborhood**. - -Current `NodeNeighborhood` render path: - -```pseudo -src/renderers/graph/neighborhood.ts - formatNeighborhood(result: NodeNeighborhood, options?) -> string - -preview command used: - npm run render -- graph-neighborhood bilal-port code-health R1 --output /tmp/brunch-neighborhood-R1.md - -observed output: - [Selected-spec node context] - - anchor: [R1] intent/requirement: ... - - anchor body: ... - - neighbors: - - [T4] intent/term: ... - - [D11] intent/decision: ... - - edges: - - R1 -[dependency]-> D11 - - R1 -[dependency]-> T4 -``` - -Current `GraphSlice` render-ish path: - -```pseudo -src/.pi/extensions/graph/command-adapter.ts - formatGraphOverview(slice: GraphSlice, heading?) -> string -``` - -But this is a smell: - -```pseudo -problem - renderer-ish code lives in adapter layer - rendering full bilal-port/code-health emitted hundreds of nodes - too large for default LLM context/tool output -``` - -Concrete evidence from preview: - -```pseudo -formatGraphOverview(queryGraph(code-health)) - -> "Graph overview (LSN 2): 277 node(s), 446 edge(s)." - -> then prints a huge unbounded list of nodes/edges -``` - -Next design should compare GraphSlice render variants: - -```pseudo -GraphSlice render variants to discuss - compact-summary - counts, lsn, grouped counts by plane/kind, top N nodes - - grouped-list - nodes grouped by plane/kind, capped per group - - evidence/gaps slice - focused list for queryGraph({ lacksEdge ... }) - can use "gaps" as tool/render phrase without making it PULL API term - - full-debug - unbounded-ish, for fixtures/probes only, not LLM default -``` - -And NodeNeighborhood render variants: - -```pseudo -NodeNeighborhood render variants - anchor-context - current renderer, likely good starting point - - related-only - possible replacement for old read_graph related mode if retained - - multi-anchor-context - if getNodes returns >1 neighborhoods for tool/context use -``` - -### Concern discovered during review: `read_graph related` semantics - -Current `.pi/extensions/graph/index.ts` maps `related` mode by: - -```pseudo -getNodes(anchorCodes, { hops }) - -> collect all edge categories during traversal - -> filter resulting edges by edgeCategory/direction afterward -``` - -This is **not equivalent** to “follow only this edge category/direction,” especially for `hops > 1`. - -Bad case: - -```pseudo -A -[association]-> B -[dependency]-> C - -related(anchor=A, edgeCategory=dependency, hops=2) - current post-filter could surface dependency B->C - but there is no dependency path out of A -``` - -Decision pending: - -```pseudo -options - 1. retire/de-emphasize read_graph related mode for now - 2. constrain related mode to hops: 1 - 3. implement true category/direction traversal in graph PULL (but this reopens options we intentionally removed from getNodes) -``` - -Recommendation from prior assistant: retire or constrain `related` until deliberately redesigned. - -### Test deletion / coverage concern - -Large files were reduced sharply: - -```pseudo -src/.pi/__tests__/graph-tools.test.ts -src/graph/queries.test.ts -``` - -This may be fine because old read modes were removed/absorbed, but once tool/render modes are settled, add focused tests per surviving mode. Do not restore broad old tests blindly. - -## Review findings - -No formal `ln-review` was run. Informal review findings from the latest pass: - -| # | Finding | Status | Implications | -| --- | --- | --- | --- | -| 1 | `read_graph related` mode post-filters after unconstrained traversal, wrong for `hops > 1`. | `deferred` | Decide before locking graph tool rendering. Likely retire/constrain related. | -| 2 | `formatGraphOverview` is renderer-ish code in `.pi/extensions/graph/command-adapter.ts`. | `in-progress` | Move/design GraphSlice LLM render under `src/renderers/graph/` or another shared renderer seam. | -| 3 | Full GraphSlice rendering is unbounded and enormous for large fixtures (277 nodes / 446 edges in `code-health`). | `in-progress` | Need compact/grouped LLM render variants before relying on it in prompts/tools. | -| 4 | `src/projections/README.md` ledger is stale relative to `projections/graph/neighborhood.ts` becoming an empty marker. | `deferred` | Reconcile after graph rendering decision; do not prematurely lock a deleted/empty projection. | -| 5 | Reduced graph/tool tests may leave mode coverage thinner until new render/tool surface is finalized. | `deferred` | Add focused tests after mode decisions, not before. | - -## Diagnostic evidence - -- `npm run check` after the reported big pass was green per user report before handoff. -- Targeted tests after the reported big pass were green per user report: 12 files / 74 tests passed. -- Current unstaged file list is large and matches the reported big pass; no files are staged. -- Render preview evidence: - - `npm run render -- graph-neighborhood bilal-port code-health R1 --output /tmp/brunch-neighborhood-R1.md` succeeded. - - Output was compact and plausible for `NodeNeighborhood`. -- GraphSlice render evidence: - - Directly rendering `queryGraph` over `.fixtures/seeds/bilal-port/code-health.json` via `formatGraphOverview` produced `277 node(s), 446 edge(s)` and a huge list, proving current default GraphSlice LLM render is too verbose. - -## Decisions and assumptions - -| Item | Type | Status | Source | -| --- | --- | --- | --- | -| Work level-by-level; do not fix all downstream consumers at every pass. | `decision` | `volatile` | conversation | -| Graph PULL API is `queryGraph` + `getNodes`, not six separate read functions. | `decision` | `implemented unstaged` | conversation + code | -| `queryGraph()` with empty/default filter replaces `getGraph`/overview. | `decision` | `implemented unstaged` | conversation + code | -| `GraphFilter` has flat node predicates; no top-level `nodes` wrapper yet. | `decision` | `implemented unstaged` | conversation + code | -| `getNodes` returns `NodeNeighborhood[]`, preserving selector outcome and per-anchor local context. | `decision` | `implemented unstaged` | conversation + code | -| RPC/web should receive full data shapes; rendering belongs to web/UI or LLM renderers, not RPC. | `decision` | `volatile/partially implemented` | conversation | -| Pi `toolResult.details` can use full data shapes (`GraphSlice` / `NodeNeighborhood[]`). | `assumption` | `volatile` | conversation | -| Dedicated `projectNeighborhood` is not earned yet; flattening lives next to renderer. | `decision` | `implemented unstaged; docs stale` | conversation + code | -| `related` mode semantics are unresolved. | `assumption` | `volatile` | latest review | - -## Repo state - -- **Branch**: `ln/fe-811-ship-gate-residue-and-mentions` (ahead 6 of origin at handoff time) -- **Recent commits**: - - `2fdf2c89 first pass on realigning graph read functions` - - `4a61c73d Author projections ledger and correct PROJECT/PULL stage in PLAN` - - `9ecfe381 Reshape PLAN around dependency-ordered context-pipeline coverage trio` - - `12b16cf3 Add prompt-composition-golden-coverage frontier` - - `389982ef Correct coverage reclassification wording in PLAN` -- **Dirty files**: - - `src/.pi/__tests__/graph-tools.test.ts` - - `src/.pi/__tests__/prompting.test.ts` - - `src/.pi/agents/contexts/graph.test.ts` - - `src/.pi/agents/contexts/graph.ts` - - `src/.pi/agents/contexts/node.test.ts` - - `src/.pi/agents/contexts/node.ts` - - `src/.pi/brunch-pi-extensions.ts` - - `src/.pi/extensions/graph/command-adapter.ts` - - `src/.pi/extensions/graph/index.ts` - - `src/.pi/extensions/graph/tool-schemas.ts` - - `src/.pi/extensions/system-prompts/index.ts` - - `src/app/brunch-tui.test.ts` - - `src/app/brunch-tui.ts` - - `src/graph/export-fixtures.ts` - - `src/graph/index.ts` - - `src/graph/queries.test.ts` - - `src/graph/queries.ts` - - `src/graph/render-preview.ts` - - `src/graph/review-set.test.ts` - - `src/graph/spec-ownership.test.ts` - - `src/graph/workspace-store.ts` - - `src/probes/capture-response-to-graph-proof.ts` - - `src/probes/fixture-curation-loop.test.ts` - - `src/probes/fixture-curation-loop.ts` - - `src/probes/project-graph-review-cycle-proof.test.ts` - - `src/probes/project-graph-review-cycle-proof.ts` - - `src/probes/propose-graph-commit-proof.test.ts` - - `src/probes/propose-graph-commit-proof.ts` - - `src/probes/submit-message-capture-proof.ts` - - `src/projections/graph/neighborhood.ts` - - `src/renderers/graph/neighborhood.ts` - - `src/rpc/methods/graph.ts` - - `src/session/workspace-context.ts` - - `src/web/features/graph/GraphOverview.tsx` - - `src/web/queries/graph.ts` - - untracked: `src/graph/read-api.test.ts` -- **Test status**: - - Last known from user report after big pass: `npm run check` ✅ - - Last known targeted suite from user report: 12 files / 74 tests ✅ - - During handoff, no full verification was rerun; only render previews / file reads were performed. - -## Artifact status - -| Artifact | Exists | Current vs conversation | -| --- | --- | --- | -| `memory/SPEC.md` | yes | likely stale re: graph PULL API naming and projection disposition; do not update until design settles further | -| `memory/PLAN.md` | yes | stale re: `projection-shape-coverage` details: graph neighborhood projection no longer a `●` survivor as previously ledgered | -| `src/projections/README.md` | yes | stale: still says `graph/neighborhood` is real projection needing invariant; current code makes it empty topology marker | -| `src/graph/README.md` | yes | likely stale: observed read-shape ledger names old `getGraphOverview`, `getNodeNeighborhood`, slice/gaps/related functions | -| `memory/cards/` | yes | `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md`; unrelated live/stale status not assessed | -| `memory/REFACTOR.md` | no | n/a | - -## Next steps - -1. **Continue design walkthrough, not global reconciliation.** Start with `src/renderers/graph/` and design LLM renderings for `GraphSlice` and `NodeNeighborhood`. -2. Produce/compare concrete render outputs for at least: - - `NodeNeighborhood` current `R1` preview (`bilal-port/code-health`) - - a small/medium `GraphSlice` (maybe `workspace-spread/alpha-grounding`) - - the large `GraphSlice` (`bilal-port/code-health`) to pressure truncation/grouping -3. Decide GraphSlice renderer API and location. Likely target: - - `src/renderers/graph/slice.ts` or extend `src/renderers/graph/neighborhood.ts` only if naming stays acceptable - - migrate `formatGraphOverview` out of `.pi/extensions/graph/command-adapter.ts` once shape is approved -4. Decide fate of `read_graph related` mode before locking tool tests: - - retire, constrain to `hops: 1`, or redesign traversal separately -5. After renderer decisions, update `.pi/extensions/graph` and `.pi/agents/contexts/graph|node` to use shared renderer(s). -6. Only after graph render/tool shape is stable, reconcile docs/ledgers (`src/projections/README.md`, `src/graph/README.md`, `memory/PLAN.md`). - -## Retirement rule - -- Delete or overwrite this file once the volatile state above is absorbed into `memory/SPEC.md`, `memory/PLAN.md`, code, or a newer `HANDOFF.md`. - -## Open questions - -- Should `read_graph related` survive? If yes, should it be limited to `hops: 1`, or should graph PULL regain category/direction traversal? -- What are the canonical LLM render variants for `GraphSlice`? -- Should there be a separate renderer for prompt context vs tool output, or a shared renderer with small options? -- Should `projections/graph/neighborhood.ts` remain as a temporary marker, or be deleted when `src/projections/README.md` is reconciled? -- Should RPC `graph.overview` continue returning `nodeCount`/`edgeCount`, or strictly relay `GraphSlice` and let web count locally? - -## Resume prompt - -Paste this into a new session: - -> Read `HANDOFF.md` in the workspace root. We are in a level-by-level graph read/render redesign, not a global reconciliation pass. The graph PULL API has already been reshaped to `queryGraph(filter?, options?) -> GraphSlice` and `getNodes(selectors, options?) -> NodeNeighborhood[]` in unstaged files. The immediate next step is to review concrete LLM renderings of `GraphSlice` and `NodeNeighborhood`: start by reading `src/renderers/graph/neighborhood.ts`, `src/.pi/extensions/graph/command-adapter.ts`, and the handoff's rendering survey, then produce compact preview options for `GraphSlice` before touching docs or running full verification. diff --git a/docs/architecture/pi-faux-provider-pattern.md b/docs/architecture/pi-faux-provider-pattern.md new file mode 100644 index 00000000..c70eacc0 --- /dev/null +++ b/docs/architecture/pi-faux-provider-pattern.md @@ -0,0 +1,38 @@ +### re faux — what it's good for, and how pi uses it + +The cleanest evidence is that pi's *own* test suite is exactly this pattern. Look at `packages/coding-agent/test/suite/` — every file boots a faux-provider `AgentSession` (their `harness.ts`, which is the thing our `createBrunchFauxHarness` is modeled on) and asserts on behavior. The categories map directly onto what you'd want to prove about the Brunch wrapper: + +```matrix +| pi suite file (faux-driven) | what it proves | Brunch analogue you'd write | +| ---------------------------------------- | --------------------------------------- | --------------------------------------- | +| agent-session-prompt | tool-call turn, multi-tool, skill/ | does a Brunch skill command expand? | +| template expansion, ext-command dispatch | does /brunch-* dispatch w/o a turn? | +| agent-session-model-extension | ext can BLOCK a tool_call, MODIFY a | does elicit op_mode deny bash/edit? | +| tool_result, MODIFY context msgs | does prompt-mutator change payload? | +| agent-session-queue | steering vs follow-up ordering | does a structured-exchange queue right? | +| agent-session-retry-events | transient retry, exhaust, non-retryable | (mostly pi's concern) | +| agent-session-compaction | manual/auto compaction, abort | does Brunch compaction policy hold? | +| agent-session-runtime | fork/resume/switch lifecycle + cancel | session-boundary refresh, binding | +| regressions/NNNN-*.test.ts | one issue → one pinned behavior | one Brunch bug → one faux regression | +``` + +The faux registration hands you three assertion surfaces that make this possible: + +```data-shape +FauxProviderRegistration: + setResponses(steps) / appendResponses(steps) # script the model's next turns + state.callCount: number # how many provider calls happened + contexts: ProviderContext[] # the EXACT requests that were sent +``` + +That last one is the quiet superpower: `contexts` is what the provider actually received, so you can assert on the system prompt, the tool JSON schemas, and your D58-L manifest *without* a real model. (The introspection tap is the runtime cousin of this same idea.) + +**Concrete Brunch tests/probes worth writing this way** (Observed — all feasible today with `createBrunchFauxHarness`): + +- **Tool gating:** script a `fauxToolCall("bash", …)` in elicit mode, assert it's denied — proves D40-L tool authority. +- **Prompt manifest gating:** boot with a given runtime state/grade, prompt, then inspect the captured context — assert `` excludes `freestyle` under AUTO (R16). +- **Capture pipeline:** prompt with labeled user text, assert it routed through `CommandExecutor.commitGraph({ basis: explicit })`. +- **Structured-exchange ordering:** you already have this as a subprocess probe; a faux in-process version would be far cheaper. +- **Extension hooks:** assert your `before_provider_request` mutators actually transform the payload (and that introspection, registered last, sees the mutated form). + +The rule of thumb: **faux for anything where "what did Brunch send / how did Brunch react to a scripted model" is the question.** Reserve real providers for "is the *content* any good." diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 56c0f4ac..fce9f894 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -540,7 +540,7 @@ Flue's two real contributions — sandbox abstraction and remote deployment — 1. **`BrunchSandbox` interface, modeled on Flue's `SessionEnv` / `SandboxApi`.** When Brunch reaches the milestone where agent tool execution needs sandboxing (well after M0–M3 and likely after M5), introduce a Brunch-owned `BrunchSandbox` with the same shape: `exec(cmd, { cwd, env, timeout, signal })` plus the file primitives (`readFile`, `writeFile`, `stat`, `readdir`, `exists`, `mkdir`, `rm`). Provide an in-process default (the existing pi tools running against the host) and leave room for connector-style adapters per provider. The connector catalog format (`connectors/sandbox--.md` as installation instructions, not npm packages) is also worth copying: it keeps the Brunch core free of provider SDK dependencies. -2. **`brunch-cli --mode serve` (or equivalent) remote deployment target, modeled on `flue build --target ...`.** When Brunch needs to run hosted/remote, the deployable artifact should be a build of the same Brunch host with the interactive adapters (TUI, slash commands, overlays) replaced by a transport adapter (HTTP+SSE or JSON-RPC over WebSocket). Flue's `flue-app.ts` Hono-based shape, its `RunSubscriberRegistry` for live-tail, and its Durable Object persistence pattern are all reasonable references. The point is that "headless remote Brunch" should be a *mode* of the same host, not a parallel codebase — which is the same posture the PRD already takes for the four local modes. +2. **`brunch-cli --mode serve` (or equivalent) remote deployment target, modeled on `flue build --target ...`.** When Brunch needs to run hosted/remote, the deployable artifact should be a build of the same Brunch host with the interactive adapters (TUI, slash commands, overlays) replaced by a transport adapter (HTTP+SSE or JSON-RPC over WebSocket). Flue's `flue-app.ts` Hono-based shape, its `RunSubscriberRegistry` for live-tail, and its Durable Object persistence pattern are all reasonable references. The point is that "headless remote Brunch" should be a *mode* of the same host, not a parallel codebase — which is the same posture the PRD already takes for the local modes. 3. **MCP tool adapter shape, modeled on `connectMcpServer`.** Even if Brunch's POC does not expose MCP to end users, the function-level shape (`connectMcpServer(name, { url, headers, transport? }) → { tools, close }`) is worth replicating when Brunch needs remote tool wiring. Keep it adapter-level; do not bake MCP into the Brunch system prompt or curated toolset. diff --git a/docs/architecture/prd.md b/docs/architecture/prd.md index cb45c4f8..15e56cfc 100644 --- a/docs/architecture/prd.md +++ b/docs/architecture/prd.md @@ -96,28 +96,29 @@ If they can, JSONL remains the transcript authority for the POC. If they cannot, ### User-facing modes -Brunch should expose one local product with four presentation modes: +Brunch is one local product driven by a single `--mode` flag. It currently exposes three presentation modes: -1. `brunch-cli` - default TUI over the local agent host. -2. `brunch-cli --mode web` - launches a local HTTP server and browser UI over the same host. -3. `brunch-cli --mode rpc` - exposes the local host over stdio JSON-RPC for other programs. -4. `brunch-cli --mode print` - runs one-shot, headless prompts for scripting and pipelines. +1. `brunch-cli` (default; `--mode tui`) - TUI over the local agent host. While the TUI runs it also starts a read-only browser **sidecar** over the same host; the sidecar is not a separate mode. +2. `brunch-cli --mode rpc` - exposes the local host over stdio JSON-RPC for other programs. +3. `brunch-cli --mode print` - runs one-shot, headless prompts for scripting and pipelines. -These modes are not four different products. They are four ways of driving one Brunch host. +A standalone web mode is a **planned future feature**, not a current `--mode` value: the browser UI is useless without the TUI driving the session, so for now it ships only as the TUI sidecar. `--mode web` currently errors with "not available yet." + +These modes are not different products. They are ways of driving one Brunch host. ### Human-interactive versus headless behavior Brunch should explicitly separate capabilities that can run unattended from capabilities that require a live human. - Reads, queries, subscriptions, and safe agent-owned writes should work across all modes. -- Confirmation-gated or human-only actions should be native in TUI and web, routable in RPC, and rejected or auto-policy-gated in print mode. +- Confirmation-gated or human-only actions should be native in the TUI (and the planned web surface), routable in RPC, and rejected or auto-policy-gated in print mode. - `needs_human` should be a first-class structured outcome rather than an exceptional failure path. ### Mode Capability Matrix -Not every capability is symmetric across all four modes. The asymmetry follows from the medium, not from a split architecture. +Not every capability is symmetric across modes. The asymmetry follows from the medium, not from a split architecture. The Web column describes the **planned** web surface (a future mode); today the browser appears only as the read-only TUI sidecar. -| Capability | TUI | Web | Print | RPC | +| Capability | TUI | Web (planned) | Print | RPC | | --- | --- | --- | --- | --- | | Read graph state / queries | yes | yes | yes | yes | | Write agent-owned graph fields | yes | yes | yes | yes | @@ -143,9 +144,9 @@ Brunch should be structured as a local host with shared storage, shared mutation | | | v v v +-----------+ +-------------+ +-----------+ - | TUI mode | | web mode | | rpc/print | - | pi-driven | | local HTTP | | adapters | - | surface | | + WS shell | | | + | TUI mode | | web sidecar | | rpc/print | + | pi-driven | | (TUI-driven)| | adapters | + | surface | | planned | | | +-----+------+ +------+------+ +-----+-----+ | | | +-----------------------------+----------------------------+ @@ -538,7 +539,7 @@ Prove whether pi JSONL sessions are sufficient as the transcript authority for t Prove the browser can be a thin remote head over the same Brunch host. -- `--mode web` serves a native Brunch React app +- the browser is served as a read-only **TUI sidecar** (standalone `--mode web` is deferred to a future milestone) - the app uses TanStack Router and TanStack Query over one WebSocket RPC client - no second backend API is invented @@ -595,7 +596,7 @@ Prove that long-running sessions remain grounded. 1. A user starts `brunch` in a project directory, creates the first graph items with the agent, quits, and resumes later with all state preserved under `.brunch/`. 2. Raw assistant and user turn payloads, plus Brunch-specific turn data, survive reload through pi JSONL sessions or a clearly justified fallback. -3. A user opens TUI and web mode against the same workspace, edits graph items in one surface, and sees the other surface update live through subscriptions. +3. A user runs the TUI and opens the read-only web sidecar against the same workspace; edits made via the TUI/agent appear live in the sidecar through subscriptions. (Editing from the browser is a future web-mode capability.) 4. A second session or direct edit changes an item relevant to the first session; the next agent turn receives a `worldUpdate` and reacts coherently. 5. A change introduces a semantic graph violation; the UI shows coherence as degraded and the agent is informed on the next turn. 6. The agent attempts a human-gated change in print or RPC mode and receives a structured `needs_human` or version-conflict response instead of silently mutating state. diff --git a/docs/testing/seeded-dev-rpc.md b/docs/testing/seeded-dev-rpc.md index a098784f..e01765ec 100644 --- a/docs/testing/seeded-dev-rpc.md +++ b/docs/testing/seeded-dev-rpc.md @@ -54,12 +54,12 @@ brunch_rpc() { ( cd "$DEV_WORKSPACE" printf '%s\n' "$payload" | \ - BRUNCH_DEV_RPC=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/app/brunch.ts" --mode=rpc + BRUNCH_DEV=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/app/brunch.ts" --mode=rpc ) } ``` -`BRUNCH_DEV_RPC=1` enables `dev.graph.mutateGraph`. Without that env var, the method is absent from discovery and calls return `Method not found`. +`BRUNCH_DEV=1` enables `dev.graph.mutateGraph`. Without that switch, the method is absent from discovery and calls return `Method not found`. RPC output may include `brunch.updated` notifications as separate JSON lines. Filter responses by `id` when scripting: @@ -68,7 +68,7 @@ brunch_rpc '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' \ | jq 'select(.id == 1).result.methods[].method' ``` -For one-shot command-line work, prefer the dev helper. It sets `BRUNCH_DEV_RPC=1`, sends one request, filters notifications, and prints only the response result: +For one-shot command-line work, prefer the dev helper. It sets `BRUNCH_DEV=1`, sends one request, filters notifications, and prints only the response result: ```bash "$REPO/node_modules/.bin/tsx" "$REPO/src/dev/workspace-rpc.ts" \ @@ -128,7 +128,7 @@ JSON ( cd "$DEV_WORKSPACE" - BRUNCH_DEV_RPC=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/app/brunch.ts" --mode=rpc < /tmp/brunch-dev-commit.json + BRUNCH_DEV=1 "$REPO/node_modules/.bin/tsx" "$REPO/src/app/brunch.ts" --mode=rpc < /tmp/brunch-dev-commit.json ) | jq 'select(.id == 90)' ``` @@ -201,11 +201,11 @@ The checked-in reference run `.fixtures/runs/fixture-curation/fixture-curation-2 The TUI-started web sidecar is read-only. It can observe graph updates from the same host, but it does not expose `dev.graph.mutateGraph`. -For agent-addressable dev mutations, run a separate `BRUNCH_DEV_RPC=1 --mode=rpc` command against the same workspace directory. Keep to the one-writer discipline: do not run concurrent dev RPC writes and TUI/agent writes against the same workspace unless you are deliberately testing concurrency behavior. +For agent-addressable dev mutations, run a separate `BRUNCH_DEV=1 --mode=rpc` command against the same workspace directory. Keep to the one-writer discipline: do not run concurrent dev RPC writes and TUI/agent writes against the same workspace unless you are deliberately testing concurrency behavior. ## Troubleshooting -- `Method not found` for `dev.graph.mutateGraph`: check `BRUNCH_DEV_RPC=1` and ensure you are using `--mode=rpc`, not the TUI-started web sidecar. +- `Method not found` for `dev.graph.mutateGraph`: check `BRUNCH_DEV=1` and ensure you are using `--mode=rpc`, not the TUI-started web sidecar. - `graph node code "G1" does not resolve`: inspect `graph.overview` for the selected `specId`; codes are spec-scoped. - Empty `workspace.selectionState`: check that you seeded from the same `$DEV_WORKSPACE` directory you are using for RPC. - Stale or surprising graph state: reset only the scratch workspace with `rm -rf "$DEV_WORKSPACE/.brunch"`, then reseed. diff --git a/memory/CROSS_CUT_PLAN.md b/memory/CROSS_CUT_PLAN.md index 71e7259f..c19c28c2 100644 --- a/memory/CROSS_CUT_PLAN.md +++ b/memory/CROSS_CUT_PLAN.md @@ -329,7 +329,7 @@ cards own their own projection+renderer pairs. It carries a coverage matrix ## Canonical pointers (do not duplicate here) -- Graph mutation engine: `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` +- Graph mutation engine: PLAN `role-safe-graph-mutations`; SPEC D53-L. - Read family design: SPEC D60-L. Runtime state: D40-L. Prompt composition: D58-L. Goals/strategies/lenses: D59-L/D25-L. Graph model lock: D54-L/D56-L/D51-L. Offer-first contract: R16. Capture distinction: SPEC "Capture analysis" design note. diff --git a/memory/PLAN.md b/memory/PLAN.md index a919ee67..050d7673 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -86,10 +86,9 @@ per ledger row: The near-term spine has two tracks. The **context-pipeline coverage trio** remains the elevated product-coverage spine, sequenced in strict dependency order (lock upstream shape before downstream output). `role-safe-graph-mutations` is a graph-mutation grammar frontier that can run before or alongside the trio, and must land before relation-bearing generalized capture or semantic fixture curation rely on the new mutation surface. The `dx-feedback-loops` DX substrate is complete and no longer gates this list. `dx-introspection-live` is its low-conflict follow-on (wire the dormant introspection extension into the real TUI, harden `.fixtures/` topology + `--cwd`, make introspection conversational); it is DX substrate, parallel to the product trio, and not POC-ship-critical. -1. `role-safe-graph-mutations` — **authored graph-mutation grammar**; structural / proving. Fold the role-named edge-surface and semantic graph-mutation cards into one frontier: introduce `mutateGraph` / `mutate_graph`, make create-edge ops role-named, port current `commitGraph` callers, and retire exposed `commitGraph` / `commit_graph` by break-and-repair. Done 2026-06-09: dev curation now uses the same projected-code `mutateGraph` grammar. -2. `projection-shape-coverage` — **PROJECT stage** (`#project`); invariant / no-loss kind. Ledger authored in `src/projections/README.md`. Two sub-steps: (a) **PULL-session prerequisite** — ledger the session read surface (`session/workspace-context`, `workspace-session-coordinator`, `runtime-state`) the session/workspace projections lock against; (b) **earns-its-place audit then lock** — delete/inline the `✗` indirection (`workspace/workspace-context`: single-consumer tag wrapper), resolve the `◐` exchange family (direct-lock vs keep-transitive), and add a shape/no-loss invariant to each `●` survivor (`graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, `workspace/workspace-state`). The graph projection stubs (`overview`, `commit-result`, `reconciliation-needs`) are `export {}` topology stubs, **not** dark implementations — leave them. Upstream of everything else in the trio; do this first so renderer goldens lock against stable shapes. -3. `renderer-golden-coverage` — **RENDER stage** (`#render`); golden + invariant kind. **Depends on `projection-shape-coverage`.** Create the renderer ledger (README claims one that does not exist), extend the preview harness past `graph-neighborhood`, and golden-lock every durable renderer (only `graph/neighborhood` + `session/runtime-frame` are locked; the rest are dark or only transitively covered via the `.pi` adapter). -4. `prompt-composition-golden-coverage` — **COMPOSE stage** (`#compose`); golden + invariant kind. **Depends on `renderer-golden-coverage`.** Add a composed-prompt preview harness, golden-lock partial bodies and a representative composed-prompt matrix (axis × grade × pin) on top of the existing invariants. `elicitation-driver` rides on this stage's locked oracle, so it follows. +1. `projection-shape-coverage` — **PROJECT stage** (`#project`); invariant / no-loss kind. Ledger authored in `src/projections/README.md`. Two sub-steps: (a) **PULL-session prerequisite** — ledger the session read surface (`session/workspace-context`, `workspace-session-coordinator`, `runtime-state`) the session/workspace projections lock against; (b) **earns-its-place audit then lock** — delete/inline the `✗` indirection (`workspace/workspace-context`: single-consumer tag wrapper), resolve the `◐` exchange family (direct-lock vs keep-transitive), and add a shape/no-loss invariant to each `●` survivor (`graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, `workspace/workspace-state`). The graph projection stubs (`overview`, `commit-result`, `reconciliation-needs`) are `export {}` topology stubs, **not** dark implementations — leave them. Upstream of everything else in the trio; do this first so renderer goldens lock against stable shapes. +2. `renderer-golden-coverage` — **RENDER stage** (`#render`); golden + invariant kind. **Depends on `projection-shape-coverage`.** Create the renderer ledger (README claims one that does not exist), extend the preview harness past `graph-neighborhood`, and golden-lock every durable renderer (only `graph/neighborhood` + `session/runtime-frame` are locked; the rest are dark or only transitively covered via the `.pi` adapter). +3. `prompt-composition-golden-coverage` — **COMPOSE stage** (`#compose`); golden + invariant kind. **Depends on `renderer-golden-coverage`.** Add a composed-prompt preview harness, golden-lock partial bodies and a representative composed-prompt matrix (axis × grade × pin) on top of the existing invariants. `elicitation-driver` rides on this stage's locked oracle, so it follows. ### After the trio @@ -106,7 +105,6 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - `topology-readmes-and-boundaries` — small doc/test hardening when a frontier moves files or exposes a boundary; should remain attached to the frontier when possible rather than becoming an abstract cleanup project. - `dev-seed-fixtures` — rich, real seed data for local dev / manual / observer testing: the consolidated seed contract, the `npm run seed` loader, and growing/enhancing fixture sets (Bilal-port + legacy). Its semantic curation mutation slice is folded into / blocked by `role-safe-graph-mutations`; ongoing seed-data maintenance remains low-conflict. - `dx-introspection-live` — DX follow-on to `dx-feedback-loops`: harden the four-role `.fixtures/` topology + `--cwd` launch (D70-L), unify dev gating under `BRUNCH_DEV` and wire the dormant introspection extension into the real TUI (D71-L), and make introspection conversational (A26-L). Three sequenced slices; ready for a scoping thread. Low-conflict with the product trio; touches `.fixtures/`, `src/app/`, `src/dev/`, `src/.pi/extensions/introspection/`. -- `web-design-system-port` — **bounded feature; `Certainty: earned`; done 2026-06-09.** Ported the prior trunk's restrained design system (tokens + card primitives) into `src/web` and re-skinned the three existing read-only views, retiring the agent-invented "warm brunch" aesthetic (D72-L). Read-only presentation only; independent of the POC delivery spine and the context-pipeline trio. Execution record: `memory/cards/web-design-system-port--restyle.md`. ### Horizon @@ -290,7 +288,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Kind:** structural / bounded feature - **Status:** done - **Certainty:** proving -- **Folded scopes:** `memory/cards/graph--role-named-edge-surface.md` and `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` were superseded by the now-consumed chain card for this frontier. +- **Folded scopes:** the former role-named edge-surface and semantic graph-mutation curation cards were consumed by this frontier and deleted during sync; `mutateGraph` / `mutate_graph` is now the one authored grammar. - **Lights up:** one authored graph-mutation grammar across direct agent graph writes, review-set proposal drafts, capture writes, seed-fixture loading, and dev curation RPC. - **Stabilizes:** D51-L/D53-L/D27-L edge-authoring boundary; agents express edges by category + endpoint roles, while `sourceId`/`targetId` stays internal storage geometry derived from `EDGE_CATEGORY_METADATA`. - **Objective:** Replace exposed create-only `commitGraph` / `commit_graph` with `mutateGraph` / `mutate_graph` as the canonical authored mutation command/tool. The grammar supports create/patch/delete operations, uses role-named create-edge variants (`oracle/claim`, `dependency/dependent`, `abstract/concrete`, etc.), normalizes those variants through `EDGE_CATEGORY_METADATA`, and preserves one `CommandExecutor` transaction, one spec-local LSN, one change-log row, and the existing stored edge shape. @@ -441,22 +439,22 @@ 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/scratch/introspection//` run (D69-L/D70-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). + - 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/scratch/introspection//` run (mechanical payload + subjective answer correlated by turn). - Product runs are unaffected: outside dev/introspection mode the introspection extension is absent and the D39-L offline default holds. -- **Verification:** Inner — alias-resolution + faux-harness-factory boot unit tests; a test asserting the introspection extension returns payloads unchanged (observation-only); a sealed-profile test that the extension is absent and offline default intact under product mode. Middle — faux launcher scripted-turn smoke; introspection run-artifact shape assertion under `.fixtures/runs/introspection/`. Outer — manual real-provider introspection session against a live model: ask the model to enumerate and critique tools/skills and eyeball the paired capture (the I38-L discretionary-loading fitness check; tracked, not gated). +- **Verification:** Inner — alias-resolution + faux-harness-factory boot unit tests; a test asserting the introspection extension returns payloads unchanged (observation-only); a sealed-profile test that the extension is absent and offline default intact under product mode. Middle — faux launcher scripted-turn smoke; introspection run-artifact shape assertion under `.fixtures/scratch/introspection/`. Outer — manual real-provider introspection session against a live model: ask the model to enumerate and critique tools/skills and eyeball the paired capture (the I38-L discretionary-loading fitness check; tracked, not gated). - **Cross-cutting obligations:** Preserve the D39-L sealed-profile boundary — introspection loads via the explicit static bundle (never ambient discovery), observes but never mutates payloads, and its offline-lift + extension inclusion are dev-gated, never product defaults. Dev loops are means-of-building and stay distinct from `src/probes/` product-verification probe runs; any durable evidence a dev loop produces lands as a probe run under the `.fixtures/runs/` contract, not a parallel artifact path (D68-L). Pi version bumps are routine adaptation, not deferred migrations; keep the dev alias mirroring pi's own `tsconfig.json` paths list and do not pin back (D67-L). -- **Topology materialization:** `src/dev/` becomes the dev front door (launchers + shared faux-harness factory); the introspection extension lives under `src/.pi/extensions/` per D39-L topology and is wired in `src/.pi/brunch-pi-extensions.ts`; dev source-alias config lives in `vite.config.ts` through the `PI_SOURCE`-gated runtime alias, while base `tsconfig.json` stays paths-free; introspection artifacts are written under `.fixtures/runs/introspection/`. +- **Topology materialization:** `src/dev/` becomes the dev front door (launchers + shared faux-harness factory); the introspection extension lives under `src/.pi/extensions/` per D39-L topology and is wired in `src/.pi/brunch-pi-extensions.ts`; dev source-alias config lives in `vite.config.ts` through the `PI_SOURCE`-gated runtime alias, while base `tsconfig.json` stays paths-free; introspection artifacts are written under `.fixtures/scratch/introspection/`. - **Traceability:** D39-L, D58-L, D67-L, D68-L, D69-L; A25-L; I38-L. - **Design docs:** `memory/SPEC.md` §Development Feedback Loops (DX) and D67-L–D69-L; a new `src/dev/README.md`; `pi-mono/packages/coding-agent/docs/development.md` and `vitest.config.ts` for the alias pattern. -- **Current execution pointer:** Done 2026-06-09. The chain landed the latest-pi bump and `PI_SOURCE`-gated runtime alias, the `src/dev/` faux front door and shared faux harness, and the dev-gated read-only introspection extension plus paired run-artifact launcher. Verification: `npm run verify` (608 tests, tsc build, web build). **Follow-on:** `dx-introspection-live` carries the three gaps this frontier deliberately deferred — the introspection extension is built but not wired into the real TUI launch path, dev runs still write under the operating cwd (the `join(cwd, '.fixtures', …)` nesting defect), and there is no conversational self-report surface yet. +- **Current execution pointer:** Done 2026-06-09. The chain landed the latest-pi bump and `PI_SOURCE`-gated runtime alias, the `src/dev/` faux front door and shared faux harness, and the dev-gated read-only introspection extension plus paired run-artifact launcher. Verification: `npm run verify` (608 tests, tsc build, web build). **Follow-on:** `dx-introspection-live` carries the remaining gaps — the introspection extension is built but not wired into the real TUI launch path, `--cwd` is not yet supported by the main CLI, and there is no conversational self-report surface yet. ### dx-introspection-live @@ -465,26 +463,22 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Kind:** structural / dev-substrate (capability expansion over `dx-feedback-loops`) - **Status:** next (DX substrate; not POC-ship-critical) - **Certainty:** proving -- **Retires:** A26-L — first proof that conversational introspection (agent reporting in chat on tool I/O, understandability, errors/uncertainty, skill activation) is buildable as read-only dev instrumentation without weakening D39-L sealing. -- **Lights up:** Running `BRUNCH_DEV=1 npm run dev -- --cwd .fixtures/workbenches/` boots the *real* Brunch TUI against a chosen fixture workspace with the introspection extension live and the model able to report back, in chat, on how it reads Brunch's tools/skills — a loop that does not exist today (the extension is built but dormant, and dev runs pollute the operating cwd). -- **Stabilizes:** The four-role `.fixtures/` topology (D70-L), the unified `BRUNCH_DEV` dev gate + `--cwd` launch surface (D71-L), and the conversational self-report contract (A26-L) that future introspection work aims from. -- **Objective:** Make introspection actually *usable live* and *conversational*, in three sequenced slices a scoping thread (`ln-scope`) should turn into cards: - 1. **Fixtures topology + dev-launch front door (D70-L).** Formalize `.fixtures/` as a four-role tree (`seeds/` tracked inputs · `workbenches/` launchable workspaces with gitignored `.brunch/` · `runs/` tracked curated evidence · `scratch/` gitignored ephemeral dev output); gitignore `.fixtures/scratch/`; document the roles in `.fixtures/README.md`. Add `--cwd ` parsing to `runBrunchCli` (default `process.cwd()`) so a dev session can target a workbench without `cd`. Decouple the artifact axis from the operating-cwd axis: dev launchers resolve their artifact root to the repo-root `.fixtures/scratch///`, fixing the `join(cwd, '.fixtures', …)` nesting defect in `src/dev/introspection-launcher.ts` (mirror the probe layer's `mkdtemp` cwd + repo-resolved `fixtureRoot` pattern). - 2. **`BRUNCH_DEV` unification + live TUI wiring (D71-L).** Generalize `BRUNCH_DEV_RPC` → one `BRUNCH_DEV` switch that gates *all* dev affordances (dev RPC `dev.*`, introspection-extension registration, scratch routing, scoped offline-default lift). Wire the introspection extension into the real launch path: `src/app/brunch-tui.ts` passes `createBrunchPiExtensions(..., { introspection: { enabled } })` with `enabled` derived from `BRUNCH_DEV` only. Keep both prod-safety gates intact (`src/dev/**` build-excluded; extension present-but-dead unless opted in). - 3. **Conversational introspection expansion (A26-L).** Extend the read-only D69-L tap with `tool_call`/`tool_result` observation; add a small structured self-report schema (not free prose) the agent emits on demand covering tool sent/returned data, input/output understandability, errors/uncertainty, and skill-activation quality; render that report back into the conversation paired to the same scratch run. Choose the in-chat-vs-artifact surface (the open A26-L unknown). +- **Retires:** A26-L — proof that conversational introspection is buildable as a read-only dev session-query-back tool without weakening D39-L sealing. +- **Lights up:** Running `BRUNCH_DEV=1 npm run dev -- --cwd .fixtures/workbenches/` boots the *real* Brunch TUI against a chosen fixture workspace with the introspection extension live and the model able to query exact prior session-log values back into chat for discussion — a loop that did not exist before this frontier (the extension was built but dormant, and dev runs polluted the operating cwd). +- **Stabilizes:** The four-role `.fixtures/` topology (D70-L), the unified `BRUNCH_DEV` dev gate + `--cwd` launch surface (D71-L), and the conversational session-query contract (A26-L) that future introspection work aims from. +- **Objective:** Make introspection actually *usable live* and *conversational*. Preflight hardening has already formalized scratch artifact routing and moved probe faux wiring out of `src/dev/**`; slice 1 added `--cwd `, unified dev gating under `BRUNCH_DEV`, and wired the introspection extension into the real TUI launch path only when enabled. Slice 2 replaces the earlier fixed self-report schema idea with a general read-only `brunch_session_query` tool over `ctx.sessionManager.getBranch()`: predicate match session entries, project exact values, truncate/spill large output, and let the agent echo/discuss those returned bytes in normal chat. The follow-on live-advertisement/payload-query slice makes registered dev query tools actually active under the D40-L allow-list and adds `brunch_introspect_query` over captured provider payloads plus base prompt options. Live-model compliance remains outer-loop fitness, not a product prompt/resource contract. - **Why now / unlocks:** `dx-feedback-loops` built the introspection machinery but left it dormant — the capability the user actually wants (interrogate the live in-product agent about how it reads Brunch's tools/skills, and get clarity feedback in chat) is not reachable yet. This closes that gap and hardens the fixtures topology every dev loop and probe shares. Not POC-ship-critical; a DX substrate that accelerates later product frontiers (especially the I38-L discretionary-loading and tool/skill-clarity questions). - **Acceptance:** - - `.fixtures/` has four documented roles; `.fixtures/scratch/` is gitignored; `.fixtures/README.md` describes seeds/workbenches/runs/scratch and the promotion path (`scratch///` → tracked `runs///`). - - `runBrunchCli` accepts `--cwd ` (defaulting to `process.cwd()`); launching against a `.fixtures/workbenches/` workspace writes dev artifacts to repo-root `.fixtures/scratch///`, **never** to `/.fixtures/…`. + - `runBrunchCli` accepts `--cwd ` (defaulting to `process.cwd()`) so a dev session can target `.fixtures/workbenches/` without `cd`. - A single `BRUNCH_DEV` switch enables dev RPC, introspection registration, scratch routing, and the offline lift together; `BRUNCH_DEV_RPC` is fully retired (no remaining references in code or docs). - With `BRUNCH_DEV=1`, the real Brunch TUI registers the introspection extension last in the `before_provider_request` chain and a live model turn produces a paired scratch run; without `BRUNCH_DEV`, the extension never registers and the D39-L offline default holds. - - The agent can emit a parseable structured self-report (tool I/O, understandability, errors/uncertainty, skill activation) on demand, rendered back paired to the same scratch run; the report is dev instrumentation, never a product behavior. -- **Verification:** Inner — `--cwd` parse unit test; scratch-path resolution test (artifact root is repo-`.fixtures/scratch/`, independent of operating cwd); `BRUNCH_DEV` gating test at the `brunch-tui.ts` call site (extension absent when unset, present + last-ordered when set); build-exclusion assertion for `src/dev/**`; offline-lift save/restore test; self-report schema parse test. Middle — faux-driven introspection scratch-run shape assertion; `tool_call`/`tool_result` observation smoke over a scripted faux turn. Outer — manual `BRUNCH_DEV=1 npm run dev -- --cwd .fixtures/workbenches/` session against a live model: interrogate the agent about tool/skill clarity and eyeball the conversational self-report + paired scratch capture (the A26-L / I38-L fitness check; tracked, not gated). -- **Cross-cutting obligations:** Preserve the D39-L sealed-profile boundary — introspection stays read-only (observes, never mutates payloads), loads only via the explicit `brunch-pi-extensions.ts` bundle (never ambient discovery), and all dev affordances stay behind `BRUNCH_DEV`; the offline lift is save/restore-scoped at the session-construction site, never a naked global `process.env` mutation (I42-L). Dev scratch output stays distinct from `src/probes/` product-verification runs; durable evidence is reached only by explicit promotion into the tracked `runs/` contract (D70-L), not a parallel artifact path. Conversational self-report is dev instrumentation; it must not leak into product behavior or the sealed profile. -- **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. + - The agent can call `brunch_session_query` on demand to return verbatim projected value(s) from predicate-matched session entries, including multi-match structured-exchange pairs/triplets; the agent can call `brunch_introspect_query` to return verbatim projected value(s) from captured provider payloads and base prompt options. Both tools are dev instrumentation, never product behavior. +- **Verification:** Inner — `--cwd` parse unit test; scratch-path resolution test (artifact root is repo-`.fixtures/scratch/`, independent of operating cwd); `BRUNCH_DEV` gating test at the `brunch-tui.ts` call site (extension absent when unset, present + last-ordered when set); build-exclusion assertion for `src/dev/**`; offline-lift save/restore test; dev query-tool find/project/truncation and active-tool advertisement tests. Middle — faux-driven introspection scratch-run shape assertion; faux/tool tests where `brunch_session_query` and `brunch_introspect_query` receive verbatim projected values. Outer — manual `BRUNCH_DEV=1 npm run dev -- --cwd .fixtures/workbenches/` session against a live model: ask the agent to pull exact prior/session and provider-payload values through the dev query tools, echo them in fenced blocks, and discuss tool/skill clarity (tracked, not gated). +- **Cross-cutting obligations:** Preserve the D39-L sealed-profile boundary — introspection stays read-only (observes/queries, never mutates payloads or session state), loads only via the explicit `brunch-pi-extensions.ts` bundle (never ambient discovery), and all dev affordances stay behind `BRUNCH_DEV`; the dev query-tool union is injected from the factory into both runtime active-tool policy and prompt composition, then still loses to blocked tools and registered-tool intersection (D40-L/I42-L). The offline lift is save/restore-scoped at the session-construction site, never a naked global `process.env` mutation. Dev scratch output stays distinct from `src/probes/` product-verification runs; durable evidence is reached only by explicit promotion into the tracked `runs/` contract (D70-L), not a parallel artifact path. Conversational query tools are dev instrumentation; they must not leak into product behavior or the sealed profile. +- **Topology materialization:** `.fixtures/scratch/` (gitignored) has joined `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 provider-payload tap remains in `src/.pi/extensions/introspection/`; conversational query planes live in `src/.pi/extensions/session-query/` and `src/.pi/extensions/introspect-query/`, sharing projection/truncation helpers from `src/.pi/extensions/shared/query-projection.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). +- **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`; `src/.pi/extensions/session-query/README.md`; `src/.pi/extensions/introspect-query/README.md`. +- **Current execution pointer:** Slice 1 done 2026-06-09. Slice 2 done 2026-06-09: `brunch_session_query` is registered only under the slice-1 `introspection.enabled` gate, queries the current session branch read-only, returns one-or-many verbatim projections, truncates/spills large output, and is covered by unit, registration-gating, and faux-turn tests. 2026-06-10 follow-on: the dev query-tool set is explicitly unioned into the D40-L active-tool allow-list under the factory's introspection gate, so `setActiveTools` and composed prompt active-tool lists advertise registered dev query tools while product/default sessions stay on the product set; `brunch_introspect_query` adds read-only projection over captured `before_provider_request` payloads plus base prompt options, sharing projection/truncation helpers with `brunch_session_query`. 2026-06-10 boot-seam smoke oracle landed in `src/app/brunch-tui.test.ts`: real `runBrunchTui` orchestration reaches `createAgentSessionRuntime`, binds the Pi header/cwd/session context, and proves dev query tools are present+active only under `BRUNCH_DEV`. Remaining debt is outer-loop live-model compliance: in a `BRUNCH_DEV=1` real TUI session, ask the agent to pull exact prior/session and provider-payload values through the dev query tools, echo them in fenced blocks, and discuss them. ### dev-seed-fixtures @@ -505,7 +499,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Verification:** Inner — `src/graph/seed-fixtures.test.ts` seeds real fixtures into an in-memory DB and asserts spec/node/edge counts plus spec-local change-log/clock coherence independent of seed order, rejects non-`explicit` basis, and covers the `macro-view-grounded-intent` explicit intent-only variant; `src/probes/fixture-curation-loop.test.ts` proves curation report/artifact evidence detection without an LLM. Outer — `npm run seed` smoke against a fresh cwd; real fixture-curation runs under `.fixtures/runs/fixture-curation/`; seeded-dev-rpc smoke proves `dev.graph.mutateGraph` advances only the mutated spec's overview LSN. - **Topology materialization:** Seed data and throwaway prep scripts live under `.fixtures/seeds/`; the loader lives in `src/graph/seed-fixtures.ts` (graph/ owns `CommandExecutor` orchestration; db/ is imported only by graph/, never the reverse); no seed-only graph runtime the product launch does not use. - **Cross-cutting obligations:** Seeds commit only through `CommandExecutor`; directly-authored items use `basis: explicit` (the retired `accepted_review_set` value is not a basis). Respect multi-spec discipline — each fixture is one spec's own graph (D61-L). Pre-release posture: regenerate fixtures when the schema moves rather than preserving stale shapes. **Known drift:** `docs/praxis/manual-testing.md` still describes the earlier seed system (scenario-arg `npm run seed`, `.brunch/brunch.db`); reconcile it to the current loader (all-sets `npm run seed`, `.brunch/data.db`) when the legacy port (backlog item 2) lands — coordinate with the doc-reconciliation track rather than double-editing. -- **Current execution pointer:** The semantic-mutation curation scope formerly parked at `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` is complete via `role-safe-graph-mutations`, so dev curation no longer mints a second graph-write dialect. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant; fresh runs now prove `mutate_graph`, while the checked-in `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` artifact is historical pre-migration `commit_graph` evidence until regenerated. +- **Current execution pointer:** The semantic-mutation curation scope is complete via `role-safe-graph-mutations`, so dev curation no longer mints a second graph-write dialect. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant; fresh runs now prove `mutate_graph`, while the checked-in `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` artifact is historical pre-migration `commit_graph` evidence until regenerated. - **Traceability:** D4-L, D16-L, D19-L, D20-L, D52-L, D61-L, D62-L, D63-L / I1-L / A4-L, A14-L. - **Design docs:** `.fixtures/seeds/bilal-port/README.md`; `docs/design/GRAPH_MODEL.md`; `docs/praxis/manual-testing.md`. @@ -515,7 +509,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Linear:** unassigned - **Kind:** bounded feature (web presentation) - **Certainty:** earned — the target design exists and works in `../brunch/src/client`; the closure is *materialize the port + delete the invented aesthetic*, not retire an unknown. (Project default is `proving`; this frontier overrides because the design is known.) -- **Status:** done (all three cards landed 2026-06-09; `memory/cards/web-design-system-port--restyle.md`) +- **Status:** done (all three cards landed 2026-06-09; exhausted scope files deleted during sync) - **Objective:** Replace the agent-invented "warm brunch" web aesthetic with the prior trunk's restrained design language (D72-L). Two materializations and one deletion: (a) **tokens** — port the token system into `src/web/styles.css` (Inter + Geist Mono; `ink/sub/hint/rule/wash/tint` ramp + link/plane accents; 11–16px type scale; `--shadow-card` family); (b) **primitives** — copy `DrawerCard`, `KindBadge`, `CountBadge`, `RefBadge` into a new `src/web/components/`, adapted from the old `KnowledgeKind` knowledge-card pattern to this trunk's `NodeKind`/`NodePlane` with a plane-organized accent map; (c) **re-skin** the three existing views (`WorkspaceChrome`, `GraphOverviewPanel`, `SessionPanel`) as a *style + component-pattern port of the views we have* (scope correction, user 2026-06-09) — preserving behavior except invented dead scaffolding — and delete the warm gradients, `backdrop-blur`, oversized radii/shadows, translucent surfaces, and wide-tracked uppercase labels. The non-functional "Focus node" placeholder (never called `graph.nodeNeighborhood`) was removed; the "Edge categories" summary was kept (restyled, user finds it useful). - **Why now / unlocks:** The current web UI's visual language was invented wholesale by the agent that built it and does not match the product's established look. Realigning now keeps the read-only observer surface presentable for manual/observer testing and stops the invented aesthetic from being copied forward into future web views. Independent of the delivery spine — touches no data, RPC, query, subscription, or routing code. - **Acceptance:** @@ -533,7 +527,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai ## Recently Completed - 2026-06-09 `role-safe-graph-mutations` — Done: retired the remaining public `commitGraph` residue, extracted the shared mutation planner/writer out of `CommandExecutor`, and completed the last boundary migration so dev curation now exposes `dev.graph.mutateGraph` with role-named create-edge ops plus projected node-code / selected-spec edge-id resolution. Follow-up closure on the same frontier: reconciled the remaining product probes and current docs to the canonical `mutateGraph` / `mutate_graph` grammar, explicitly marked the checked-in 2026-06-05 fixture-curation artifact as historical pre-migration `commit_graph` evidence, and added role-named edge schema coverage across the Pi tool and dev RPC boundaries. Verified: `npx vitest run src/rpc/handlers.test.ts src/app/brunch.test.ts src/probes/fixture-curation-loop.test.ts src/probes/propose-graph-commit-proof.test.ts src/graph/mutate-graph-edge-schema.test.ts` and `npm run verify`. -- 2026-06-09 `dx-feedback-loops` (FE-825) — Done: bumped Brunch to the pi 0.79 line with a dev-only `PI_SOURCE` runtime alias, consolidated the dev front door around a shared faux harness and scripted faux launcher, and added the dev-gated read-only introspection extension plus `runBrunchIntrospectionTurn()` paired artifact writer under `.fixtures/runs/introspection//`. Product runs omit introspection by default and keep the D39-L offline default; the dev launcher explicitly lifts offline mode for real-provider introspection. Verified: `src/.pi/__tests__/introspection.test.ts`, `src/dev/introspection-launcher.test.ts`, and `npm run verify`. +- 2026-06-09 `dx-feedback-loops` (FE-825) — Done: bumped Brunch to the pi 0.79 line with a dev-only `PI_SOURCE` runtime alias, consolidated the dev front door around a shared faux harness and scripted faux launcher, and added the dev-gated read-only introspection extension plus `runBrunchIntrospectionTurn()` paired artifact writer now routed under `.fixtures/scratch/introspection/`. Product runs omit introspection by default and keep the D39-L offline default; the dev launcher explicitly lifts offline mode for real-provider introspection. Verified: `src/.pi/__tests__/introspection.test.ts`, `src/dev/introspection-launcher.test.ts`, and `npm run verify`. - 2026-06-08 `runtime-affordances-and-legality` — Done (00105108): added `src/projections/session/affordances.ts` owning the pure `(resolvedState, readinessGrade) → legal goal/strategy/lens options + default-on-switch` derivation; lifted the shared grade/AUTO legality tables into `src/projections/session/runtime-policy.ts` and refactored `src/.pi/agents/state.ts` to reuse that single legality source (no client-local reimplementation); added the closed coverage ledger to `src/session/README.md` with `src/session/runtime-affordances-coverage.test.ts` guarding the required agent rows while tripwiring `active-review-set` / `turn-mode` as explicit product-state-gated deferrals. Reconciled D40-L. Verified: `src/projections/session/affordances.test.ts`, `src/session/runtime-affordances-coverage.test.ts`, and `npm run verify`. diff --git a/memory/SPEC.md b/memory/SPEC.md index b10db67d..61dd7511 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -120,7 +120,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness-grade updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | partially validated | D18-L, D26-L, D45-L, I30-L | 2026-06-05 `capture-response-to-graph` validated the product wiring for narrow labeled text facts (`Goal:`, `Context:`, `Constraint:`, `Criterion:`) on `session.submitExchangeResponse`. 2026-06-07 generalized the same explicit-text capture core onto `session.submitMessage`: ordinary labeled user text now appends to transcript truth, commits through `graph/capture` → `CommandExecutor.mutateGraph({createBasis: explicit, ops})`, targets the transcript binding's spec, and publishes graph invalidations; explicit interruptions are transcript-visible but do not capture or silently answer a pending exchange. 2026-06-08 `capture-quality-spike` added a fixed scenario measurement over free prose, file/ref-bearing prose, and implication-heavy prose; the sample extraction report reached precision 1.0 / recall 1.0 with zero false commits, moving generalized capture from parked evidence-gate to a narrow graduate recommendation with an explicit false-commit guard. Readiness-grade capture remains open fitness evidence. | | A24-L | A flat `elicitation_backlog` table (prospective memory) is sufficient to drive elicitor questioning and seed grounding without graph structure — no `unknown` plane/node and no unknown→unknown edges; apparent dependency among open questions is mediated by the claims their resolution produces. | medium | partially validated | D65-L | 2026-06-08 FE-823 materialized the flat table, `createSpec` seed set, `CommandExecutor` create/close mutations, and graph-owned per-spec read-back on the real LSN/change-log seam. Remaining proof is the live per-turn driver plus capture-reflection across elicitation fixtures; if genuine unknown→unknown dependency or rich traversal emerges, promote the table to a plane (rows→nodes, FK pointers→edges). | | A25-L | Tracking the latest `pi-coding-agent` release continuously (via source-alias in dev + package dependency bumps) keeps Brunch adaptable without routinely destabilizing it, because Brunch's pi product-behavior surface is concentrated in a few sealed integration seams (the `src/.pi/` extension bundle and the session/runtime adapters) behind the D39-L profile — even though pi *types* are imported across ~25 files, those are mostly type-only and pass through that small set of seams. | medium | partially validated | D67-L | 2026-06-09 FE-825 bumped Brunch to pi 0.79, kept type/default resolution on installed `dist`, added a `PI_SOURCE`-gated vite/vitest runtime alias to sibling `pi-mono` source, preserved product default sealed-profile/offline behavior, and passed `npm run verify`. Each later pi bump that lands without product-behavior regressions raises confidence; a bump that silently breaks sealed-profile assumptions falsifies it. | -| A26-L | The "conversational introspection" goal — the in-product agent reporting, in chat, on what tools sent/returned, how understandable inputs/outputs were, errors/uncertainty it hit, and how cleanly a skill activated — can be built as a *read-only* extension of the D69-L tap (adding `tool_call`/`tool_result` observation) plus a small structured self-report schema and an in-chat surface, **without** weakening D39-L sealing or making the agent's self-report a product behavior. | medium | open | D69-L | Prove with a dev-gated slice: pi's `tool_call`/`tool_result` hooks can observe tool I/O and errors read-only; the agent can emit a parseable self-report (not free prose) on demand; and the report can render back into the conversation paired to the same scratch run — all behind `BRUNCH_DEV`. Risk: getting a reliable *structured* self-report rather than narration, and choosing the in-chat-vs-artifact surface, are the open unknowns. | +| A26-L | The refined "conversational introspection" goal can be built as a *read-only session-query-back tool*: under `BRUNCH_DEV`, the agent can call `brunch_session_query` over `ctx.sessionManager.getBranch()`, find entries by predicate, project capped dot/`[n]`/`[*]` paths, and surface exact returned values in chat without weakening D39-L sealing or turning self-reporting into product behavior. | medium | validated | D69-L, D71-L | 2026-06-09 `dx-introspection-live` slice 2 replaced the earlier fixed structured self-report/schema idea with `src/.pi/extensions/session-query/`: a dev-gated read-only tool registered only through `createBrunchPiExtensions(..., { introspection: { enabled } })`, covered by find/project/truncation unit tests, default-off/default-on registration tests, and a faux turn that returns verbatim projected session values. Live-model compliance with "call then echo verbatim" remains outer-loop fitness, not a merge gate. | ### Active Decisions @@ -168,7 +168,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes concrete named methods, not vague feature buckets or generic records. The canonical public RPC vocabulary is maintained in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md): `rpc.discover`; `workspace.state`, `workspace.selectionState`, `workspace.activate`; current selected-spec graph reads `graph.overview` and `graph.nodeNeighborhood`; and session methods `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.submitMessage`, `session.exchanges`, and `session.runtimeState`. Reserved future target names such as `graph.changesSince` and graph-adjacent `graph.coherenceSummary` must not appear in discovery until real behavior exists. Each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/workspace.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch-owned command/session layer. `brunch.updated` notifications are first-class JSON-RPC records over WebSocket and stdio, but they are process-local invalidation hints carrying `{topic, specId?, sessionId?, nodeId?, lsn?}`, not canonical truth and not a durable cross-store event spine. Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Dev-only RPC harness methods may exist under `dev.*` when explicitly enabled for local fixture curation or seam testing; they are absent from normal product discovery, absent from read-only sidecars, and still route mutations through the owning command/session layer. They are not public product capabilities and must not occupy `graph.*`, `session.*`, or `workspace.*` names. Retired public names are quarantined in `src/rpc/README.md` §Names absent from current public RPC and must not re-enter product discovery. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model, vague `elicitation.*` / `command.*` public families, and any discovery/dispatch split where a surface describes methods it rejects. - **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders current product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/workspace.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. -- **D72-L — The web client's visual design system is ported from the prior trunk (`../brunch/src/client`), not freshly invented.** The browser surface adopts the earlier product's restrained design language: Inter (sans) + Geist Mono (mono) fonts; the neutral gray ramp (`ink #202020`, `sub #5b5b5b`, `hint #a6a6a6`, `rule #e3e3e3`, `wash #f0f0f0`, `tint #fafafa`) plus link/per-kind accents; a compact 11–16px type scale; and the subtle `--shadow-card` family. Card primitives are ported too — `DrawerCard` (collapsible card-with-drawer), `KindBadge`, `CountBadge`, and the kind-grouped knowledge-card pattern (white header card over `tint` body via `-m-px` overlap). Because the two trunks are independent checkouts with no shared package, the tokens and primitives are **copied** into `src/web/` (new `src/web/components/`), adapted from the old `KnowledgeKind` vocabulary to this trunk's `NodeKind` model: node reference codes reuse `NODE_KIND_METADATA` labels + `kindOrdinal` (D62-L) — which already match the old `G1`/`CTX1`/`AC1` codes — and the per-kind accent is organized **by plane** (`intent`/`oracle`/`design`/`plan`). The graph overview groups `GraphSlice.nodes` by kind and renders `edges` as inline "Links to:" reference badges. Scope is read-only presentation only: no data, RPC, query, subscription, or routing changes, and no new layout surfaces — only re-skinning the three existing views (`WorkspaceChrome`, `GraphOverviewPanel`, `SessionPanel`). Full first-trunk layout fidelity (phase-navigation rail, center acceptance-criteria column, three-pane spec workspace) is explicitly out of scope. Depends on: D10-L, D52-L, D62-L. Supersedes: the agent-invented "warm brunch" web aesthetic — paper/card warm palette, body radial/linear gradients, `backdrop-blur`, oversized radii (`rounded-[2rem]`) and shadows, translucent surfaces (`bg-white/45`), wide-tracked uppercase mono labels, hover-lift "Focus node" cards, and the invented "Edge categories" chip cluster. +- **D72-L — The web client's visual design system is ported from the prior trunk (`../brunch/src/client`), not freshly invented.** The browser surface adopts the earlier product's restrained design language: Inter (sans) + Geist Mono (mono) fonts; the neutral gray ramp (`ink #202020`, `sub #5b5b5b`, `hint #a6a6a6`, `rule #e3e3e3`, `wash #f0f0f0`, `tint #fafafa`) plus link/per-kind accents; a compact 11–16px type scale; and the subtle `--shadow-card` family. Card primitives are ported too — `DrawerCard` (collapsible card-with-drawer), `KindBadge`, `CountBadge`, and the kind-grouped knowledge-card pattern (white header card over `tint` body via `-m-px` overlap). Because the two trunks are independent checkouts with no shared package, the tokens and primitives are **copied** into `src/web/` (new `src/web/components/`), adapted from the old `KnowledgeKind` vocabulary to this trunk's `NodeKind` model: node reference codes reuse `NODE_KIND_METADATA` labels + `kindOrdinal` (D62-L) — which already match the old `G1`/`CTX1`/`AC1` codes — and the per-kind accent is organized **by plane** (`intent`/`oracle`/`design`/`plan`). The graph overview groups `GraphSlice.nodes` by kind and renders `edges` as inline "Links to:" reference badges. Scope is read-only presentation only: no web write paths and no new primary data plane. The shipped port re-skins the existing views (`WorkspaceChrome`, `GraphOverviewPanel`, `SessionPanel`) and adds a root Specs nav over the existing `workspace.selectionState` RPC read; that method-shaped query is refreshed through the normal `brunch.updated` invalidation path (D19-L), not through a web-local store. Full first-trunk layout fidelity (phase-navigation rail, center acceptance-criteria column, three-pane spec workspace) is explicitly out of scope. Depends on: D10-L, D52-L, D62-L. Supersedes: the agent-invented "warm brunch" web aesthetic — paper/card warm palette, body radial/linear gradients, `backdrop-blur`, oversized radii (`rounded-[2rem]`) and shadows, translucent surfaces (`bg-white/45`), wide-tracked uppercase mono labels, hover-lift "Focus node" cards, and the invented "Edge categories" chip cluster. Product RPC / Pi relay model: @@ -258,7 +258,7 @@ 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 subjective modes are separately triggered but share one captured run.** A single Brunch-owned extension, 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. **Subjective** introspection (interrogating the model about clarity) is an injected turn driven from the dev front-door launcher (`session.prompt`) or typed interactively, not a separate slash command; the same extension's tap captures the paired payload+answer. Both modes write to one `.fixtures/scratch/introspection//` run (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, 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. +- **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. - **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). - **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. @@ -295,7 +295,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless Brunch's sealed Pi settings/extension boundary explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch settings/extension boundary by contract tests: ambient resource flags and explicit extension factories are preserved; hostile ambient global/project settings are ignored by the in-memory Brunch settings policy before and after reload; audited Pi settings getters are tracked in `src/.pi/brunch-pi-settings.ts`. Subagent subprocess inheritance remains future coverage under I29-L. | D2-L, D39-L | | I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory; legal option/default affordances are pure projections over resolved runtime state plus readiness grade, not persisted state. | covered (`src/session/runtime-state.test.ts` covers default state, cumulative last-writer-wins posture, mention/world/lifecycle slot projection, and non-linear rejection; `src/rpc/handlers.test.ts` covers explicit-target `session.runtimeState` discovery/params/spec validation; `src/.pi/__tests__/operational-mode.test.ts` covers append/project/switch helpers over the reconciled axis vocabulary, AUTO selection for every objective axis, init idempotence, previous-state values, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state, including selected-spec grade activation for commitment-grade `present_review_set` / `request_review` proposal tools; `src/.pi/extensions/runtime/authority-matrix.test.ts` covers the current POC authority matrix for `elicit-read-only`, blocking `bash`/`edit`/`write`, and structured `needs_human` result representability while leaving A18-L strict built-in suppression as residue; `src/projections/session/affordances.test.ts` covers shared goal/strategy/lens legal options, defaults, AUTO freestyle exclusion, pinned freestyle, and grade-sensitive legality; `src/session/runtime-affordances-coverage.test.ts` guards the required-vs-deferred affordance ledger). | D17-L, D23-L, D40-L, D58-L, D59-L, D66-L | | I27-L | Session display names are presentation metadata only: every Brunch-created session gets a neutral workspace-global default `session_info` label (`Untitled Session N`) at creation, unchanged defaults do not collide across specs in one cwd, later user/generated names may replace the default, and no naming path mutates spec identity, session binding, or graph truth. | planned (creation/boundary tests for workspace-global default allocation across specs and replacement sessions; session-lifecycle naming tests with empty transcript/auth failure/success paths; picker/chrome projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | -| I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/.pi/extensions/exchanges/schemas/`; TypeBox remains valid for unrelated Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export and assert semantic details contracts stay in `src/.pi/extensions/exchanges/schemas/`; the legacy `shared/model.ts` details interface is retired; structured-exchange TypeBox usage is quarantined to the single Pi `TSchema` cast adapter in `src/.pi/extensions/exchanges/pi-schema.ts`; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | +| I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear in D41-L-acknowledged product/protocol schema seams — the structured-exchange schemas (`src/.pi/extensions/exchanges/schemas/`) and the dev-gated query-tool params (`src/.pi/extensions/{session-query,introspect-query}/`), each converting to Pi `TSchema` only through a single per-plane `z.toJSONSchema(..., { unrepresentable: 'throw' })` cast adapter (`exchanges/pi-schema.ts`, `shared/pi-tool-schema.ts`); TypeBox remains valid for unrelated Pi tool parameters (e.g. graph tools), small config/frontmatter contracts, and Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Pi tool parameter schemas authored in Zod must export JSON Schema draft 2020-12 (Zod v4 default), so tuples emit `prefixItems` rather than the draft-07 array-`items`/`additionalItems` form that strict provider validators (Anthropic) reject. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export and assert semantic details contracts stay in `src/.pi/extensions/exchanges/schemas/`; the legacy `shared/model.ts` details interface is retired; structured-exchange TypeBox usage is quarantined to the single Pi `TSchema` cast adapter in `src/.pi/extensions/exchanges/pi-schema.ts`, and the dev query tools to `src/.pi/extensions/shared/pi-tool-schema.ts`; `session-query`/`introspect-query` tests assert the advertised parameter schema is draft 2020-12 with no draft-07 tuple form; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor contract) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec readiness-grade updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | partially covered (`src/graph/capture/structured-response.test.ts` accepts only directly labeled text facts for the current tracer, rejects implication-only prose as `no_capture`, preserves structural diagnostics, `src/probes/capture-response-to-graph-proof.test.ts` proves public RPC response capture into selected-spec graph truth, and `src/probes/submit-message-capture-proof.test.ts` proves the same explicit-text capture path for ordinary `session.submitMessage` turns; reconciliation-needs and readiness-grade capture remain planned) | D18-L, D47-L; A22-L | @@ -306,13 +306,13 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I35-L | Graph context reads support multiple detail levels: a cursory/compact full-graph overview for orientation, and detailed node-neighborhood context with configurable hop depth for focused work. Context builders in `.pi/agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | covered for current POC push path (`getGraphOverview` + `getNodeNeighborhood` in `queries.ts` with 10 tests; `src/.pi/agents/contexts/{graph,node,cwd}.test.ts` covers lens-shaped overview rendering, bounded node-neighborhood rendering, and selected-spec cwd/session/posture context; `src/.pi/__tests__/prompting.test.ts` proves the explicit shell/product prompt path supplies selected-spec-bound graph context to `composeAgentPrompt()`). Pulled context tools are part of the live read surface. | D52-L, D53-L, D58-L | | I36-L | Node `kind` is drawn from a per-plane closed enum structurally validated by the `CommandExecutor`; the intent kind category (basic / structural / reasoning) is a pure function of `kind` and is never stored on the node. | covered (CommandExecutor rejects invalid kind-for-plane; `intentKindCategory` is pure derivation with exhaustive switch; tests in `command-executor.test.ts`) | D54-L, D56-L | | I37-L | `detail` is per-kind validated by the `CommandExecutor`: `decision` and `term` nodes REQUIRE `detail` with their respective sub-schemas; all other kinds must omit `detail`; unknown fields in `detail` are rejected. | covered (detail-required/prohibited/shape tests in `command-executor.test.ts`) | D54-L | -| I38-L | Every Brunch prompt-resource manifest injected for an agent turn is generated from projected runtime state and spec/workspace gates: listed resources are Brunch-owned, readable under the active tool policy, legal for the current `(op_mode × goal × strategy × lens)` / grade / agent allow-list, and off-list resources are not advertised as available. AUTO axes never list illegal choices; pinned axes point to the pinned resource. The shared affordance derivation and prompt manifest filtering use the same grade/AUTO legality source. | covered for current P0 manifest families (`src/.pi/agents/compose.test.ts` covers default header/context/manifest output, AUTO grade/allow-list filtering, pinned singleton resources, illegal pinned grade rejection, and readable `src/.pi/` locations; `src/.pi/__tests__/prompting.test.ts` covers the explicit shell `before_agent_start` product path appending `agents/compose()` output from transcript-projected runtime state and no legacy composer import/resource discovery; `src/.pi/agents/state.test.ts` plus `src/projections/session/affordances.test.ts` cover shared legality/default behavior, including AUTO excluding `freestyle`). FE-825 added a dev-gated introspection loop (`src/.pi/extensions/introspection/` + `src/dev/introspection-launcher.ts`) that records final provider payloads and pairs them with subjective model answers under `.fixtures/runs/introspection//`; probe fitness may still track whether the agent reads selected resources before use. | D39-L, D40-L, D58-L, D59-L, D66-L, D69-L | +| I38-L | Every Brunch prompt-resource manifest injected for an agent turn is generated from projected runtime state and spec/workspace gates: listed resources are Brunch-owned, readable under the active tool policy, legal for the current `(op_mode × goal × strategy × lens)` / grade / agent allow-list, and off-list resources are not advertised as available. AUTO axes never list illegal choices; pinned axes point to the pinned resource. The shared affordance derivation and prompt manifest filtering use the same grade/AUTO legality source. | covered for current P0 manifest families (`src/.pi/agents/compose.test.ts` covers default header/context/manifest output, AUTO grade/allow-list filtering, pinned singleton resources, illegal pinned grade rejection, and readable `src/.pi/` locations; `src/.pi/__tests__/prompting.test.ts` covers the explicit shell `before_agent_start` product path appending `agents/compose()` output from transcript-projected runtime state and no legacy composer import/resource discovery; `src/.pi/agents/state.test.ts` plus `src/projections/session/affordances.test.ts` cover shared legality/default behavior, including AUTO excluding `freestyle`). FE-825 added a dev-gated introspection loop (`src/.pi/extensions/introspection/` + `src/dev/introspection-launcher.ts`) that records final provider payloads and pairs them with subjective model answers under `.fixtures/scratch/introspection//`; `brunch_introspect_query` now makes the captured provider payload/tool schemas/base options queryable in-chat for the same diagnostic plane. Probe fitness may still track whether the agent reads selected resources before use. | D39-L, D40-L, D58-L, D59-L, D66-L, D69-L | | 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 only when `BRUNCH_DEV` opts it in (default product sessions never register the tap, the `/introspect` command, 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. | partially covered (`src/.pi/__tests__/introspection.test.ts` proves default-off registration + last-position ordering when enabled; `tsconfig.build.json` excludes `src/dev`; planned: build-exclusion assertion, `BRUNCH_DEV` gating test at the TUI call site, scratch-path resolution test, offline-lift save/restore test) | D39-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 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 | | 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`. | planned (architecture.test grep guards: kinds.ts has zero import statements; db/ imports from graph/ only via `graph/schema/kinds.ts`; db/schema.ts exports no enum const array; post-`build:web` assertion that the dist-web bundle has no `drizzle`/`sqliteTable` match; lands with the `graph--taxonomy-ownership-leaf` slice) | D52-L, D73-L; I26-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 | ## Future Direction Register @@ -522,7 +522,7 @@ src/.pi/ | **Scratch run** | Gitignored ephemeral dev-loop output under `.fixtures/scratch///`, always resolved to the repo-root `.fixtures/` rather than the operating cwd (D70-L). Becomes durable evidence only by explicit promotion to a tracked `runs///`. | | **Promotion** | The explicit act of moving a `scratch///` run into tracked `runs///` evidence, the only path by which exploratory dev output becomes a curated probe run (D70-L). | | **`BRUNCH_DEV`** | The single env switch gating every dev affordance at once: dev RPC methods, introspection-extension registration, scratch artifact routing, and the scoped offline-default lift (D71-L). Generalizes the former `BRUNCH_DEV_RPC`. | -| **Conversational introspection** | The targeted capability (A26-L) where the in-product agent reports in chat on tool I/O, input/output understandability, errors/uncertainty, and skill-activation quality — built as a read-only extension of the D69-L tap plus a structured self-report schema and in-chat surface, never a product behavior. | +| **Conversational introspection** | The targeted capability (A26-L) where, in a `BRUNCH_DEV` session, the agent can inspect prior session-log values through `brunch_session_query` and captured provider payload/base options through `brunch_introspect_query`, then surface exact returned bytes in chat for discussion. The tools are dev instrumentation, not product behavior; live compliance with exact echoing is outer-loop fitness. | | **Elicitation lens** | Retired term. The interaction-shape axis is now **Strategy** (`step-wise-decision-tree`, `step-wise-disambiguate`, `propose-graph`, `project-graph`) and the topical-focus axis is **Lens** (`intent`, `design`, `oracle`) — two orthogonal session-agent axes (D25-L). The prior free-text catalogue (`step-by-step`, `disambiguate-via-examples`, `propose-scenarios-with-tradeoffs`, `propose-design-shapes`, `propose-oracle-ensembles`, `project-requirements-from-upstream`) is superseded. | | **Single-exchange elicitation flow** | A prompt/answer exchange such as step-by-step questioning or contrastive disambiguation. The elicitor captures high-confidence extractive content synchronously post-exchange; low-confidence implications stay in preface/question material. | | **Batch-proposal flow** | A proposal/review flow with structured entity-draft payloads in structured-exchange proposal details. Durable graph changes land only through review-set approval. | diff --git a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md deleted file mode 100644 index 874aec31..00000000 --- a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md +++ /dev/null @@ -1,228 +0,0 @@ -# Semantic graph mutations for fixture curation - -Frontier: superseded by `role-safe-graph-mutations` for the semantic mutation command; `dev-seed-fixtures` remains the seed-data frontier. -Status: superseded -Mode: chain -Created: 2026-06-05 -Superseded: 2026-06-09 by the completed `role-safe-graph-mutations` frontier. - -## Orientation - -- Supersession note: this card's command-layer create/patch/delete curation scope is folded into `role-safe-graph-mutations` so semantic mutation work lands as the canonical `mutateGraph` / `mutate_graph` grammar instead of a dev-only second graph-write dialect. Keep this file as historical scoping context; the frontier is complete. -- Containing seam: `graph/CommandExecutor` as the single graph-truth mutation boundary. The current creation-only `commitGraph({nodes, edges})` shape is sufficient for `propose-graph` creation, but not for manual curation of persisted seed specs where humans must patch or remove existing graph items. -- Relevant frontier item: `dev-seed-fixtures` because the immediate product need is curated Bilal/reference seed data that can be edited in a local DB and exported back to `.fixtures/seeds/**`. This slice also touches the cross-frontier graph mutation contract (`D4-L`, `D20-L`, `D53-L`), so it must reconcile SPEC/GRAPH_MODEL when built. -- Volatile handoff state: a clean curation workspace exists at `.fixtures/workbenches/bilal-curation`; DB→fixture export and the one-shot RPC helper are already in place (`src/graph/export-fixtures.ts`, `src/dev/workspace-rpc.ts`). FE-809 review-cycle work has landed, but this scope still touches fresh `src/graph/command-executor.ts` and review-set graph code; coordinate before building it in a shared worktree. -- Main open risk: edit/delete semantics can accidentally become a second mutation model. The implementation must preserve one transaction, one spec-local LSN, one change-log row, all-or-nothing structural validation, and no direct DB writes outside `CommandExecutor`. - -Posture: proving (inherited from `dev-seed-fixtures`). - -Frontier-level cross-cutting obligations this slice carries: - -- Preserve D4-L/D20-L: all semantic graph mutations route through the Brunch command layer and return structured command results. -- Preserve D16-L/A4-L: every graph mutation allocates exactly one `{specId, lsn}` through the target spec's existing `graph_clock` row; bare LSNs remain non-comparable across specs. -- Preserve D51-L/D54-L: accepted node `plane`/`kind` and edge `category`/endpoints/`stance` are immutable; changing those means delete+create or supersession, not an in-place patch. -- Preserve D62-L: `kind_ordinal` is monotonic and never reused after deletion or supersession; rendered codes stay projected, not stored. -- Preserve D63-L: `basis` remains approval strength (`explicit | implicit`), not mutation pathway. Editing or deleting an item does not rewrite its original basis. -- Preserve D19-L: curation-only RPC lives under `dev.*`, is enabled only by `BRUNCH_DEV_RPC=1`, and is absent from normal product discovery/read-only sidecars. -- Preserve D52-L: `graph/` owns mutation semantics; `rpc/` and `.pi/extensions/` adapt boundary refs and publish invalidation, never import `db/`. - -## Card 1 — Canonical semantic graph mutation command - -Status: next -Weight: full - -### Target Behavior - -`CommandExecutor` accepts one atomic selected-spec graph mutation batch containing create, patch, and delete operations over accepted graph nodes and edges. - -### Boundary Crossings - -```pseudo -→ graph command input type(s) -→ semantic mutation planner / structural validation -→ CommandExecutor transaction boundary -→ SQLite graph rows + spec-local graph_clock/change_log -→ graph readers / existing product callers -→ graph topology docs + SPEC/GRAPH_MODEL reconciliation -``` - -### Risks and Assumptions - -- RISK: `commitGraph` and the new semantic batch command drift into two validation engines. - → MITIGATION: one private planner/engine owns structural validation and write planning; any creation-only public surface is only an operation-constructor over that engine, or is removed by breakage-driven repair if no longer needed. -- RISK: delete semantics create dangling edges or surprising cascades. - → MITIGATION: node deletion rejects incident edges by default; destructive incident-edge deletion requires an explicit operation option and records deleted edge ids in the same change-log payload. -- RISK: in-place patches weaken immutable graph-shape decisions. - → MITIGATION: patch only mutable fields (`node.title`, `node.body`, `node.source`, `node.detail`; `edge.rationale`); reject `plane`, `kind`, `kindOrdinal`, `category`, endpoints, `stance`, `basis`, and LSN fields in patch payloads. -- RISK: adapters or tests silently depend on raw DB ids for human curation. - → MITIGATION: core may use internal ids after adapter resolution, but boundary tests must prove selected-spec projected-code resolution for the curation path in Card 2. -- ASSUMPTION: hard delete is acceptable for pre-release manual fixture curation. - → IMPACT IF FALSE: curation would need explicit supersession/retirement operations instead of deletion, changing exporter and UI expectations. - → VALIDATE: tests cover both hard delete and supersession preservation through graph-truth export; if the user wants historical curation lineage, scope a separate retention model before using deletes for reference fixtures. - → memory/SPEC.md: D51-L currently says accepted graph items are present-or-absent and category/kind changes are delete+recreate; no new assumption id expected unless this proves false. - -### Posture check - -This proving slice is a tracer bullet on two axes: - -- **Invariants:** it stabilizes the command-layer shape required to edit seed truth without bypassing `CommandExecutor`. -- **Proof of life:** a mixed create/update/delete batch must be visible through normal graph readers and later exportable as seed JSON. - -It deliberately does not attempt a full UI curation workflow or write leases. Those are adjacent surfaces, not required to prove the mutation seam. - -### Acceptance Criteria - -```pseudo tree -semantic graph mutation command -├── creation parity -│ ├── ✓ create-node/create-edge ops can express the existing `commitGraph` creation batch shape -│ ├── ✓ intra-batch refs and existing same-spec refs validate before any write -│ └── ✓ structural-illegal creation batch writes no rows and does not advance graph_clock -├── node patch -│ ├── ✓ patching title/body/source/detail advances only `updated_at_lsn` on the node -│ ├── ✓ invalid per-kind detail is rejected before LSN allocation -│ └── ✓ immutable fields (`plane`, `kind`, `kindOrdinal`, `basis`) are not patchable -├── edge patch -│ ├── ✓ patching rationale advances only `updated_at_lsn` on the edge -│ └── ✓ immutable fields (`category`, `source`, `target`, `stance`, `basis`) are not patchable -├── deletion -│ ├── ✓ deleting an edge removes that edge and records its id in the batch result/change-log payload -│ ├── ✓ deleting a node with incident edges rejects by default before LSN allocation -│ ├── ✓ deleting a node with explicit incident-edge deletion removes the node and its incident edges in one transaction -│ └── ✓ deleting a node does not decrement or reuse `(spec, plane, kind)` ordinals -├── atomicity and audit -│ ├── ✓ mixed create/patch/delete batch consumes one spec-local LSN and one change-log row -│ ├── ✓ any invalid op rejects the whole batch with diagnostics and no partial writes -│ ├── ✓ refs to nodes/edges from a sibling spec are rejected -│ └── ✓ result reports created, updated, and deleted node/edge identities sufficiently for adapters and tests -└── reconciliation - ├── ✓ existing creation callers either use the semantic engine or are updated directly; no second validation path remains - ├── ✓ `src/graph/README.md` describes the surviving command shape - └── ✓ `memory/SPEC.md` / `docs/design/GRAPH_MODEL.md` reconcile D53-L from creation-only `commitGraph` to semantic graph mutation, or explicitly preserve `commitGraph` as a creation-specific product tool over the same engine -``` - -### Verification Approach - -- Inner: `CommandExecutor` unit/regression tests — prove validation, all-or-nothing writes, spec scoping, LSN/change-log behavior, and immutable-field rules. -- Inner: graph-read/export cross-check — after a mixed mutation, `getGraphOverview(..., graph_truth)` and `exportSeedFixture` reflect the post-mutation graph. -- Middle: compile/import repair over existing graph callers — proves the old creation path did not keep an unmaintained validation fork. - -### Cross-cutting obligations - -- Do not introduce a generic records/data API; this remains graph-native command input. -- Do not add a permanent compatibility bridge. A creation-only `commitGraph` facade is acceptable only if it is still a present product tool name and delegates to the semantic engine without owning validation. -- Do not introduce workspace-global writes or compare bare LSNs. - -### Expected touched paths (tentative) - -```pseudo tree -src/graph/ -├── command-executor.ts ~ -├── command-executor.test.ts ~ -├── command-executor/ -│ ├── commit-graph-types.ts ~ -│ ├── commit-graph-batch.ts ~ -│ ├── semantic-mutation-types.ts +? -│ ├── semantic-mutation-planner.ts +? -│ └── semantic-mutation.test.ts +? -├── export-fixtures.test.ts ~ -├── index.ts ~ -└── README.md ~ -src/.pi/extensions/graph/ ? -src/graph/capture/ ? -src/rpc/ ? -docs/design/GRAPH_MODEL.md ~ -memory/SPEC.md ~ -memory/PLAN.md ? -``` - -## Card 2 — Dev curation RPC exposes semantic mutations by projected codes - -Status: next after Card 1 -Weight: full - -### Target Behavior - -A local curation agent can apply semantic graph mutations to a seeded workspace through one dev-only RPC method using projected graph codes instead of raw DB ids. - -### Boundary Crossings - -```pseudo -→ dev JSON-RPC params over stdio -→ selected-spec projected-code resolution -→ CommandExecutor semantic mutation command -→ product-update invalidation `{specId, lsn}` -→ seeded-dev workflow docs / one-shot RPC helper usage -``` - -### Risks and Assumptions - -- RISK: dev RPC becomes an accidental public product API. - → MITIGATION: method name stays under `dev.graph.*`, discovery requires `BRUNCH_DEV_RPC=1`, and read-only sidecars do not expose it. -- RISK: curation payloads require raw IDs and become unusable from UI/readback context. - → MITIGATION: node targets and edge endpoints at the RPC boundary accept projected existing codes (`G1`, `CTX4`, `R2`) and batch refs; raw edge ids may be allowed only where no stable projected edge code exists yet. -- RISK: creation-only `dev.graph.commitGraph` remains as stale docs/API after semantic mutation lands. - → MITIGATION: update `docs/testing/seeded-dev-rpc.md` to present the semantic method as the curation path; keep `dev.graph.commitGraph` only if it is intentionally retained as a tiny create-only convenience over the same command engine. -- ASSUMPTION: a one-shot JSON helper is enough ergonomics for agents before a richer `brunch-dev` CLI. - → IMPACT IF FALSE: curation sessions will stall on command ceremony, and a small command-specific CLI should be scoped next. - → VALIDATE: run manual smoke commands against a temporary seeded workspace and record the command shape in the docs. - -### Posture check - -This is a proving slice because it lights up the real local curation entrypoint without committing to a broad CLI or UI editor. It should be enough for an agent to patch/delete the Bilal specs safely; if not, the failed smoke identifies the next ergonomic slice. - -### Acceptance Criteria - -```pseudo tree -dev curation mutation RPC -├── discovery and access -│ ├── ✓ `rpc.discover` includes the method only when `BRUNCH_DEV_RPC=1` -│ └── ✓ the method is absent from normal/read-only sidecar discovery -├── refs and validation -│ ├── ✓ node targets accept selected-spec projected codes and reject malformed/unresolved codes with field diagnostics -│ ├── ✓ sibling-spec codes do not resolve accidentally -│ ├── ✓ batch create refs can be used by same-batch create-edge ops -│ └── ✓ invalid semantic operations return `structural_illegal` without writes -├── mutation behavior -│ ├── ✓ update-node, delete-edge, and create-node/create-edge work through the same RPC method -│ ├── ✓ success publishes `brunch.updated` with `{topic: "graph.overview", specId, lsn}` or the established graph mutation update payload -│ └── ✓ graph.overview readback shows the post-mutation graph and unchanged sibling-spec LSNs -└── workflow ergonomics - ├── ✓ `src/dev/workspace-rpc.ts` can call the semantic dev mutation method without JSON-RPC stdin ceremony - ├── ✓ `docs/testing/seeded-dev-rpc.md` shows one curation mutation example and one fixture export example - └── ✓ a fresh temporary seed workspace smoke mutates one spec, verifies sibling LSN stability, and exports the mutated spec JSON for inspection -``` - -### Verification Approach - -- Inner: RPC handler/discovery tests — prove dev-only exposure, schema validation, projected-code diagnostics, and product-update payloads. -- Middle: one-shot helper smoke against a temporary seeded workspace — prove the actual command an agent will use works end to end. -- Outer: optional manual curation rehearsal in `.fixtures/workbenches/bilal-curation` only after the user confirms the workspace may be mutated. - -### Cross-cutting obligations - -- Keep one-writer discipline: do not run dev RPC writes concurrently with TUI/agent writes against the same workspace unless deliberately testing concurrency. -- Do not add package scripts or bin aliases while `package.json` is dirty from unrelated work; the helper path is sufficient for this slice. -- Do not capture curated fixtures into reusable seed files until the user has reviewed the UI-curated content. - -### Expected touched paths (tentative) - -```pseudo tree -src/rpc/ -├── methods/dev-graph.ts ~ -├── handlers.test.ts ~ -└── README.md ? -src/dev/ -└── workspace-rpc.ts ~ -docs/testing/seeded-dev-rpc.md ~ -.fixtures/workbenches/ ? (scratch smoke only; do not commit DB state) -``` - -## Foreseeable follow-ons not scoped as build cards yet - -These are intentionally named but not pre-scoped because their exact shape depends on the manual curation discoveries made after Cards 1–2 land. - -1. **Manual Bilal spec curation pass.** Use `.fixtures/workbenches/bilal-curation` and the semantic dev mutation method to repair the current ported specs. Do not encode this as a code card until the user identifies the concrete curation edits or target quality rubric. -2. **Capture curated reference seed set.** Export reviewed DB state into a new seed set such as `.fixtures/seeds/bilal-curated/`; add a README documenting provenance (`bilal-port` + manual Brunch curation) and update seed tests only after the curated files exist. -3. **Richer curation CLI.** If `workspace-rpc.ts` plus JSON payloads remain too cumbersome, scope a tiny command-specific helper (`overview`, `mutate`, `capture`) without touching `package.json` until package-file dirtiness clears or the user asks for a bin/script. -4. **Product tool expansion.** Decide separately whether the agent-facing `commit_graph` tool should remain creation-only (likely) or gain patch/delete operations. Do not silently expose deletion to autonomous agents just because dev curation needs it. diff --git a/memory/cards/graph--role-named-edge-surface.md b/memory/cards/graph--role-named-edge-surface.md deleted file mode 100644 index 982469f6..00000000 --- a/memory/cards/graph--role-named-edge-surface.md +++ /dev/null @@ -1,318 +0,0 @@ -# Role-named edge surface for agent-authored graph mutations - -Frontier: superseded by `role-safe-graph-mutations` -Status: superseded -Mode: chain -Created: 2026-06-08 -Superseded: 2026-06-09 by the completed `role-safe-graph-mutations` frontier. - -## Orientation - -- **Supersession note:** this card's role-named edge-surface design is folded into - the `role-safe-graph-mutations` frontier, where it becomes part of the - canonical `mutateGraph` / `mutate_graph` grammar rather than a standalone - `commit_graph` remediation. -- **Seam:** the *agent-authored edge boundary* — the two places an LLM emits an - edge before `CommandExecutor`: the `commit_graph` Pi tool schema - (`src/.pi/extensions/graph/tool-schemas.ts` → `command-adapter.ts`, - D53-L) and the `project-graph` review-set proposal payload - (`src/graph/review-set.ts`, D27-L). Both currently expose generic - `{ category, source, target, stance?, rationale? }`. -- **Problem (this thread):** `source → target` *sounds* directional but is - meaningless/misleading at the agent boundary. Direction is real only as - endpoint **role** (oracle/claim, dependency/dependent, abstract/concrete, …) - already encoded in `EDGE_CATEGORY_METADATA` (`src/graph/policy/category-policy.ts`). - The agent must today silently remember "proof source = oracle, target = - claim" etc. — a directionally-wrong-yet-structurally-valid error the - executor cannot reject. -- **Decision carried in from discussion:** flip only the *agent boundary* to an - 8-variant role-named discriminated union (category/role granularity, **not** - tuple-specific `requirement_realized_by_module` sprawl). Normalize to the - existing `BatchEdgeInput { category, source, target }` deterministically via - `EDGE_CATEGORY_METADATA`. **Do not** re-orient persistence to - upstream/downstream — storage stays assertion-oriented `source/target` - (see Non-goals). -- **Posture:** proving (no inherited frontier; settled design, low residual - unknown — the normalizer is a few lines over an existing table). The - load-bearing risk is drift between the union's role field names and the - metadata table, retired by an explicit drift test (Card 1). -- **Open risk:** the change edits locked agent-edge-draft wording in D53-L / - D27-L and `docs/design/GRAPH_MODEL.md`; that is durable reconciliation, not - just code (handled per card + Routing). - -## Cross-cutting obligations (whole chain) - -- Preserve the D4-L/D20-L command boundary: agents never touch `db/`; all edge - writes still route through `CommandExecutor.commitGraph`. -- Preserve D51-L storage contract: stored edge identity stays - `(category, sourceId, targetId, stance)`, immutable; **no** persistence / - schema / migration change. -- Preserve D16-L/A4-L one `{specId, lsn}` clock and I34-L all-or-nothing batch - semantics — the union is a pre-executor translation only. -- Keep the closed category set (D51-L) the single source of relation kinds; the - union must not become a relation catalogue. -- `EDGE_CATEGORY_METADATA` stays the **one** source of endpoint-role truth - (its header comment already records it superseded a prior drifted split — do - not reintroduce a parallel role map). - -## Non-goals (explicit) - -- **No upstream/downstream re-orientation of storage.** Rejected in discussion: - impact direction is *undefined* for `association` (1 of 8), `direction.ts` - already derives upstream/downstream from the metadata for the only two - readers (`labels.ts`, `direction.ts`), and assertion orientation is what - `change_log`, supersession acyclicity, and `labels.ts` want. Storage column - names and `BatchEdgeInput` stay `source/target`. -- **No read DTO with `upstream/downstream`.** Deferred and likely unnecessary — - `src/graph/projection/direction.ts` already *is* that projection. Do not add - one in this chain. -- **No tuple-specific variants** (`criterion_proves_requirement`, …). Tuple - phrasing stays in `edgeLabel()` (`labels.ts`). Union stays at category/role. -- **No new `link*` single-edge tools.** GRAPH_MODEL.md's `linkProof`/ - `linkDependency`/… surface stays M5/out of scope; this chain only fixes the - two edge boundaries that ship today (`commit_graph` batch + review-set). - ---- - -## Card 1 — `commit_graph` role-named edge union + driftless normalizer - -Status: next - -### Target Behavior - -The `commit_graph` Pi tool accepts edges as an 8-variant role-named -discriminated union and deterministically normalizes each variant to -`BatchEdgeInput { category, source, target }` via `EDGE_CATEGORY_METADATA`, -with no change to stored edge shape. - -### Boundary Crossings - -``` -→ LLM tool call (commit_graph params) -→ CommitEdgeSchema (TypeBox discriminated union, role-named) [tool-schemas.ts] -→ translateCommitGraph → normalizeEdgeDraft(category, roleFields) [command-adapter.ts] -→ EDGE_CATEGORY_METADATA[category].{sourceRole,targetRole} [category-policy.ts] -→ BatchEdgeInput { category, source, target, stance?, rationale? } (unchanged) -→ CommandExecutor.commitGraph (unchanged) -``` - -### The union (category/role level) - -``` -dependency { dependency, dependent } -proof { oracle, claim, stance } -support { support, claim, stance } -realization { abstract, concrete } -boundary { boundary, subject } -composition { whole, part } -supersession{ successor, predecessor } -association { a, b } // peer/peer; arbitrary storage orientation -``` - -Normalization rule (single, table-driven): for category `C`, the field named -`EDGE_CATEGORY_METADATA[C].sourceRole` → `source`, the field named -`.targetRole` → `target`. (`association` peer/peer: map `a`→source, `b`→target.) -Each variant still carries optional `rationale`; `stance` only on -`proof`/`support`. - -### Risks and Assumptions - -``` -- RISK: union role field names drift from EDGE_CATEGORY_METADATA roles - → MITIGATION: drift test (acceptance ✓ below) pins every variant's two - role field names to that category's sourceRole/targetRole; normalizer - reads the table, never a hand-copied map. -- RISK: TypeBox discriminated-union JSON Schema is awkward for the LLM / - Pi `defineTool` typing (D41-L: schemas must stay JSON-representable) - → MITIGATION: use a tagged union keyed on `category` (StringEnum literal - per variant) — plain JSON Schema oneOf; add an export/parse test that the - params schema still satisfies the Pi `TSchema` adapter and round-trips. -- ASSUMPTION: EDGE_CATEGORY_METADATA endpoint roles are the correct agent-facing - role vocabulary (oracle/claim, abstract/concrete, whole/part, …). - → IMPACT IF FALSE: rename in one table + union; localized. - → VALIDATE: matches docs/design/GRAPH_MODEL.md §Per-category policy table. -- ASSUMPTION: dev RPC (`rpc/methods/dev-graph.ts`) builds CommitGraphInput - directly (not via the tool schema), so it is unaffected. - → IMPACT IF FALSE: add it to touched paths at build. - → VALIDATE: grep confirms it constructs BatchEdgeInput directly; leave as-is. -``` - -### Posture check (proving) - -Scores on **invariants** (locks the agent-edge-draft seam to the metadata -table via a drift oracle) and **uncertainty** (retires the "agent orients -source/target wrong" failure mode named in this thread). It lights up the new -role-named path end-to-end through a real tool call. Build it. - -### Acceptance Criteria - -``` -✓ tool-schema-edge-union — commit_graph CommitEdgeSchema is an 8-variant - category-tagged union; submitting `{category:"proof", oracle, claim, stance}` - normalizes to source=oracle, target=claim in the resulting BatchEdgeInput. -✓ normalizer-all-categories — for every EdgeCategory, a role-named draft - normalizes to source/target matching EDGE_CATEGORY_METADATA sourceRole/ - targetRole (table-driven over all 8). -✓ role-name-drift-guard — a test asserts each union variant's two endpoint - field names equal that category's {sourceRole,targetRole} (peer/peer ↔ a/b - mapping asserted explicitly); fails if a variant or the table drifts. -✓ stance-locality — stance accepted only on proof/support variants; rejected - (structural_illegal or schema reject) elsewhere. -✓ schema-export-roundtrip — CommitGraphParams still passes the Pi `TSchema` - adapter / JSON-Schema export used for the tool (D41-L). -✓ commit-graph-batch-unchanged — existing commit-graph-batch executor tests - still pass with no edits (storage path untouched). -``` - -### Verification Approach - -``` -- Inner: vitest unit — normalizer + drift table + schema parse/export. -- Middle: src/.pi/__tests__/graph-tools.test.ts — real tool registration emits - role-named edges and persists correct source/target via CommandExecutor. -- Outer: optional — re-run a propose-graph-commit probe to confirm an LLM emits - the role-named union (not required to land; existing A14-L probes cover the - commit path). -``` - -### Cross-cutting obligations - -See chain-level section. Specifically: metadata stays the single role source; -no storage/schema change; I34-L all-or-nothing preserved. - -### Expected touched paths (tentative) - -``` -src/graph/policy/ -├── category-policy.ts ~ # + normalizeEdgeDraft / endpointForRole helper -└── category-policy.test.ts ~ # + drift guard + all-category normalize -src/graph/index.ts ~ # export normalizer + role types/field names -src/.pi/extensions/graph/ -├── tool-schemas.ts ~ # CommitEdgeSchema → category-tagged union -├── command-adapter.ts ~ # translateCommitGraph uses normalizer -└── __tests__/ - └── graph-tools.test.ts ~ # (under src/.pi/__tests__) role-named edges -src/rpc/methods/dev-graph.ts ? # confirm builds BatchEdgeInput directly; likely untouched -docs/design/GRAPH_MODEL.md ~ # commitGraph example edges → role-named; reconcile §Agent-facing surface -memory/SPEC.md ~ # D53-L wording: agent edge drafts are role-named; + invariant (see Traceability) -``` - ---- - -## Card 2 — review-set proposal edge drafts adopt the same union - -Status: next (after Card 1; reuses Card 1's normalizer) - -### Target Behavior - -The `project-graph` review-set proposal payload carries edge drafts as the same -role-named union, and `translateReviewSetPayloadToCommitGraph` normalizes them -to `BatchEdgeInput` through Card 1's shared normalizer. - -### Boundary Crossings - -``` -→ review-set proposal payload (LLM-authored, D27-L) -→ ReviewSetEdgeDraft union (role-named, mirrors Card 1) [review-set.ts] -→ resolve endpoints (draftId | existingCode) per role field -→ normalizeEdgeDraft (shared, Card 1) [category-policy.ts] -→ BatchEdgeInput → dryRun/accept via CommandExecutor (unchanged, D27-L/I20-L) -``` - -### Risks and Assumptions - -``` -- RISK: a separate TypeBox/Zod review-set payload schema exists in - src/.pi/extensions/exchanges/schemas/ (present_review_set / request_review) - and must change in lockstep with the runtime shape in review-set.ts. - → MITIGATION: at build, grep exchanges/schemas/{present,request,shared}.ts - for the edge-draft shape; update both or confirm review-set.ts is the only - validator. Dry-run parity (I20-L) test must still hold. -- ASSUMPTION: review-set endpoint refs ({draftId} | {existingCode}) are - orthogonal to the role rename — only the *field names* change, not ref shape. - → IMPACT IF FALSE: localized to resolveReviewSetEndpoint. - → VALIDATE: ReviewSetEndpointRef stays a draftId/existingCode union. -``` - -### Posture check (proving) - -Closure-flavored but proving: it **canonicalizes** the agent edge vocabulary -across *both* edge boundaries so the role-named union is the one way an agent -ever expresses an edge. Lands the same seam on the D27-L path; drift guard from -Card 1 extends to cover it. Build it. - -### Acceptance Criteria - -``` -✓ review-set-edge-union — a review-set payload with role-named edge drafts - (e.g. {category:"realization", abstract, concrete}) translates to - BatchEdgeInput with source=abstract, target=concrete. -✓ review-set-dryrun-parity — dryRunAcceptReviewSet still rejects structurally - illegal role-named drafts and surfaces non-reviewable diagnostics (I20-L). -✓ schema-lockstep — if an exchanges/schemas payload schema exists, it accepts - the role-named union and rejects generic source/target; otherwise a test - asserts review-set.ts is the sole edge-draft validator. -✓ no-generic-source-target — generic {category, source, target} edge drafts are - rejected at both boundaries (grep/lint or schema-reject test). -``` - -### Verification Approach - -``` -- Inner: vitest — review-set translation over role-named drafts; reuse Card 1 normalizer. -- Middle: src/graph/review-set.test.ts — dry-run/accept parity, projected-code - resolution, invalid-proposal rejection (existing suite, updated inputs). -- Outer: optional — project-graph-review-cycle probe regen if its fixture - encodes edge drafts (check .fixtures/runs/project-graph-review-cycle/). -``` - -### Cross-cutting obligations - -See chain-level section. Plus: preserve I20-L (only dry-run-valid proposals -surface as reviewable) and I18-L lens metadata on the payload. - -### Expected touched paths (tentative) - -``` -src/graph/ -├── review-set.ts ~ # ReviewSetEdgeDraft → role-named union; translate via shared normalizer -└── review-set.test.ts ~ -src/.pi/extensions/exchanges/schemas/ -├── present.ts ? # present_review_set payload edge-draft shape (confirm at build) -├── request.ts ? # request_review payload (confirm at build) -└── shared.ts ? -docs/design/GRAPH_MODEL.md ~ # review-set edge-draft shape → role-named -memory/SPEC.md ~ # D27-L wording: edge drafts role-named -``` - ---- - -## Traceability (durable reconciliation — do before/at build) - -This chain edits locked decisions; reconcile canonical docs as part of landing, -not as an afterthought: - -- **D53-L** — restate the `commit_graph` `edges` shape as the role-named union - (was `{category, source, target}`); note deterministic normalization to - stored `source/target` via `EDGE_CATEGORY_METADATA`. -- **D27-L** — restate review-set edge drafts as the role-named union (was - `{category, source, target, stance?, rationale?}` over draftId / existingCode). -- **D51-L** — unchanged storage contract; add a sentence clarifying that - endpoint *roles* are the agent vocabulary while `sourceId/targetId` remain the - immutable stored geometry. -- **New invariant (propose)** — "Agents express edges only by category + - endpoint roles; `source/target` is internal storage geometry derived - deterministically from `EDGE_CATEGORY_METADATA`. Union role field names are - test-pinned to that table." Tie to D51-L/D53-L/D27-L, A14-L. -- **A14-L** — the structural-legality assumption now includes role-named edge - drafts; existing probes still cover the commit path. -- `docs/design/GRAPH_MODEL.md` §"Agent-facing command surface" + the - `commitGraph` example — update both edge examples to role-named. - -Because this is durable change to locked decisions, the strictly-correct path is -a short `ln-spec` pass (D53-L/D27-L rewording + the new invariant) and, if it -should be its own Linear issue/branch, an `ln-plan` frontier promotion per -project AGENTS.md workflow. The cards above are buildable as soon as that -reconciliation is agreed; the SPEC/doc edits are listed in each card's touched -paths so they can also be folded into the build commit if you prefer to keep it -as one move. diff --git a/memory/cards/graph--taxonomy-ownership-leaf.md b/memory/cards/graph--taxonomy-ownership-leaf.md deleted file mode 100644 index 075a7bae..00000000 --- a/memory/cards/graph--taxonomy-ownership-leaf.md +++ /dev/null @@ -1,165 +0,0 @@ -# Graph taxonomy ownership leaf (close the web→drizzle bundle leak) - -Frontier: n/a (graph/db shared-seam hardening) -Status: active -Mode: single -Created: 2026-06-09 - -## Orientation - -- **Seam:** the `db/ ↔ graph/ ↔ web/` taxonomy boundary. Domain enum `const` arrays - (`INTENT_KINDS`, `EDGE_CATEGORIES`, `NODE_BASES`, readiness/lens/backlog enums, …) - currently live in [`src/db/schema.ts`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/db/schema.ts) - and flow *outward* (D52-L, I26-L: `graph/index.ts` re-exports them so other layers - avoid importing `db/` directly). -- **Frontier item:** none — this is a shared-seam hardening fix, not a PLAN frontier. - Governing decisions: **D52-L** (source topology / dependency direction) and **I26-L** - (schema-library import scoping + "no direct `db/` imports outside `graph/`"). -- **Open risk / why now:** [`src/web/components/node-card.tsx`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/web/components/node-card.tsx#L1-L6) - value-imports `NODE_KIND_METADATA` / `formatGraphNodeCode` from - [`graph/schema/nodes.ts`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/graph/schema/nodes.ts#L11), - which value-imports the `*_KINDS` arrays from `db/schema.ts`, which imports `drizzle-orm` - and evaluates `sqliteTable(...)`. Result: **drizzle is in the browser bundle** — - confirmed: `dist-web/assets/brunch-web.js` matches `drizzle|sqliteTable`. Under - `verbatimModuleSyntax: true`, even type-only `import {}` lines are emitted at runtime. -- **Posture:** earned. The design is settled — user approved **Option A** (domain owns - taxonomy; db is a consumer) over the cheap `import type` patch and over inline-duplication. - Closure move: materialize a drizzle-free taxonomy leaf and invert the one db edge. - -## Target Behavior - -The web build target transitively contains no Drizzle/persistence code because all domain -enum taxonomy lives in a drizzle-free `src/graph/schema/kinds.ts` leaf that both `db/` and -`graph/` import, and `db/schema.ts` no longer owns or exports any enum `const` array. - -## Boundary Crossings - -``` -→ src/graph/schema/kinds.ts (new drizzle-free leaf: zero imports) -→ src/db/schema.ts (imports enums from kinds.ts for column constraints) -→ src/graph/schema/{nodes,edges,elicitation-backlog}.ts (derive types from kinds.ts) -→ src/graph/index.ts (re-export enums from kinds.ts, not db/schema.ts) -→ graph runtime consumers (command-executor*, review-set, rpc/methods/dev-graph) -→ src/web/components/node-card.tsx (unchanged source; leak closes transitively) -→ src/graph/architecture.test.ts (new guards) -``` - -## Risks and Assumptions - -``` -- RISK: db/schema.ts importing from graph/schema/kinds.ts introduces a layer cycle. - → MITIGATION: kinds.ts is a pure zero-import leaf (constants only, no `graph/atoms`, - no drizzle). graph/* → db/schema → kinds is acyclic because kinds is a sink. Add a - leaf-purity guard test asserting kinds.ts has zero import statements. -- RISK: db now depends on graph/ — contradicts D52-L's "graph imports from db; no other - layer imports db directly" clause and db/README's "enums flow outward from db". - → MITIGATION: this is the approved decision change. Refine D52-L + I26-L + both READMEs - in the same slice so the inversion is recorded, and constrain db→graph to the single - kinds.ts edge via a guard test. -- ASSUMPTION: every enum array currently in db/schema.ts is domain taxonomy, not a storage - concept, so all of them belong in kinds.ts (node kinds, NODE_BASES, EDGE_CATEGORIES, - EDGE_STANCES, READINESS_GRADES, READINESS_BANDS, LENS_AFFINITIES, - ELICITATION_BACKLOG_STATUSES) plus a named NODE_PLANES (currently inlined 3× as - ['intent','oracle','design','plan']). - → IMPACT IF FALSE: an array that is genuinely storage-only would be miscategorized; - low blast radius (move it back, db re-owns it). - → VALIDATE: each is consumed by a graph domain type or validator (confirmed: - command-executor.ts, schema/{nodes,edges,elicitation-backlog}.ts), and db uses them - only as `text({ enum })` column constraints. -``` - -## Posture check (earned) - -- **Closes:** the architectural fork from the prior thread (Option A vs `import type` vs - inline) and the active web→drizzle bundle leak. -- **Materializes:** the "domain owns taxonomy, db is a consumer" decision into topology - (`graph/schema/kinds.ts`). -- **Canonicalizes:** one source of truth for domain enum vocabulary, at a drizzle-free path. -- **Deletes/retires:** the enum `const` array definitions in `db/schema.ts`; the inlined - `['intent','oracle','design','plan']` plane triplication. -- **Locks in:** web build target is drizzle-free (new invariant + guard). - -## Acceptance Criteria - -``` -✓ kinds-leaf-purity (architecture.test) — src/graph/schema/kinds.ts contains zero import statements -✓ db-imports-only-kinds (architecture.test) — db/ imports from graph/ only via graph/schema/kinds.ts -✓ no-enum-arrays-in-db (architecture.test or grep) — db/schema.ts exports no `*_KINDS`/`EDGE_*`/`NODE_BASES`/readiness/lens/backlog const array -✓ web-bundle-drizzle-free — after `npm run build:web`, dist-web bundle contains no `drizzle`/`sqliteTable` -✓ existing graph suite green — command-executor, seed-fixtures, role-named-edge, category-policy tests pass with enums sourced from kinds.ts/graph index -✓ existing I26-L boundary test still green — no src/ module outside graph/ imports from db/ -✓ npm run verify green (oxlint type-aware + oxfmt + vitest + build) -``` - -## Verification Approach - -``` -- Inner: unit/architecture grep tests — kinds.ts leaf purity; db→graph edge constrained to - kinds.ts; db/schema.ts exports no enum arrays; existing I26-L boundary test unchanged-green. -- Middle: `npm run build:web` then assert dist-web JS has no `drizzle|sqliteTable` match - (the regression oracle for the leak). Consider a small scripted check so it runs in CI. -- Outer: none required (read-only presentation already covered by web suite). -``` - -## Cross-cutting obligations - -``` -- Preserve I26-L: non-graph layers still must not import db/ directly; they consume enum - taxonomy via graph/index.ts (now sourced from kinds.ts) — keep architecture.test green. -- Preserve D52-L's other directed edges; only the db→graph/schema/kinds taxonomy edge is new. -- Preserve D62-L canonical node reference codes (NODE_KIND_METADATA + kindOrdinal) — do not - relocate NODE_KIND_METADATA out of nodes.ts; only the raw kind/plane/basis arrays move. -- Keep NODE_KIND_METADATA, formatGraphNodeCode, parseGraphNodeCode, intentKindCategory in - nodes.ts (web depends on them; they are derivations, not raw taxonomy). -- Pre-release posture (free-rewrite): repoint all consumers; do not leave compatibility - re-exports of the moved arrays in db/schema.ts. -``` - -## Required canonical reconciliation (land atomically with the code) - -This slice changes a load-bearing decision, so SPEC/READMEs update in the same commit: - -``` -- D52-L: add that src/graph/schema/kinds.ts is a drizzle-free, zero-import taxonomy leaf; - canonical domain enum vocabulary lives there (not db/schema.ts); db/ may import from it - as its single sanctioned graph-ward edge; reaffirm web/ is a standalone drizzle-free build. -- I26-L (or a new invariant): the enum re-export seam sources from kinds.ts; the web build - target transitively contains no Drizzle code (guarded). -- src/db/README.md: replace "enums flow outward from db/schema.ts; db owns the shared enum - const arrays" with "domain taxonomy is owned by graph/schema/kinds.ts; db imports the enums - it persists." -- src/graph/README.md: record kinds.ts ownership in the layout/dependency notes. -``` - -## Expected touched paths (tentative) - -``` -src/graph/schema/ -├── kinds.ts + -├── nodes.ts ~ -├── edges.ts ~ -└── elicitation-backlog.ts ~ -src/graph/ -├── index.ts ~ -├── command-executor.ts ~ -├── review-set.ts ~ -├── architecture.test.ts ~ -├── README.md ~ -└── command-executor/ - └── create-graph-batch.ts ~ -src/rpc/methods/dev-graph.ts ~ -src/db/ -├── schema.ts ~ -└── README.md ~ -src/graph/seed-fixtures.test.ts ~ -src/graph/policy/category-policy.test.ts ~ -src/graph/command-executor/role-named-edge-draft.test.ts ~ -memory/SPEC.md ~ -memory/PLAN.md ? (frontier note only if needed) -``` - -## Traceability - -- Refines: **D52-L** (dependency direction), **I26-L** (import scoping). -- Preserves: D54-L, D56-L, D62-L, D63-L, D64-L (taxonomy semantics unchanged; only location moves). -- Origin: cross-thread architecture review (omega builder + this thread), user-approved Option A 2026-06-09. diff --git a/memory/cards/runtime-affordances--coverage-ledger.md b/memory/cards/runtime-affordances--coverage-ledger.md deleted file mode 100644 index 912f7bae..00000000 --- a/memory/cards/runtime-affordances--coverage-ledger.md +++ /dev/null @@ -1,124 +0,0 @@ -# Runtime affordances coverage ledger - -Frontier: runtime-affordances-and-legality -Status: active -Mode: single -Created: 2026-06-08 - -## Orientation - -- **Containing seam:** the runtime posture legality/default surface. Truth is the append-only `brunch.agent_runtime_state` projection; the legality/default rules already live in `src/projections/session/runtime-policy.ts` (allowed lists + `defaultStrategy`/`defaultLens`/`defaultGoal`) and `src/.pi/agents/state.ts` (`AUTO_EXCLUDED_STRATEGIES`, `isGradeLegal`/grade gating, `selectAxisResources`). The current RPC projection in `src/rpc/methods/session.ts` exposes only the *current* selection per axis (`agent.strategy`/`lens`/`goal`), **not** the available options or the default-on-switch value. -- **Input-surface note (load-bearing):** legality is **not** derivable from `ResolvedBrunchAgentState` alone. That type (`runtime-policy.ts`) carries only mode/role/axis selections + definitions; the grade gate is a *separate* input — see `BrunchPostureToolPolicyInput` in `src/.pi/agents/state.ts` (`{ state: ResolvedBrunchAgentState; readinessGrade: ReadinessGrade }`) and the grade-sensitive filter in `selectAxisResources` / `isGradeLegal`. The shared derivation must therefore take **both** resolved state and readiness grade: `affordances(resolvedState, readinessGrade)`. Do not certify a grade-independent function as the legality seam. -- **Relevant frontier item:** `runtime-affordances-and-legality` (PLAN.md §Frontier Definitions). This is a **coverage** frontier in the same mold as the landed `graph-observed-shapes` — a closed enumerated ledger of which affordance shapes are canonical per consumer, guarded by a drift test, plus one shared derivation so no client re-implements legality. It is buildable now; the legality/default tables already exist. -- **Volatile handoff state:** no `HANDOFF.md`. The `snapshot`→`reads/projections/renderers` migration (35eff395) is landed; use current paths. `graph-observed-shapes` (85e73ba7) is the precedent: `src/graph/README.md` owns its ledger and `src/graph/observed-shapes-coverage.test.ts` guards required-subset coverage **without shipping any transport shape it does not need**. Mirror that discipline exactly. -- **Main open risk:** scope creep into building TUI/web posture-switch UI, or into an xstate/persisted state machine. This card is a **coverage ledger + one pure derivation**, not a control surface. The genuinely product-state-gated rows (`active-review-set` affordances, freestyle-vs-structured `turn-mode`) must stay tripwired in the ledger, not built. - -Posture: proving (inherited from `runtime-affordances-and-legality`). - -Frontier-level cross-cutting obligations this slice carries: - -- Keep runtime truth append-only in `brunch.agent_runtime_state`; affordances are **pure derivations** over the shared legality/default tables, never new persisted state. -- Do not add xstate or a persisted machine (PLAN cross-cutting obligation; SPEC D40-L projection-as-truth). -- Do not duplicate legality/default rules in any client (`web/`, `rpc/`, TUI); the derivation is the single owner. -- Preserve D66-L: `freestyle` is AUTO-excluded; the affordance derivation's available-under-AUTO set must reflect that, matching `AUTO_EXCLUDED_STRATEGIES`. - -## Full scope card - -### Target Behavior - -A single Brunch-owned `affordances(resolvedState, readinessGrade)` derivation reports, per posture axis (goal / strategy / lens), the legal options and the default-on-switch value from the existing shared legality/default tables (including the grade gate), and a closed coverage ledger in `src/session/README.md` records which affordance rows each consumer (agent, RPC, web) requires versus defers, guarded by a drift test. - -### Boundary Crossings - -``` -→ resolved runtime state (ResolvedBrunchAgentState from src/projections/session/runtime-policy.ts) - + readiness grade (ReadinessGrade — the separate grade input, cf. BrunchPostureToolPolicyInput) -→ shared affordance derivation (new pure function over allowed lists + defaults + AUTO + grade rules) -→ coverage ledger (src/session/README.md): required vs deferred affordance rows per consumer -→ drift guard test (asserts the ledger's required subset against the real derivation + RPC schema) -``` - -### Risks and Assumptions - -``` -- RISK: the derivation re-implements legality instead of reusing src/.pi/agents/state.ts logic - → MITIGATION: extract/lift the existing allowed + AUTO-excluded + isGradeLegal logic into the - shared projection seam (projections/session) and have agent manifest composition consume it, - OR have the new derivation import the same source-of-truth tables; do not fork the rules. -- RISK: scope drifts into shipping the affordance shape onto the RPC/web transport - → MITIGATION: follow graph-observed-shapes — the ledger may mark a row "web-eligible deferred"; - shipping a transport shape is a separate later slice, not this card. -- ASSUMPTION: the legality/default knowledge needed for affordances is fully present in - runtime-policy.ts + state.ts and needs no new product state. - → IMPACT IF FALSE: a required affordance row would depend on active-review-set / turn-mode - product state that does not exist yet; that row is then a tripwired deferred row, not a gap. - → VALIDATE: enumerate the ledger rows first; any row that cannot be derived from current tables - is marked product-state-gated with its tripwire, not built. - → memory/SPEC.md D40-L, D59-L -``` - -### Posture check - -Proving slice. It scores on **invariants** (locates and stabilizes the affordance-derivation seam as the single owner of legality/default truth across transports) and **proof of life** (a shared `affordances(resolvedState, readinessGrade)` derivation exists and is consumed where legality was previously implicit). It retires the fog that runtime affordances are unbuildable until a UI pass: the ledger proves how much is derivable now. No high-impact assumption is left unretired — rows that cannot be derived become explicit tripwired deferrals. - -### Acceptance Criteria - -``` -✓ affordances-derivation.test.ts — affordances(resolvedState, readinessGrade) returns, per axis - (goal/strategy/lens), the legal option set and the default-on-switch value, matching runtime-policy.ts defaults. -✓ affordances-derivation.test.ts — under AUTO the strategy options exclude `freestyle` - (parity with AUTO_EXCLUDED_STRATEGIES); under an explicit pin the pinned legal value is reported. -✓ affordances-derivation.test.ts — varying readinessGrade changes the legal option set exactly as - isGradeLegal dictates (grade-illegal options excluded); proves the grade input is load-bearing, not ignored. -✓ runtime-affordances-coverage.test.ts — the ledger's required affordance rows per consumer - (agent/RPC/web) are covered by the derivation and the RPC session schema; deferred rows are not forced. -✓ runtime-affordances-coverage.test.ts — `active-review-set` and `turn-mode` rows are present as - deferred/tripwired entries, not as built affordances. -✓ No client (web/, rpc/, TUI) re-derives availability/legality locally; legality has one owner. -✓ No xstate, no persisted machine, no new runtime-state table. -``` - -### Verification Approach - -``` -- Inner: unit tests (oracle: derivation against fixtures) — affordances() vs hand-specified legal/ - default/AUTO/grade expectations over ResolvedBrunchAgentState fixtures. -- Inner: drift/coverage test (oracle: ledger-vs-reality) — required-subset coverage like - graph-observed-shapes; fails if a required row loses its derivation or RPC field. -- Middle: only if a transport shape is actually adopted in this card (default: not adopted). -``` - -### Cross-cutting obligations - -``` -- Affordances are pure derivations over shared tables; runtime truth stays append-only. -- No client-side legality reimplementation; single owner for availability/default rules. -- Preserve D66-L freestyle AUTO-exclusion in the available-under-AUTO set. -- Keep src/renderers/ for durable LLM/session text only; affordances are structured data, not renderers. -``` - -### Expected touched paths (tentative) - -```pseudo -src/projections/session/ -├── affordances.ts + # affordances(resolvedState, readinessGrade): legal options + default-on-switch -├── affordances.test.ts + -├── runtime-policy.ts ? # may export shared legality/default helpers if lifted here -└── runtime-state.ts ? # if RuntimeStateProjection gains a required affordance row - -src/.pi/agents/ -└── state.ts ? # consume shared derivation instead of forked legality logic - # (only if it reduces duplication; keep behavior identical) - -src/session/ -├── README.md ~ # owns the closed affordance coverage ledger -└── runtime-affordances-coverage.test.ts + # drift guard for required-vs-deferred rows per consumer - -src/rpc/methods/session.ts ? # only if a required affordance row must surface now -``` - -### Promotion checklist - -- [x] Already a full scope card. -- Build note: if enumerating the ledger reveals that a *required* (not deferred) row depends on - product state that does not exist, stop and route back through `ln-plan` — the frontier shape shifted. diff --git a/memory/cards/web-design-system-port--restyle.md b/memory/cards/web-design-system-port--restyle.md deleted file mode 100644 index 2bae7acd..00000000 --- a/memory/cards/web-design-system-port--restyle.md +++ /dev/null @@ -1,158 +0,0 @@ -# Web design-system port — restyle - -Frontier: web-design-system-port -Status: done -Mode: chain -Created: 2026-06-09 - -## Orientation - -- **Seam:** `src/web` — read-only React client (D52-L standalone build target; must not read SQLite/Pi RPC/JSONL directly). -- **Frontier:** `web-design-system-port` (PLAN §Frontier Definitions). Decision D72-L; invariant I43-L. -- **Posture:** earned (inherited from `web-design-system-port`) — target design exists and works in `../brunch/src/client`; each card materializes a known shape or deletes the invented aesthetic. -- **Open risk / micro-decision:** webfont delivery (Inter + Geist Mono). The old trunk imports `@fontsource-variable/inter` + `@fontsource-variable/geist-mono` (npm asset packages). Frontier says "no new packages," but the fonts *are* the most visible design token. Resolve in Card 1 (see its Risk). No other unknowns. -- **Cross-cutting obligations:** read-only contract (no web write paths, D33-L); node reference codes via canonical `NODE_KIND_METADATA` projection (D62-L), not a web-local relabeling; pre-release `migration: free-rewrite` — delete the invented design, don't preserve it; `sourcing: strip-or-build` — copy patterns, avoid new logic/framework deps. - -Reference source (separate checkout, **not** imported): `../brunch/src/client/index.css`, `../brunch/src/client/components/drawer-card.tsx`, `../brunch/src/client/components/knowledge-card.tsx`. - ---- - -## Card 1 — Port the token system into `styles.css` · status: done - -### Objective - -Replace the warm "brunch" theme in `src/web/styles.css` with the prior trunk's token system, so every subsequent view styles against `ink/sub/hint/rule/wash/tint`, the compact type scale, and `--shadow-card`. - -### Acceptance Criteria - -``` -✓ styles.css @theme defines: fonts (Inter sans + Geist Mono mono); gray ramp ink #202020 / sub #5b5b5b / hint #a6a6a6 / rule #e3e3e3 / wash #f0f0f0 / tint #fafafa; link #2070e6; the 11–16px type scale (xxs..base); --shadow-card / --shadow-ring / --shadow-card-ring -✓ no warm tokens remain (brunch-paper/card/rule/muted/accent/graph), no body radial/linear gradient, no backdrop-blur -✓ body background is plain (white/near-white), color-scheme light preserved, focus-visible outline retained (re-toned to the new palette) -✓ app still mounts and renders (npm run build:web succeeds; existing tests run) -``` - -### Verification Approach - -``` -- Inner: npm run verify (build:web compiles styles; vitest runs). Visual: load / in a browser. -``` - -### Cross-cutting obligations - -``` -- Tailwind v4 @theme block only; do not pull in shadcn semantic var layer (not needed by the ported primitives). -``` - -### Assumption dependency - -`None` — purely presentational; no SPEC assumption is load-bearing. - -### Webfont delivery — RESOLVED (user, 2026-06-09) - -Option (a): add `@fontsource-variable/inter` + `@fontsource-variable/geist-mono` and `@import` them in `styles.css`. The "no new packages" line was not a hard rule; webfonts are approved. - -### Expected touched paths (tentative) - -``` -src/web/ -├── styles.css ~ -package.json ? (only if fontsource option (a) chosen) -``` - ---- - -## Card 2 — Port card primitives into `src/web/components/` · status: done - -### Objective - -Create `src/web/components/` holding the ported `DrawerCard`, `KindBadge`, `CountBadge`, and a plane-organized node-kind → accent map adapted from the old `KnowledgeKind` vocabulary to this trunk's `NodeKind`. - -### Acceptance Criteria - -``` -✓ src/web/components/drawer-card.tsx ports DrawerCard verbatim-in-shape (useState toggle, summary/locked/compact variants, rounded-xl + border-rule + bg-tint + shadow-[var(--shadow-card)] nesting); no shadcn dependency -✓ src/web/components/node-card.tsx (or kind-badge.tsx) exposes KindBadge + CountBadge using NODE_KIND_METADATA labels for the prefix -✓ a kindAccent map is exhaustive over NodeKind via `satisfies Record`, organized by plane (intent/oracle/design/plan) — build fails if a kind is missing (I43-L) -✓ no new logic deps: className composition uses template literals or a tiny local cn (no clsx/tailwind-merge unless already present) -✓ npm run verify green -``` - -### Verification Approach - -``` -- Inner: npm run verify (type-aware oxlint proves the satisfies-exhaustiveness; vitest; build). Optional: a small unit test asserting KindBadge renders the NODE_KIND_METADATA label for a sample of kinds across all four planes. -``` - -### Cross-cutting obligations - -``` -- Reference codes come from NODE_KIND_METADATA + kindOrdinal (D62-L); do not hardcode a parallel prefix table. -- Only src/web imports from src/web/components/. -``` - -### Assumption dependency - -`None`. - -### Expected touched paths (tentative) - -``` -src/web/components/ -├── drawer-card.tsx + -├── node-card.tsx + (KindBadge, CountBadge, kindAccent map, node detail card) -└── node-card.test.tsx + (optional kind→label/accent coverage) -``` - ---- - -## Card 3 — Re-skin the three views; delete the invented aesthetic · status: done - -### Objective - -Restyle `WorkspaceChrome`, `GraphOverviewPanel`, and `SessionPanel` into the ported language — quiet metadata-row chrome, plane-accented kind-grouped node cards with canonical reference codes, a plain session card — removing all warm/gradient/translucent styling. **Scope correction (user, 2026-06-09):** this is a *style + component-pattern port of the views we have*, not a feature rewrite. Behavior is preserved except where it was invented dead scaffolding. - -### Scope decisions (user, 2026-06-09) - -- **"Focus node" — REMOVED.** It was a non-functional placeholder: clicking only rendered the string `Focused read pending: graph.nodeNeighborhood(...)` and never called the `graph.nodeNeighborhood` RPC. Pre-release `migration: free-rewrite` — delete invented aesthetic/scaffolding. (The real `nodeNeighborhood` query/subscription infra in `queries/graph.ts` + `subscriptions/` is untouched and still tested.) -- **"Edge categories" — KEPT.** User finds the per-category edge summary potentially useful; restyled (`RefBadge` chips) but behavior/text (`support: 1`) preserved. -- **Counts and group labels preserved** as `Nodes` / `Edges` / `LSN` and `plane / kind` (tested DOM contract), now rendered in the compact token style with a plane-accented `KindBadge`. - -### Acceptance Criteria - -``` -✓ WorkspaceChrome: opaque metadata-row card (rounded-xl, border-rule, bg-white, shadow-card; no rounded-[2rem], no bg-white/45, no backdrop-blur, no tracking-[0.35em] uppercase mono labels) -✓ GraphOverviewPanel: groups GraphSlice.nodes by `plane / kind`; each node renders a compact card (canonical reference code + title + body); plane-accented KindBadge + CountBadge per group; Edge-categories summary retained as RefBadge chips; Focus-node button + focused-read placeholder removed; no hover-lift animation -✓ SessionPanel + spec.tsx invalid banner: plain bordered cards in the new palette -✓ read-only contract intact: no edits to queries/, rpc-client, subscriptions/, routes loaders/params, or projection inputs (GraphSlice/WorkspaceState consumed as-is) -✓ src/web/app.test.tsx behavior preserved; only the two Focus-node assertions (+ now-unused fireEvent import) removed; all other assertions pass unchanged; npm run verify green (28 web tests, oxlint type-aware clean, build:web clean, no better-sqlite3 in bundle) -``` - -### Verification Approach - -``` -- Inner: npm run verify (vitest over updated web tests; build). -- Outer: manual browser check of / and /spec/$specId against a seeded spec (npm run seed, launch web mode) — chrome, kind-grouped graph cards, session panel match the prior trunk's look. -``` - -### Cross-cutting obligations - -``` -- No new RPC/query/route behavior — presentation only. -- Edge badges resolve target reference codes through NODE_KIND_METADATA + kindOrdinal (D62-L). -- empty-state ("No graph nodes yet…") preserved in the new styling. -``` - -### Assumption dependency - -`None`. - -### Expected touched paths (tentative) - -``` -src/web/ -├── routes/root.tsx ~ (WorkspaceChrome, SessionPanel) -├── routes/spec.tsx ~ (InvalidSpecRoutePage warm-styled banner) -├── features/graph/GraphOverview.tsx ~ -└── app.test.tsx ~ -``` diff --git a/package-lock.json b/package-lock.json index 07dc15ef..6eac34de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1299,7 +1299,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/src/.pi/__tests__/context-tools.test.ts b/src/.pi/__tests__/context-tools.test.ts index 3c9bae92..b46b967d 100644 --- a/src/.pi/__tests__/context-tools.test.ts +++ b/src/.pi/__tests__/context-tools.test.ts @@ -5,11 +5,22 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { createBrunchFauxHarness } from '../../dev/index.js'; import { openWorkspaceCommandExecutor } from '../../graph/index.js'; import { seedFixture, type SeedFixture } from '../../graph/seed-fixtures.js'; -import { createSessionBindingData } from '../../session/session-binding.js'; +import { createSessionBindingData, SESSION_BINDING_TYPE } from '../../session/session-binding.js'; import { registerBrunchContext } from '../extensions/context/index.js'; +function collectContextTools() { + const tools = new Map Promise }>(); + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + return tools; +} + describe('context tools', () => { it('read_workspace_context returns a gitignore-aware cwd inventory', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-context-tool-')); @@ -45,7 +56,7 @@ describe('context tools', () => { .get('read_workspace_context')! .execute('context-cwd', { mode: 'cwd_inventory' }, undefined, undefined, { sessionManager: { - getEntries: () => [{ type: 'session', id: 'session-1', cwd }], + getHeader: () => ({ type: 'session', id: 'session-1', cwd }), }, })) as { content: Array<{ type: 'text'; text: string }>; @@ -73,8 +84,10 @@ describe('context tools', () => { const result = (await tools.get('read_session_context')!.execute('context-1', {}, undefined, undefined, { sessionManager: { + // The Pi header is reachable only via getHeader(); getEntries() never + // contains a 'session' entry. + getHeader: () => ({ type: 'session', id: 'session-1', cwd: '/tmp/brunch' }), getEntries: () => [ - { type: 'session', id: 'session-1', cwd: '/tmp/brunch' }, { id: 'binding-1', type: 'custom', @@ -140,7 +153,8 @@ describe('context tools', () => { const result = (await tools.get('read_session_context')!.execute('context-2', {}, undefined, undefined, { sessionManager: { - getEntries: () => [{ type: 'session', id: 'session-1', cwd: '/tmp/brunch' }], + getHeader: () => ({ type: 'session', id: 'session-1', cwd: '/tmp/brunch' }), + getEntries: () => [], }, })) as { content: Array<{ type: 'text'; text: string }>; @@ -155,6 +169,43 @@ describe('context tools', () => { }); }); + it('read_session_context reports missing_session_header only when getHeader() is null', async () => { + const tools = new Map Promise }>(); + + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + + // Regression: the header lives behind getHeader(), not in getEntries(). A + // present binding in getEntries() with a null header must still be + // not_ready / missing_session_header, never ready. + const result = (await tools.get('read_session_context')!.execute('context-3', {}, undefined, undefined, { + sessionManager: { + getHeader: () => null, + getEntries: () => [ + { + id: 'binding-1', + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId: 1 }), + }, + ], + }, + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + + expect(result.details).toEqual({ + status: 'not_ready', + reason: 'missing_session_header', + sessionId: null, + }); + }); + it('read_workspace_context returns a workspace overview for bound specs and sessions', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-context-overview-')); const executor = await openWorkspaceCommandExecutor(cwd); @@ -179,7 +230,7 @@ describe('context tools', () => { .get('read_workspace_context')! .execute('context-overview', { mode: 'workspace_overview' }, undefined, undefined, { sessionManager: { - getEntries: () => [{ type: 'session', id: 'session-1', cwd }], + getHeader: () => ({ type: 'session', id: 'session-1', cwd }), }, })) as { content: Array<{ type: 'text'; text: string }>; @@ -199,6 +250,55 @@ describe('context tools', () => { ]); expect(result.details.data.sessions.map((session) => session.turnCount)).toEqual([1, 2]); }); + + // Authentic oracle: drive the context tools against the faux harness's REAL + // SessionManager instead of a hand-written fake. The real manager keeps the + // Pi header behind getHeader() and excludes it from getEntries(), so this + // would have failed the header-search bugs (read_session_context always + // not_ready, read_workspace_context resolving cwd to process.cwd()). A lying + // mock cannot reproduce that split; the real session machinery does. + it('context tools resolve against the faux harness real SessionManager (header via getHeader)', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-context-faux-')); + await mkdir(join(cwd, '.brunch', 'sessions'), { recursive: true }); + await writeFile(join(cwd, 'faux-guard-doc.md'), '# Faux guard\n'); + + const harness = await createBrunchFauxHarness({ cwd }); + try { + const sessionManager = harness.session.sessionManager; + sessionManager.appendCustomEntry(SESSION_BINDING_TYPE, createSessionBindingData({ specId: 4 })); + + // The real header is reachable only via getHeader(); getEntries() returns + // SessionEntry[], whose `type` provably never includes 'session' (a search + // for it is now a compile error — the original bug's root cause). The + // header below comes from getHeader(); getEntries() holds only the binding. + const headerId = sessionManager.getHeader()?.id; + expect(typeof headerId).toBe('string'); + + const tools = collectContextTools(); + const ctx = { sessionManager }; + + const sessionResult = (await tools + .get('read_session_context')! + .execute('faux-session', {}, undefined, undefined, ctx)) as { details: unknown }; + expect(sessionResult.details).toMatchObject({ + status: 'ready', + specId: 4, + sessionId: headerId, + }); + + const workspaceResult = (await tools + .get('read_workspace_context')! + .execute('faux-workspace', { mode: 'cwd_inventory' }, undefined, undefined, ctx)) as { + details: { data: { markdownFiles: Array<{ path: string }> } }; + }; + // cwd came from the header (the temp workbench), not process.cwd(). + expect(workspaceResult.details.data.markdownFiles.map((file) => file.path)).toContain( + 'faux-guard-doc.md', + ); + } finally { + harness.dispose(); + } + }); }); async function loadFixture(slug: string, set = 'bilal-port'): Promise { diff --git a/src/.pi/__tests__/introspection.test.ts b/src/.pi/__tests__/introspection.test.ts index a3522bfa..5b181f3e 100644 --- a/src/.pi/__tests__/introspection.test.ts +++ b/src/.pi/__tests__/introspection.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; +import { BRUNCH_INTROSPECT_QUERY_TOOL } from '../extensions/introspect-query/index.js'; import { BRUNCH_INTROSPECTION_COMMAND, createInMemoryBrunchIntrospectionStore, registerBrunchIntrospection, } from '../extensions/introspection/index.js'; +import { BRUNCH_SESSION_QUERY_TOOL } from '../extensions/session-query/index.js'; interface FakeCommandContext { readonly ui: { notify(message: string, type?: 'info' | 'warning' | 'error'): void }; @@ -76,6 +78,8 @@ describe('Brunch introspection extension', () => { ); expect(productApi.commandNames).not.toContain(BRUNCH_INTROSPECTION_COMMAND); + expect(productApi.toolNames).not.toContain(BRUNCH_SESSION_QUERY_TOOL); + expect(productApi.toolNames).not.toContain(BRUNCH_INTROSPECT_QUERY_TOOL); expect(productApi.eventNames).not.toContain('before_provider_request'); const devApi = createFakeExtensionApi(); @@ -85,8 +89,30 @@ describe('Brunch introspection extension', () => { })(devApi.api as never); expect(devApi.commandNames.at(-1)).toBe(BRUNCH_INTROSPECTION_COMMAND); + expect(devApi.toolNames.slice(-2)).toEqual([BRUNCH_SESSION_QUERY_TOOL, BRUNCH_INTROSPECT_QUERY_TOOL]); expect(devApi.eventNames.slice(-2)).toEqual(['before_agent_start', 'before_provider_request']); }); + + it('advertises registered dev query tools only when introspection is enabled', async () => { + const productApi = createFakeExtensionApi(); + await createBrunchPiExtensions(brunchChromeFixture, undefined, { coordinator: {} as never })( + productApi.api as never, + ); + await productApi.emitBeforeAgentStart({ systemPrompt: 'base' }); + + const devApi = createFakeExtensionApi(); + await createBrunchPiExtensions(brunchChromeFixture, undefined, { + coordinator: {} as never, + introspection: { enabled: true, store: createInMemoryBrunchIntrospectionStore() }, + })(devApi.api as never); + await devApi.emitBeforeAgentStart({ systemPrompt: 'base' }); + + expect(productApi.activeToolSets.at(-1)).not.toContain(BRUNCH_SESSION_QUERY_TOOL); + expect(productApi.activeToolSets.at(-1)).not.toContain(BRUNCH_INTROSPECT_QUERY_TOOL); + expect(devApi.activeToolSets.at(-1)).toEqual( + expect.arrayContaining([BRUNCH_SESSION_QUERY_TOOL, BRUNCH_INTROSPECT_QUERY_TOOL]), + ); + }); }); function fixedClock(): Date { @@ -104,8 +130,10 @@ const brunchChromeFixture = { function createFakeExtensionApi() { const eventNames: string[] = []; const commandNames: string[] = []; + const toolNames: string[] = []; const handlers = new Map unknown>>(); const commands = new Map }>(); + const activeToolSets: string[][] = []; const api = { on(eventName: string, handler: (event: unknown, ctx: unknown) => unknown) { eventNames.push(eventName); @@ -118,18 +146,25 @@ function createFakeExtensionApi() { commandNames.push(name); commands.set(name, command); }, - registerTool() {}, + registerTool(tool: { name: string }) { + toolNames.push(tool.name); + }, registerShortcut() {}, registerMessageRenderer() {}, sendMessage() {}, - getAllTools: () => ['read', 'grep', 'find', 'ls', 'bash'].map((name) => ({ name })), - setActiveTools() {}, + getAllTools: () => + [...new Set(['read', 'grep', 'find', 'ls', 'bash', ...toolNames])].map((name) => ({ name })), + setActiveTools(tools: string[]) { + activeToolSets.push(tools); + }, }; return { api, eventNames, commandNames, + toolNames, + activeToolSets, async emitBeforeAgentStart(event: unknown): Promise { return last( await Promise.all((handlers.get('before_agent_start') ?? []).map((handler) => handler(event, {}))), diff --git a/src/.pi/__tests__/prompting.test.ts b/src/.pi/__tests__/prompting.test.ts index fc6d75fe..9dd777a3 100644 --- a/src/.pi/__tests__/prompting.test.ts +++ b/src/.pi/__tests__/prompting.test.ts @@ -8,6 +8,8 @@ import type { WorkspacePostureState } from '../../session/workspace-session-coor import { composeAgentPrompt } from '../agents/compose.js'; import type { ReadinessGrade } from '../agents/state.js'; import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; +import { BRUNCH_INTROSPECT_QUERY_TOOL } from '../extensions/introspect-query/index.js'; +import { createInMemoryBrunchIntrospectionStore } from '../extensions/introspection/index.js'; import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -17,6 +19,7 @@ import { type BrunchAgentStateEntryData, registerBrunchOperationalModePolicy, } from '../extensions/runtime/index.js'; +import { BRUNCH_SESSION_QUERY_TOOL } from '../extensions/session-query/index.js'; import { registerBrunchPrompting } from '../extensions/system-prompts/index.js'; function runtimeEntry(state: BrunchAgentState) { @@ -424,6 +427,61 @@ describe('Brunch prompt-pack topology', () => { }); }); + it('keeps dev query tools in the prompt active-tools list when introspection is enabled', async () => { + const events: Record unknown>> = {}; + const toolNames: string[] = []; + const activeTools: string[][] = []; + + await createBrunchPiExtensions( + { + cwd: '/tmp/brunch', + chatMode: 'responding-to-elicitation', + phase: 'elicitation', + spec: { id: 1, title: 'Spec' }, + session: { id: 'session-1', label: 'Session' }, + }, + undefined, + { + coordinator: {} as never, + graphMentionSource: { listMentionCandidates: () => [] }, + promptContext, + introspection: { enabled: true, store: createInMemoryBrunchIntrospectionStore() }, + }, + )({ + on: (eventName: string, handler: (event: never, ctx?: never) => unknown) => { + events[eventName] ??= []; + events[eventName].push(handler); + }, + registerTool(tool: { name: string }) { + toolNames.push(tool.name); + }, + registerCommand() {}, + registerShortcut() {}, + registerMessageRenderer() {}, + sendMessage() {}, + getAllTools: () => + [...new Set(['read', 'grep', 'bash', 'write', ...toolNames])].map((name) => ({ name })), + setActiveTools: (tools: string[]) => activeTools.push(tools), + } as never); + + const results = await Promise.all( + (events.before_agent_start ?? []).map((handler) => + Promise.resolve( + handler({ systemPrompt: 'base' } as never, { sessionManager: { getEntries: () => [] } } as never), + ), + ), + ); + const promptResult = results.find( + (result) => typeof (result as { systemPrompt?: unknown } | undefined)?.systemPrompt === 'string', + ) as { systemPrompt: string } | undefined; + + expect(activeTools.at(-1)).toEqual( + expect.arrayContaining([BRUNCH_SESSION_QUERY_TOOL, BRUNCH_INTROSPECT_QUERY_TOOL]), + ); + expect(promptResult?.systemPrompt).toContain(BRUNCH_SESSION_QUERY_TOOL); + expect(promptResult?.systemPrompt).toContain(BRUNCH_INTROSPECT_QUERY_TOOL); + }); + it('applies the selected-spec grade to mutate_graph tool activation', async () => { async function activeToolsForGrade(readinessGrade: ReadinessGrade) { const events: Record unknown> = {}; diff --git a/src/.pi/agents/state.test.ts b/src/.pi/agents/state.test.ts index 1798d3a2..e26319f1 100644 --- a/src/.pi/agents/state.test.ts +++ b/src/.pi/agents/state.test.ts @@ -69,6 +69,40 @@ describe('agent posture policy', () => { expect(elicitationTools).not.toContain('present_review_set'); }); + it('allows registered dev tool names only through the injected dev allow-list', () => { + const state = projectBrunchAgentState([]); + const productTools = activeToolNamesForPosture({ + registeredToolNames: [...registeredToolNames, 'brunch_session_query'], + state, + readinessGrade: 'grounding_onboarding', + }); + const devTools = activeToolNamesForPosture({ + registeredToolNames: [...registeredToolNames, 'brunch_session_query'], + state, + readinessGrade: 'grounding_onboarding', + devAllowedToolNames: ['brunch_session_query'], + }); + + expect(productTools).not.toContain('brunch_session_query'); + expect(devTools).toContain('brunch_session_query'); + expect(productTools).toEqual( + activeToolNamesForPosture({ registeredToolNames, state, readinessGrade: 'grounding_onboarding' }), + ); + }); + + it('keeps blocked tools blocked and never advertises unregistered dev tool names', () => { + const state = projectBrunchAgentState([]); + const tools = activeToolNamesForPosture({ + registeredToolNames, + state, + readinessGrade: 'grounding_onboarding', + devAllowedToolNames: ['bash', 'brunch_session_query'], + }); + + expect(tools).not.toContain('bash'); + expect(tools).not.toContain('brunch_session_query'); + }); + it('keeps freestyle pin-only while leaving elicit tool authority unchanged', () => { const autoState = projectBrunchAgentState([]); const pinnedFreestyle = projectBrunchAgentState([ diff --git a/src/.pi/agents/state.ts b/src/.pi/agents/state.ts index 9bdfa17c..f36792ee 100644 --- a/src/.pi/agents/state.ts +++ b/src/.pi/agents/state.ts @@ -51,6 +51,7 @@ export interface BrunchPostureToolPolicyInput { registeredToolNames: readonly string[]; state: ResolvedBrunchAgentState; readinessGrade: ReadinessGrade; + devAllowedToolNames?: readonly string[] | undefined; } const METHOD_MIN_GRADE: Record = { @@ -262,6 +263,7 @@ export function activeToolNamesForPosture({ registeredToolNames, state, readinessGrade, + devAllowedToolNames = [], }: BrunchPostureToolPolicyInput): string[] { const toolPolicy = toolPolicyForRuntimeState(state); const legalTools = new Set(toolPolicy.baseAllowedToolNames); @@ -270,6 +272,9 @@ export function activeToolNamesForPosture({ legalTools.add(toolName); } } + for (const toolName of devAllowedToolNames) { + legalTools.add(toolName); + } const blockedTools = new Set(toolPolicy.blockedToolNames); diff --git a/src/.pi/brunch-pi-extensions.ts b/src/.pi/brunch-pi-extensions.ts index 192a80c8..a66dac09 100644 --- a/src/.pi/brunch-pi-extensions.ts +++ b/src/.pi/brunch-pi-extensions.ts @@ -9,6 +9,10 @@ import { registerBrunchBranchPolicyHandlers } from './extensions/commands/policy import { registerBrunchContext } from './extensions/context/index.js'; import { registerStructuredExchange } from './extensions/exchanges/index.js'; import { registerBrunchGraph, type BrunchGraphDeps } from './extensions/graph/index.js'; +import { + BRUNCH_INTROSPECT_QUERY_TOOL, + registerBrunchIntrospectQuery, +} from './extensions/introspect-query/index.js'; import { registerBrunchIntrospection, type BrunchIntrospectionOptions, @@ -16,6 +20,7 @@ import { import { type GraphMentionSource } from './extensions/mentions/index.js'; import { registerBrunchMentionAutocomplete } from './extensions/mentions/index.js'; import { registerBrunchOperationalModePolicy } from './extensions/runtime/index.js'; +import { BRUNCH_SESSION_QUERY_TOOL, registerBrunchSessionQuery } from './extensions/session-query/index.js'; import { registerBrunchSessionBoundary } from './extensions/session/lifecycle.js'; import { type BrunchSessionBoundaryHandler } from './extensions/session/lifecycle.js'; import { @@ -71,6 +76,16 @@ export { type BrunchIntrospectionStore, type BrunchIntrospectionTurnCapture, } from './extensions/introspection/index.js'; +export { + BRUNCH_SESSION_QUERY_TOOL, + createBrunchSessionQueryTool, + registerBrunchSessionQuery, +} from './extensions/session-query/index.js'; +export { + BRUNCH_INTROSPECT_QUERY_TOOL, + createBrunchIntrospectQueryTool, + registerBrunchIntrospectQuery, +} from './extensions/introspect-query/index.js'; export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions { graphMentionSource?: GraphMentionSource; @@ -107,16 +122,21 @@ export function createBrunchPiExtensions( const graphMentionSource = options.graphMentionSource ?? graphMentionSourceFromDeps(options.graph); const promptContext = options.promptContext; const introspectionOptions = options.introspection; + const devAllowedToolNames = introspectionOptions?.enabled + ? [BRUNCH_SESSION_QUERY_TOOL, BRUNCH_INTROSPECT_QUERY_TOOL] + : undefined; const extensions: BrunchProductExtensionRegistrar[] = [ (api) => registerBrunchSessionBoundary(api, onSessionBoundary), (api) => registerBrunchChrome(api, chrome), registerBrunchBranchPolicyHandlers, - registerBrunchOperationalModePolicy, + (api) => registerBrunchOperationalModePolicy(api, { devAllowedToolNames }), registerBrunchContext, // Prompting registers immediately after operational-mode policy and // before mention autocomplete when prompt context is provided; its // position in this list is the registration order, not a splice index. - ...(promptContext ? [(api: ExtensionAPI) => registerBrunchPrompting(api, promptContext)] : []), + ...(promptContext + ? [(api: ExtensionAPI) => registerBrunchPrompting(api, promptContext, { devAllowedToolNames })] + : []), (api) => registerBrunchMentionAutocomplete(api, graphMentionSource), registerBrunchAlternatives, (api) => @@ -131,10 +151,12 @@ export function createBrunchPiExtensions( ? [ (api: ExtensionAPI) => { const { store, clock } = introspectionOptions; - registerBrunchIntrospection(api, { + const introspectionStore = registerBrunchIntrospection(api, { ...(store ? { store } : {}), ...(clock ? { clock } : {}), }); + registerBrunchSessionQuery(api); + registerBrunchIntrospectQuery(api, { store: introspectionStore }); }, ] : []), diff --git a/src/.pi/extensions/README.md b/src/.pi/extensions/README.md index 5b7b0e65..956169f4 100644 --- a/src/.pi/extensions/README.md +++ b/src/.pi/extensions/README.md @@ -4,7 +4,7 @@ SPEC decisions: D34-L, D35-L, D37-L, D39-L, D40-L, D52-L, D69-L ## Owns -Pi-facing registration and adaptation only: lifecycle hooks, agent tool definitions, command/shortcut handlers, TUI chrome affordances, autocomplete wrappers, per-turn system-prompt append hooks, dev-gated read-only introspection taps, workspace dialogs, and Pi-specific tool result renderers. +Pi-facing registration and adaptation only: lifecycle hooks, agent tool definitions, command/shortcut handlers, TUI chrome affordances, autocomplete wrappers, per-turn system-prompt append hooks, dev-gated read-only introspection taps, payload/session-log query tools, workspace dialogs, and Pi-specific tool result renderers. ## Does NOT own @@ -27,6 +27,9 @@ extensions/ ├── exchanges/ structured-exchange present_* / request_* Pi tools ├── graph/ mutate_graph/read_graph Pi tools ├── introspection/ dev-gated read-only provider-payload tap + /introspect command +├── introspect-query/ dev-gated read-only brunch_introspect_query tool over captured payloads +├── session-query/ dev-gated read-only brunch_session_query tool over current branch +├── shared/ projection/truncation helpers + Zod→Pi schema adapter for dev query tools ├── mentions/ #graph mention prompt hint + autocomplete provider ├── runtime/ active-tool policy and tool/user_bash guards ├── session/ session lifecycle hooks @@ -49,6 +52,6 @@ rules: ## 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. `exchanges/pi-schema.ts` remains the only Zod-to-Pi `TSchema` adapter. +`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. `exchanges/shared/markdown.ts` contains Pi-rendering helpers. Move only reusable product markdown/text rendering into the future renderer seam; keep Pi `renderCall` / `renderResult` widgets and UI-only message components local to `.pi/`. diff --git a/src/.pi/extensions/context/get-cwd.ts b/src/.pi/extensions/context/get-cwd.ts index 44003f99..36235b2e 100644 --- a/src/.pi/extensions/context/get-cwd.ts +++ b/src/.pi/extensions/context/get-cwd.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path'; -import type { FileEntry } from '@earendil-works/pi-coding-agent'; +import type { SessionHeader } from '@earendil-works/pi-coding-agent'; import { projectWorkspaceCwdContext, @@ -13,8 +13,12 @@ import { inspectWorkspaceOverview, } from '../../../session/workspace-context.js'; +// The session cwd lives on the Pi header, which is reachable only via +// getHeader() — not getEntries() (SessionEntry[], header excluded). Searching +// getEntries() for it previously always missed, silently falling back to +// process.cwd() and inventorying the wrong directory. interface SessionManagerLike { - getEntries(): readonly FileEntry[]; + getHeader(): SessionHeader | null; } export async function readWorkspaceContext( @@ -33,12 +37,6 @@ export async function readWorkspaceContext( } function resolveWorkspaceCwd(sessionManager?: SessionManagerLike): string { - const header = sessionManager?.getEntries().find(isSessionHeaderEntry); + const header = sessionManager?.getHeader(); return typeof header?.cwd === 'string' ? resolve(header.cwd) : process.cwd(); } - -function isSessionHeaderEntry( - entry: FileEntry, -): entry is FileEntry & { readonly type: 'session'; readonly cwd: string } { - return entry.type === 'session' && typeof (entry as { cwd?: unknown }).cwd === 'string'; -} diff --git a/src/.pi/extensions/context/index.ts b/src/.pi/extensions/context/index.ts index 0d0772f3..e6d3e120 100644 --- a/src/.pi/extensions/context/index.ts +++ b/src/.pi/extensions/context/index.ts @@ -1,4 +1,4 @@ -import type { ExtensionAPI, FileEntry } from '@earendil-works/pi-coding-agent'; +import type { ExtensionAPI, SessionEntry, SessionHeader } from '@earendil-works/pi-coding-agent'; import { projectSessionRuntimeState, @@ -8,15 +8,18 @@ import { renderRuntimeFrame, type SessionRuntimeFrameRenderInput, } from '../../../renderers/session/runtime-frame.js'; -import { - NonLinearTranscriptError, - type BrunchSessionEnvelope, -} from '../../../session/brunch-session-envelope.js'; +import { NonLinearTranscriptError } from '../../../session/brunch-session-envelope.js'; import { isSessionBindingEntry } from '../../../session/session-binding.js'; import { readWorkspaceContext } from './get-cwd.js'; +// Mirror the real ReadonlySessionManager surface this projection uses. The Pi +// session header is NOT part of getEntries() (which returns SessionEntry[]); it +// is only reachable via getHeader(). Typing getEntries() as FileEntry[] here +// previously hid that: the header lookup searched getEntries() for a 'session' +// entry that can never appear, so the frame was always missing_session_header. interface SessionManagerLike { - getEntries(): readonly FileEntry[]; + getHeader(): SessionHeader | null; + getEntries(): readonly SessionEntry[]; } export function registerBrunchContext(pi: ExtensionAPI): void { @@ -96,12 +99,12 @@ export function registerBrunchContext(pi: ExtensionAPI): void { function projectSessionContext( sessionManager: SessionManagerLike | undefined, ): RuntimeStateProjection | SessionRuntimeFrameRenderInput { - const entries = sessionManager?.getEntries() ?? []; - const header = entries.find(isSessionHeaderEntry); + const header = sessionManager?.getHeader() ?? undefined; if (!header) { return { status: 'not_ready', reason: 'missing_session_header', sessionId: null }; } + const entries = sessionManager?.getEntries() ?? []; const binding = entries.find(isSessionBindingEntry); if (!binding) { return { status: 'not_ready', reason: 'missing_binding', sessionId: header.id }; @@ -120,7 +123,3 @@ function projectSessionContext( throw error; } } - -function isSessionHeaderEntry(entry: FileEntry): entry is BrunchSessionEnvelope['header'] { - return entry.type === 'session' && typeof entry.id === 'string'; -} diff --git a/src/.pi/extensions/introspect-query/README.md b/src/.pi/extensions/introspect-query/README.md new file mode 100644 index 00000000..19ba44ac --- /dev/null +++ b/src/.pi/extensions/introspect-query/README.md @@ -0,0 +1,10 @@ +# introspect-query + +Owns the dev-gated, read-only query tool over Brunch introspection captures. + +- **Owns:** `brunch_introspect_query`, which projects values from the latest captured `before_provider_request` payload plus base `getSystemPromptOptions` input. +- **Input:** `BrunchIntrospectionStore` from `../introspection/`; the store is injected by `brunch-pi-extensions.ts` only when dev introspection is enabled. +- **Output:** verbatim projected rows returned as a Pi tool result, with shared projection/truncation behavior from `../shared/query-projection.ts`. +- **Used by:** `createBrunchPiExtensions(..., { introspection: { enabled: true } })`; never loaded in the product default path. + +Decisions: D39-L sealed profile, D40-L active-tool policy, D69-L final provider-payload capture, D71-L dev-only introspection wiring. The tool is read-only and dev-gated; it observes the payload plane without shaping prompts or product behavior. diff --git a/src/.pi/extensions/introspect-query/index.test.ts b/src/.pi/extensions/introspect-query/index.test.ts new file mode 100644 index 00000000..a7ce86d4 --- /dev/null +++ b/src/.pi/extensions/introspect-query/index.test.ts @@ -0,0 +1,177 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { + type BrunchIntrospectionStore, + createInMemoryBrunchIntrospectionStore, + registerBrunchIntrospection, +} from '../introspection/index.js'; +import { + BRUNCH_INTROSPECT_QUERY_TOOL, + createBrunchIntrospectQueryTool, + queryIntrospectionCaptures, + registerBrunchIntrospectQuery, +} from './index.js'; + +describe('brunch_introspect_query', () => { + it('returns the latest capture and projects payload and baseOptions paths', () => { + const store = seededStore(); + + expect( + queryIntrospectionCaptures(store, { select: ['payload.tools[*].name', 'baseOptions.cwd'] }), + ).toEqual([ + { + ref: { turnId: 'turn-2', capturedAt: '2026-06-09T00:00:02.000Z' }, + value: { 'payload.tools[*].name': 'brunch_session_query', 'baseOptions.cwd': '/tmp/brunch' }, + }, + ]); + }); + + it('returns the whole queryable capture when select is omitted', () => { + const store = seededStore(); + + expect(queryIntrospectionCaptures(store, {})[0]?.value).toEqual({ + turnId: 'turn-2', + capturedAt: '2026-06-09T00:00:02.000Z', + payload: { + system: 'final two', + tools: [{ name: 'brunch_session_query' }], + messages: [{ role: 'user' }], + }, + baseOptions: { cwd: '/tmp/brunch', selectedTools: ['read'] }, + }); + }); + + it('finds captures by turnId and returns an empty result for unknown turn ids', () => { + const store = seededStore(); + + expect( + queryIntrospectionCaptures(store, { find: { turnId: 'turn-1' }, select: 'payload.system' }), + ).toEqual([ + { + ref: { turnId: 'turn-1', capturedAt: '2026-06-09T00:00:01.000Z' }, + value: 'final one', + }, + ]); + expect(queryIntrospectionCaptures(store, { find: { turnId: 'missing' } })).toEqual([]); + }); + + it('finds captures by nth-from-end', () => { + const store = seededStore(); + + expect(queryIntrospectionCaptures(store, { find: { nth: 2 }, select: 'payload.system' })[0]?.value).toBe( + 'final one', + ); + }); + + it('truncates large payloads with temp-file spillover and respects maxBytes', async () => { + const store = createInMemoryBrunchIntrospectionStore(); + const large = 'x'.repeat(200); + store.recordPassiveCapture({ + turnId: 'turn-big', + capturedAt: '2026-06-09T00:00:03.000Z', + event: 'before_provider_request', + payload: { system: large }, + }); + const tool = createBrunchIntrospectQueryTool(store); + + const result = await tool.execute( + 'query-1', + { select: 'payload.system', maxBytes: 80 }, + undefined, + undefined, + {} as never, + ); + + expect(result.content[0]?.type).toBe('text'); + const text = result.content[0]?.type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Output truncated'); + expect(result.details?.truncation?.truncated).toBe(true); + expect(result.details?.truncation?.outputBytes).toBeLessThanOrEqual(80); + expect(await readFile(result.details!.fullOutputPath!, 'utf8')).toContain(large); + }); + + it('reads a real before_provider_request capture recorded by the introspection tap', async () => { + const store = createInMemoryBrunchIntrospectionStore(); + const handlers: Record unknown>> = {}; + const tools: Array<{ name: string; execute: (...args: any[]) => Promise }> = []; + const api = { + on(eventName: string, handler: (event: unknown, ctx: unknown) => unknown) { + handlers[eventName] ??= []; + handlers[eventName].push(handler); + }, + registerCommand() {}, + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.push(tool); + }, + }; + + registerBrunchIntrospection(api as never, { store, clock: () => new Date('2026-06-09T00:00:04.000Z') }); + registerBrunchIntrospectQuery(api as never, { store }); + + for (const handler of handlers.before_agent_start ?? []) await handler({}, {}); + for (const handler of handlers.before_provider_request ?? []) { + await handler({ payload: { system: 'VERBATIM FINAL SYSTEM', tools: [{ name: 'read' }] } }, {}); + } + const tool = tools.find((candidate) => candidate.name === BRUNCH_INTROSPECT_QUERY_TOOL); + if (!tool) throw new Error('brunch_introspect_query tool not registered'); + + const result = await tool.execute('query-1', { select: 'payload.system' }, undefined, undefined, {}); + + expect(result.content[0]).toEqual( + expect.objectContaining({ text: expect.stringContaining('VERBATIM FINAL SYSTEM') }), + ); + }); + + it('registers the tool through the extension registrar', () => { + const store = createInMemoryBrunchIntrospectionStore(); + const tools: Array<{ name: string }> = []; + registerBrunchIntrospectQuery({ registerTool: (tool: { name: string }) => tools.push(tool) } as never, { + store, + }); + expect(tools.map((tool) => tool.name)).toEqual([BRUNCH_INTROSPECT_QUERY_TOOL]); + }); + + it('advertises a JSON Schema draft 2020-12 parameter schema (no draft-07 tuple form)', () => { + const schema = createBrunchIntrospectQueryTool(createInMemoryBrunchIntrospectionStore()) + .parameters as Record; + expect(schema.$schema).toContain('draft/2020-12'); + expect(draft07TupleSmells(schema)).toEqual([]); + }); +}); + +function draft07TupleSmells(node: unknown, path = '$'): string[] { + if (Array.isArray(node)) return node.flatMap((item, i) => draft07TupleSmells(item, `${path}[${i}]`)); + if (typeof node !== 'object' || node === null) return []; + const record = node as Record; + const smells: string[] = []; + if (Array.isArray(record.items)) smells.push(`${path}.items is an array`); + if ('additionalItems' in record) smells.push(`${path}.additionalItems present`); + for (const [key, value] of Object.entries(record)) + smells.push(...draft07TupleSmells(value, `${path}.${key}`)); + return smells; +} + +function seededStore(): BrunchIntrospectionStore { + const store = createInMemoryBrunchIntrospectionStore(); + store.recordPassiveCapture({ + turnId: 'turn-1', + capturedAt: '2026-06-09T00:00:01.000Z', + event: 'before_provider_request', + payload: { system: 'final one', tools: [{ name: 'read' }], messages: [{ role: 'user' }] }, + }); + store.recordPassiveCapture({ + turnId: 'turn-2', + capturedAt: '2026-06-09T00:00:02.000Z', + event: 'before_provider_request', + payload: { system: 'final two', tools: [{ name: 'brunch_session_query' }], messages: [{ role: 'user' }] }, + }); + store.recordBaseReport({ + reportedAt: '2026-06-09T00:00:02.500Z', + command: 'introspect', + baseSystemPromptOptions: { cwd: '/tmp/brunch', selectedTools: ['read'] }, + latestPassiveCapture: store.latestPassiveCapture(), + }); + return store; +} diff --git a/src/.pi/extensions/introspect-query/index.ts b/src/.pi/extensions/introspect-query/index.ts new file mode 100644 index 00000000..f22ef278 --- /dev/null +++ b/src/.pi/extensions/introspect-query/index.ts @@ -0,0 +1,155 @@ +import { defineTool, type ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import * as z from 'zod'; + +import { + type BrunchIntrospectionStore, + type BrunchIntrospectionTurnCapture, +} from '../introspection/index.js'; +import { devToolParameters } from '../shared/pi-tool-schema.js'; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + projectSelection, + rowsToText, + truncateQueryOutput, + type TruncationResult, +} from '../shared/query-projection.js'; + +export const BRUNCH_INTROSPECT_QUERY_TOOL = 'brunch_introspect_query'; + +const zFind = z + .object({ + capture: z.literal('latest').optional(), + turnId: z.string().optional(), + nth: z.number().min(1).optional(), + }) + .strict() + .optional(); + +const zBrunchIntrospectQueryParams = z + .object({ + find: zFind, + select: z.union([z.string(), z.array(z.string())]).optional(), + maxBytes: z.number().min(1).optional(), + format: z.enum(['json', 'text']).optional(), + }) + .strict(); + +export type BrunchIntrospectQueryParams = z.infer; + +export interface BrunchIntrospectQueryRef { + readonly turnId: string; + readonly capturedAt: string; +} + +export interface BrunchIntrospectQueryRow { + readonly ref: BrunchIntrospectQueryRef; + readonly value: unknown; +} + +export interface BrunchIntrospectQueryDetails { + readonly matched: number; + readonly returned: number; + readonly selected?: string | readonly string[]; + readonly truncation?: TruncationResult; + readonly fullOutputPath?: string; +} + +interface BrunchIntrospectionQueryableCapture { + readonly turnId: string; + readonly capturedAt: string; + readonly payload: unknown; + readonly baseOptions: unknown; +} + +export function registerBrunchIntrospectQuery( + pi: ExtensionAPI, + options: { store: BrunchIntrospectionStore }, +): void { + pi.registerTool(createBrunchIntrospectQueryTool(options.store)); +} + +export function createBrunchIntrospectQueryTool(store: BrunchIntrospectionStore) { + return defineTool, BrunchIntrospectQueryDetails>({ + name: BRUNCH_INTROSPECT_QUERY_TOOL, + label: 'Brunch introspect query', + description: [ + 'Read-only dev tool for querying the provider payload captured by Brunch introspection.', + 'Use brunch_introspect_query when the user asks what system prompt, tool schemas, messages, or prompt options you were actually given. Echo returned values verbatim in a fenced block when asked for exact bytes.', + 'The payload field is the final provider-serialized before_provider_request payload; baseOptions is only Pi getSystemPromptOptions base input and does not include later prompt/context/payload mutations.', + `Output is truncated to maxBytes (default ${formatSize(DEFAULT_MAX_BYTES)}) or ${DEFAULT_MAX_LINES} lines; truncated full output is saved to a temp file.`, + ].join(' '), + promptSnippet: 'Query the latest captured provider payload and base prompt options.', + promptGuidelines: [ + 'Use brunch_introspect_query when the user asks what prompt, tools, or provider payload you actually received; quote returned values verbatim rather than paraphrasing when exactness matters.', + 'Treat baseOptions as base prompt inputs only; use payload for the final provider-serialized request.', + ], + parameters: devToolParameters(zBrunchIntrospectQueryParams), + async execute(_toolCallId, rawParams) { + const params = zBrunchIntrospectQueryParams.parse(rawParams); + const rows = queryIntrospectionCaptures(store, params); + const serialized = + params.format === 'text' ? rowsToIntrospectText(rows) : JSON.stringify(rows, null, 2); + const maxBytes = params.maxBytes ?? DEFAULT_MAX_BYTES; + const { content, details } = await truncateQueryOutput( + serialized, + maxBytes, + { + matched: matchedCaptureCount(store, params.find), + returned: rows.length, + ...(params.select === undefined ? {} : { selected: params.select }), + }, + 'brunch-introspect-query-', + ); + + return { content: [{ type: 'text', text: content }], details }; + }, + }); +} + +export function queryIntrospectionCaptures( + store: BrunchIntrospectionStore, + params: BrunchIntrospectQueryParams, +): BrunchIntrospectQueryRow[] { + const baseOptions = store.latestBaseReport()?.baseSystemPromptOptions; + return findCaptures(store, params.find).map((capture) => { + const queryable: BrunchIntrospectionQueryableCapture = { + turnId: capture.turnId, + capturedAt: capture.capturedAt, + payload: capture.payload, + baseOptions, + }; + return { + ref: { turnId: capture.turnId, capturedAt: capture.capturedAt }, + value: projectSelection(queryable, params.select), + }; + }); +} + +function matchedCaptureCount( + store: BrunchIntrospectionStore, + find: BrunchIntrospectQueryParams['find'], +): number { + return findCaptures(store, find).length; +} + +function findCaptures( + store: BrunchIntrospectionStore, + find: BrunchIntrospectQueryParams['find'], +): readonly BrunchIntrospectionTurnCapture[] { + const captures = store.allPassiveCaptures(); + if (find?.turnId !== undefined) return captures.filter((capture) => capture.turnId === find.turnId); + if (find?.nth !== undefined) { + const capture = captures.at(-find.nth); + return capture ? [capture] : []; + } + const latest = store.latestPassiveCapture(); + return latest ? [latest] : []; +} + +function rowsToIntrospectText(rows: readonly BrunchIntrospectQueryRow[]): string { + return rowsToText(rows, (ref) => `${ref.turnId} ${ref.capturedAt}`); +} + +export default registerBrunchIntrospectQuery; diff --git a/src/.pi/extensions/introspection/README.md b/src/.pi/extensions/introspection/README.md index 665eec35..f8e6a0d2 100644 --- a/src/.pi/extensions/introspection/README.md +++ b/src/.pi/extensions/introspection/README.md @@ -4,7 +4,7 @@ Owns the dev-only D69-L agent-input introspection tap. - **Owns:** read-only `before_provider_request` capture of the final provider payload and the dev `/introspect` command that reports base `getSystemPromptOptions()` inputs plus the latest passive capture. - **Input:** Pi extension events from the explicit Brunch extension bundle. -- **Output:** in-memory capture records consumed by `src/dev/introspection-launcher.ts` and written under `.fixtures/runs/introspection//`. +- **Output:** in-memory capture records consumed by `src/dev/introspection-launcher.ts` and written under repo-root `.fixtures/scratch/introspection//`. - **Used by:** developer feedback loops only. Product Brunch sessions omit this extension unless `createBrunchPiExtensions(..., { introspection: { enabled: true } })` is passed explicitly. The extension observes only: hook handlers return `undefined` and never replace provider payloads or system prompts. It must be registered last in `brunch-pi-extensions.ts` when enabled so the passive tap sees the post-mutation provider payload. diff --git a/src/.pi/extensions/introspection/index.ts b/src/.pi/extensions/introspection/index.ts index 9b3d5281..39fade61 100644 --- a/src/.pi/extensions/introspection/index.ts +++ b/src/.pi/extensions/introspection/index.ts @@ -19,6 +19,7 @@ export interface BrunchIntrospectionBaseReport { export interface BrunchIntrospectionStore { recordPassiveCapture(capture: BrunchIntrospectionTurnCapture): void; recordBaseReport(report: BrunchIntrospectionBaseReport): void; + allPassiveCaptures(): readonly BrunchIntrospectionTurnCapture[]; latestPassiveCapture(): BrunchIntrospectionTurnCapture | undefined; latestPassiveCaptureAfter(cursor: number): BrunchIntrospectionTurnCapture | undefined; passiveCaptureCursor(): number; @@ -47,6 +48,10 @@ class InMemoryStore implements InMemoryBrunchIntrospectionStore { this.baseReports.push(report); } + allPassiveCaptures(): readonly BrunchIntrospectionTurnCapture[] { + return this.passiveCaptures; + } + latestPassiveCapture(): BrunchIntrospectionTurnCapture | undefined { return this.passiveCaptures.at(-1); } diff --git a/src/.pi/extensions/runtime/index.ts b/src/.pi/extensions/runtime/index.ts index 1529e0a5..a85d4e4d 100644 --- a/src/.pi/extensions/runtime/index.ts +++ b/src/.pi/extensions/runtime/index.ts @@ -81,16 +81,24 @@ export function activeToolNamesForBrunchAgentState( pi: ExtensionAPI, state: ResolvedBrunchAgentState, readinessGrade: ReadinessGrade = 'grounding_onboarding', + devAllowedToolNames?: readonly string[], ): string[] { return activeToolNamesForPosture({ registeredToolNames: pi.getAllTools().map((tool) => tool.name), state, readinessGrade, + devAllowedToolNames, }); } -function applyBrunchToolPolicy(pi: ExtensionAPI, state: ResolvedBrunchAgentState): void { - pi.setActiveTools(activeToolNamesForBrunchAgentState(pi, state)); +function applyBrunchToolPolicy( + pi: ExtensionAPI, + state: ResolvedBrunchAgentState, + devAllowedToolNames?: readonly string[], +): void { + pi.setActiveTools( + activeToolNamesForBrunchAgentState(pi, state, 'grounding_onboarding', devAllowedToolNames), + ); } interface TextLikeContent { @@ -153,7 +161,10 @@ function supportsOperationalModePolicy(pi: ExtensionAPI): boolean { ); } -export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { +export function registerBrunchOperationalModePolicy( + pi: ExtensionAPI, + options: { devAllowedToolNames?: readonly string[] | undefined } = {}, +) { if (!supportsOperationalModePolicy(pi)) { return; } @@ -260,12 +271,12 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { appendBrunchAgentRuntimeInit(ctx.sessionManager as BrunchAgentStateEntrySessionManager); } const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager); - applyBrunchToolPolicy(pi, state); + applyBrunchToolPolicy(pi, state, options.devAllowedToolNames); }); pi.on('before_agent_start', async (_event, ctx) => { const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager); - applyBrunchToolPolicy(pi, state); + applyBrunchToolPolicy(pi, state, options.devAllowedToolNames); }); pi.on('tool_call', async (event, ctx) => { diff --git a/src/.pi/extensions/session-query/README.md b/src/.pi/extensions/session-query/README.md new file mode 100644 index 00000000..5c7c357d --- /dev/null +++ b/src/.pi/extensions/session-query/README.md @@ -0,0 +1,26 @@ +# .pi/extensions/session-query/ — dev session-log query tool + +SPEC decisions: D39-L, D58-L, D69-L, D71-L + +## Owns + +Dev-gated, read-only Pi tool registration for `brunch_session_query`: predicate matching over the current session branch, capped path projection, and output truncation/spillover. + +## Does NOT own + +- Provider-payload capture or `/introspect` reporting — sibling `../introspection/` owns the payload plane. +- Prompt-resource manifests or product prompt behavior — `.pi/agents/` and `.pi/skills/`. +- Product transcript/domain projection — top-level `session/` and `projections/` seams. + +## Boundary rules + +```pseudo +rules: + session-query/ -> ctx.sessionManager.getBranch() [read-only] + session-query/ x> session mutation / pi.appendEntry [no writes] + session-query/ x> prompt-resource manifests [tool description nudge only] +``` + +## Migration notes + +This is the slice-2 conversational introspection surface for `dx-introspection-live`: the agent can query and echo exact session-log values in chat without adding product prompt resources or weakening the sealed profile. diff --git a/src/.pi/extensions/session-query/index.test.ts b/src/.pi/extensions/session-query/index.test.ts new file mode 100644 index 00000000..f434d0ec --- /dev/null +++ b/src/.pi/extensions/session-query/index.test.ts @@ -0,0 +1,259 @@ +import { readFile } from 'node:fs/promises'; + +import { fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai'; +import type { SessionEntry } from '@earendil-works/pi-coding-agent'; +import { describe, expect, it } from 'vitest'; + +import { createBrunchFauxHarness } from '../../../dev/index.js'; +import { + BRUNCH_SESSION_QUERY_TOOL, + createBrunchSessionQueryTool, + querySessionBranch, + registerBrunchSessionQuery, +} from './index.js'; + +const branch = [ + messageEntry('u1', { role: 'user', content: 'show me the graph summary' }), + messageEntry('a1', { + role: 'assistant', + content: [ + { type: 'text', text: 'I will inspect it.' }, + { type: 'toolCall', id: 'call-1', name: 'read_graph', arguments: { specId: 42 } }, + ], + }), + messageEntry('t1', { + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'read_graph', + content: [{ type: 'text', text: 'GRAPH EXACT VALUE' }], + details: { review: { status: 'clear' } }, + isError: false, + }), + messageEntry('c1', { + role: 'custom', + customType: 'structured-exchange', + content: [{ type: 'text', text: 'option alpha' }], + details: { x: 'alpha' }, + }), + messageEntry('c2', { + role: 'custom', + customType: 'structured-exchange', + content: [{ type: 'text', text: 'option beta' }], + details: { x: 'beta' }, + }), + messageEntry('b1', { + role: 'bashExecution', + command: 'npm test', + output: 'all green', + exitCode: 0, + cancelled: false, + truncated: false, + }), +]; + +describe('brunch_session_query', () => { + it('finds entries by role, toolName, customType, and contains predicates', () => { + expect(querySessionBranch(branch, { find: { role: 'toolResult', toolName: 'read_graph' } })).toEqual([ + expect.objectContaining({ + ref: expect.objectContaining({ id: 't1', role: 'toolResult', toolName: 'read_graph' }), + }), + ]); + expect( + querySessionBranch(branch, { find: { role: 'custom', customType: 'structured-exchange' } }), + ).toEqual([ + expect.objectContaining({ + ref: expect.objectContaining({ id: 'c2', role: 'custom', customType: 'structured-exchange' }), + }), + ]); + expect(querySessionBranch(branch, { find: { contains: 'all green' } })).toEqual([ + expect.objectContaining({ ref: expect.objectContaining({ id: 'b1', role: 'bashExecution' }) }), + ]); + }); + + it('applies last and range over matching entries rather than branch position', () => { + expect( + querySessionBranch(branch, { + find: { role: 'custom', customType: 'structured-exchange', last: 2 }, + select: 'details.x', + }).map((row) => row.value), + ).toEqual(['alpha', 'beta']); + + expect( + querySessionBranch(branch, { + find: { range: [1, 3] }, + select: 'role', + }).map((row) => row.value), + ).toEqual(['assistant', 'toolResult']); + }); + + it('projects a single capped path and an array of capped paths', () => { + expect( + querySessionBranch(branch, { + find: { role: 'toolResult' }, + select: 'content[*].text', + })[0]?.value, + ).toEqual('GRAPH EXACT VALUE'); + + expect( + querySessionBranch(branch, { + find: { role: 'toolResult' }, + select: ['content[*].text', 'details.review.status'], + })[0]?.value, + ).toEqual({ + 'content[*].text': 'GRAPH EXACT VALUE', + 'details.review.status': 'clear', + }); + }); + + it('roots select at the same normalized view returned when select is omitted', () => { + // No-select returns a flat view: message fields and entry sidecars merged, + // so the model sees content/role/details at the top level. + const entry = querySessionBranch(branch, { find: { toolCallId: 'call-1' } })[0]?.value; + expect(entry).toEqual({ + type: 'message', + id: 't1', + parentId: null, + timestamp: '2026-06-09T00:00:00.000Z', + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'read_graph', + content: [{ type: 'text', text: 'GRAPH EXACT VALUE' }], + details: { review: { status: 'clear' } }, + isError: false, + }); + // The path the model naturally reaches for from the no-select shape resolves. + expect( + querySessionBranch(branch, { find: { toolCallId: 'call-1' }, select: 'content[0].text' })[0]?.value, + ).toEqual('GRAPH EXACT VALUE'); + }); + + it('returns multiple projected rows for multi-match queries', () => { + expect( + querySessionBranch(branch, { + find: { role: 'custom', customType: 'structured-exchange', last: 2 }, + select: 'content[*].text', + }), + ).toEqual([ + { + ref: { id: 'c1', index: 3, role: 'custom', customType: 'structured-exchange' }, + value: 'option alpha', + }, + { + ref: { id: 'c2', index: 4, role: 'custom', customType: 'structured-exchange' }, + value: 'option beta', + }, + ]); + }); + + it('truncates large values with temp-file spillover and respects maxBytes', async () => { + const tool = createBrunchSessionQueryTool(); + const large = 'x'.repeat(200); + const result = await tool.execute( + 'query-1', + { find: { role: 'toolResult' }, select: 'content[*].text', maxBytes: 80 }, + undefined, + undefined, + { sessionManager: { getBranch: () => [messageEntry('big', toolResultMessage(large))] } } as never, + ); + + expect(result.content[0]?.type).toBe('text'); + const text = result.content[0]?.type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Output truncated'); + expect(result.details?.truncation?.truncated).toBe(true); + expect(result.details?.truncation?.outputBytes).toBeLessThanOrEqual(80); + expect(await readFile(result.details!.fullOutputPath!, 'utf8')).toContain(large); + }); + + it('runs in a faux turn and returns verbatim projected values as a tool result', async () => { + const harness = await createBrunchFauxHarness({ + responses: [ + fauxAssistantMessage( + fauxToolCall( + BRUNCH_SESSION_QUERY_TOOL, + { find: { role: 'custom' }, select: 'content[*].text' }, + { id: 'query-call' }, + ), + ), + fauxAssistantMessage('done'), + ], + customTools: [createBrunchSessionQueryTool()], + }); + + try { + harness.session.sessionManager.appendCustomMessageEntry( + 'structured-exchange', + [{ type: 'text', text: 'VERBATIM CUSTOM VALUE' }], + true, + ); + await harness.session.prompt('pull the custom value'); + + const toolResult = harness.session.messages.find( + (message) => message.role === 'toolResult' && message.toolName === BRUNCH_SESSION_QUERY_TOOL, + ); + if (toolResult?.role !== 'toolResult') throw new Error('brunch_session_query tool result not found'); + expect(toolResult.content[0]).toEqual( + expect.objectContaining({ text: expect.stringContaining('VERBATIM CUSTOM VALUE') }), + ); + } finally { + harness.dispose(); + } + }); + + it('registers the tool through the extension registrar', () => { + const tools: Array<{ name: string }> = []; + registerBrunchSessionQuery({ registerTool: (tool: { name: string }) => tools.push(tool) } as never); + expect(tools.map((tool) => tool.name)).toEqual([BRUNCH_SESSION_QUERY_TOOL]); + }); + + it('advertises a JSON Schema draft 2020-12 parameter schema (range uses prefixItems, no draft-07 tuple form)', () => { + const schema = createBrunchSessionQueryTool().parameters as Record; + expect(schema.$schema).toContain('draft/2020-12'); + expect(draft07TupleSmells(schema)).toEqual([]); + const range = ( + ((schema.properties as Record>).find.properties ?? {}) as Record< + string, + Record + > + ).range; + expect(range).toHaveProperty('prefixItems'); + }); +}); + +// Anthropic rejects tool schemas that are not draft 2020-12; the draft-07 tuple +// form (array-valued `items` + `additionalItems`) is the specific violation that +// kept brunch_session_query from being callable live once it was advertised. +function draft07TupleSmells(node: unknown, path = '$'): string[] { + if (Array.isArray(node)) return node.flatMap((item, i) => draft07TupleSmells(item, `${path}[${i}]`)); + if (typeof node !== 'object' || node === null) return []; + const record = node as Record; + const smells: string[] = []; + if (Array.isArray(record.items)) smells.push(`${path}.items is an array`); + if ('additionalItems' in record) smells.push(`${path}.additionalItems present`); + for (const [key, value] of Object.entries(record)) + smells.push(...draft07TupleSmells(value, `${path}.${key}`)); + return smells; +} + +// Faux session entries for the dynamic projector. The entry envelope is the +// canonical SessionEntry shape; only the inner message payload is cast, since +// these fixtures deliberately use partial role shapes to exercise path +// projection rather than reconstruct every required AgentMessage field. +function messageEntry(id: string, message: Record): SessionEntry { + return { + type: 'message', + id, + parentId: null, + timestamp: '2026-06-09T00:00:00.000Z', + message: message as unknown as Extract['message'], + }; +} + +function toolResultMessage(text: string) { + return { + role: 'toolResult', + toolCallId: 'call-big', + toolName: 'read', + content: [{ type: 'text', text }], + isError: false, + }; +} diff --git a/src/.pi/extensions/session-query/index.ts b/src/.pi/extensions/session-query/index.ts new file mode 100644 index 00000000..47a7029e --- /dev/null +++ b/src/.pi/extensions/session-query/index.ts @@ -0,0 +1,248 @@ +import { defineTool, type ExtensionAPI, type SessionEntry } from '@earendil-works/pi-coding-agent'; +import * as z from 'zod'; + +import { devToolParameters } from '../shared/pi-tool-schema.js'; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + projectSelection, + rowsToText, + truncateQueryOutput, + type TruncationResult, +} from '../shared/query-projection.js'; + +export const BRUNCH_SESSION_QUERY_TOOL = 'brunch_session_query'; +const DEFAULT_LAST_MATCHING = 1; + +// The query `role` predicate matches `roleFor(entry)`, which surfaces a message +// entry's `message.role`. Anchor the predicate vocabulary to that canonical +// union: `satisfies Record` makes a new Pi role a build error +// until it is listed here, and the Zod enum is constructed from these keys so the +// runtime contract cannot drift from the type. +type EntryRole = Extract['message']['role']; +const ENTRY_ROLES = { + user: true, + assistant: true, + toolResult: true, + custom: true, + bashExecution: true, + branchSummary: true, + compactionSummary: true, +} as const satisfies Record; +const ENTRY_ROLE_NAMES = Object.keys(ENTRY_ROLES) as [EntryRole, ...EntryRole[]]; + +const zFind = z + .object({ + role: z.enum(ENTRY_ROLE_NAMES).optional(), + toolName: z.string().optional(), + toolCallId: z.string().optional(), + customType: z.string().optional(), + isError: z.boolean().optional(), + contains: z.string().optional(), + last: z.number().min(1).optional(), + range: z.tuple([z.number().min(0), z.number().min(0)]).optional(), + }) + .strict(); + +const zBrunchSessionQueryParams = z + .object({ + find: zFind, + select: z.union([z.string(), z.array(z.string())]).optional(), + maxBytes: z.number().min(1).optional(), + format: z.enum(['json', 'text']).optional(), + }) + .strict(); + +export type BrunchSessionQueryParams = z.infer; + +export interface BrunchSessionQueryRef { + readonly id?: string; + readonly index: number; + readonly role?: string; + readonly toolName?: string; + readonly customType?: string; +} + +export interface BrunchSessionQueryRow { + readonly ref: BrunchSessionQueryRef; + readonly value: unknown; +} + +export interface BrunchSessionQueryDetails { + readonly matched: number; + readonly returned: number; + readonly selected?: string | readonly string[]; + readonly truncation?: TruncationResult; + readonly fullOutputPath?: string; +} + +export function registerBrunchSessionQuery(pi: ExtensionAPI): void { + pi.registerTool(createBrunchSessionQueryTool()); +} + +export function createBrunchSessionQueryTool() { + return defineTool, BrunchSessionQueryDetails>({ + name: BRUNCH_SESSION_QUERY_TOOL, + label: 'Brunch session query', + description: [ + 'Read-only dev tool for querying the current Pi session branch. Finds entries by predicate and returns verbatim projected value(s).', + 'Use brunch_session_query when the user asks you to inspect or quote prior session messages, tool calls/results, or custom entries. Echo returned values verbatim in a fenced block when asked for exact bytes.', + 'select is a dotted/indexed path rooted at the matched entry (the object returned when select is omitted, a flat view where message fields and entry sidecars are merged), e.g. "content[0].text" for a tool result\'s text, "content[*].text" for every text block, or "details" for the structured sidecar. Omit select to see the whole entry first.', + `Output is truncated to maxBytes (default ${formatSize(DEFAULT_MAX_BYTES)}) or ${DEFAULT_MAX_LINES} lines; truncated full output is saved to a temp file.`, + ].join(' '), + promptSnippet: + 'Query the current session branch by predicate and project verbatim values from matching entries.', + promptGuidelines: [ + 'Use brunch_session_query when the user asks for exact prior session-log values; quote returned values verbatim rather than paraphrasing when exactness matters.', + ], + parameters: devToolParameters(zBrunchSessionQueryParams), + async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) { + const params = zBrunchSessionQueryParams.parse(rawParams); + const branch = ctx.sessionManager.getBranch(); + const rows = querySessionBranch(branch, params); + const serialized = params.format === 'text' ? rowsToSessionText(rows) : JSON.stringify(rows, null, 2); + const maxBytes = params.maxBytes ?? DEFAULT_MAX_BYTES; + const { content, details } = await truncateQueryOutput( + serialized, + maxBytes, + { + matched: countMatchingEntries(branch, params.find), + returned: rows.length, + ...(params.select === undefined ? {} : { selected: params.select }), + }, + 'brunch-session-query-', + ); + + return { + content: [{ type: 'text', text: content }], + details, + }; + }, + }); +} + +export function querySessionBranch( + branch: readonly SessionEntry[], + params: BrunchSessionQueryParams, +): BrunchSessionQueryRow[] { + const matches = branch + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entryMatchesFind(entry, params.find)); + const windowed = windowMatches(matches, params.find); + + return windowed.map(({ entry, index }) => ({ + ref: refForEntry(entry, index), + value: projectSelection(queryableEntry(entry), params.select), + })); +} + +function countMatchingEntries( + branch: readonly SessionEntry[], + find: BrunchSessionQueryParams['find'], +): number { + return branch.filter((entry) => entryMatchesFind(entry, find)).length; +} + +function entryMatchesFind(entry: SessionEntry, find: BrunchSessionQueryParams['find']): boolean { + const view = queryableEntry(entry); + if (find.role !== undefined && roleFor(entry) !== find.role) return false; + if (find.toolName !== undefined && valueAt(view, ['toolName']) !== find.toolName) return false; + if (find.toolCallId !== undefined && valueAt(view, ['toolCallId']) !== find.toolCallId) return false; + if (find.customType !== undefined && customTypeFor(entry) !== find.customType) return false; + if (find.isError !== undefined && valueAt(view, ['isError']) !== find.isError) return false; + if (find.contains !== undefined && !textForContains(entry).includes(find.contains)) return false; + return true; +} + +function windowMatches(matches: readonly T[], find: BrunchSessionQueryParams['find']): readonly T[] { + const ranged = find.range ? matches.slice(find.range[0], find.range[1]) : matches; + if (find.last !== undefined) return ranged.slice(-find.last); + return find.range ? ranged : ranged.slice(-DEFAULT_LAST_MATCHING); +} + +// One normalized queryable view per entry so a `select` path addresses the same +// object returned when `select` is omitted. `SessionEntry` is Pi's canonical +// discriminated union (`getBranch(): SessionEntry[]`): only `message` entries +// nest their payload under `.message`, while custom/bash/summary entries keep +// their fields and sidecars (`details`/`data`) at the entry level. Narrowing on +// `entry.type` flattens the message variant so `content[0].text`, `role`, and +// `details` resolve uniformly across entry kinds. +function queryableEntry(entry: SessionEntry): Record { + if (entry.type === 'message') { + const { message, ...sidecars } = entry; + return { ...sidecars, ...message }; + } + return { ...entry }; +} + +function refForEntry(entry: SessionEntry, index: number): BrunchSessionQueryRef { + const role = roleFor(entry); + const toolName = valueAt(queryableEntry(entry), ['toolName']); + const customType = customTypeFor(entry); + return { + id: entry.id, + index, + ...(role ? { role } : {}), + ...(typeof toolName === 'string' ? { toolName } : {}), + ...(customType ? { customType } : {}), + }; +} + +function roleFor(entry: SessionEntry): string | undefined { + const role = valueAt(queryableEntry(entry), ['role']); + if (typeof role === 'string') return role; + if (entry.type === 'custom' || entry.type === 'custom_message') return 'custom'; + return undefined; +} + +function customTypeFor(entry: SessionEntry): string | undefined { + const customType = valueAt(queryableEntry(entry), ['customType']); + return typeof customType === 'string' ? customType : undefined; +} + +function textForContains(entry: SessionEntry): string { + const view = queryableEntry(entry); + const chunks = [ + ...textChunks(valueAt(view, ['content'])), + valueAt(view, ['output']), + valueAt(view, ['command']), + valueAt(view, ['summary']), + valueAt(view, ['data']), + valueAt(view, ['details']), + ]; + return chunks + .filter((chunk) => chunk !== undefined) + .map((chunk) => (typeof chunk === 'string' ? chunk : JSON.stringify(chunk))) + .join('\n'); +} + +function textChunks(content: unknown): unknown[] { + if (typeof content === 'string') return [content]; + if (!Array.isArray(content)) return []; + return content.flatMap((block) => { + if (!isRecord(block)) return []; + if (block.type === 'text' && typeof block.text === 'string') return [block.text]; + if (block.type === 'toolCall') return [block.name, block.arguments]; + return []; + }); +} + +function valueAt(value: unknown, path: readonly string[]): unknown { + return path.reduce((current, key) => (isRecord(current) ? current[key] : undefined), value); +} + +function rowsToSessionText(rows: readonly BrunchSessionQueryRow[]): string { + return rowsToText(rows, (ref) => + [ref.index, ref.role, ref.toolName, ref.customType] + .filter((part) => part !== undefined) + .map((part) => String(part)) + .join(' '), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export default registerBrunchSessionQuery; diff --git a/src/.pi/extensions/shared/pi-tool-schema.ts b/src/.pi/extensions/shared/pi-tool-schema.ts new file mode 100644 index 00000000..222fc8ad --- /dev/null +++ b/src/.pi/extensions/shared/pi-tool-schema.ts @@ -0,0 +1,17 @@ +import type { TSchema } from 'typebox'; +import * as z from 'zod'; + +/** + * Zod → Pi tool-parameter adapter for the dev-gated query tools. + * + * Pi's `defineTool` types `parameters` as a TypeBox `TSchema`, but Brunch authors + * boundary schemas in Zod (D41-L) and exports JSON Schema with + * `z.toJSONSchema(..., { unrepresentable: 'throw' })`. Zod v4 emits JSON Schema + * draft 2020-12 (tuples become `prefixItems`, not the draft-07 array-form `items` + * that Anthropic's strict validator rejects). This is the dev-plane sibling of the + * structured-exchange `pi-schema.ts` adapter, kept here so the session-query / + * introspect-query tools do not depend on the exchanges seam. + */ +export function devToolParameters(schema: z.ZodType): TSchema { + return z.toJSONSchema(schema, { unrepresentable: 'throw' }) as unknown as TSchema; +} diff --git a/src/.pi/extensions/shared/query-projection.ts b/src/.pi/extensions/shared/query-projection.ts new file mode 100644 index 00000000..3f6c22cc --- /dev/null +++ b/src/.pi/extensions/shared/query-projection.ts @@ -0,0 +1,110 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, + type TruncationResult, + withFileMutationQueue, +} from '@earendil-works/pi-coding-agent'; + +export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize }; +export type { TruncationResult }; + +export interface TruncatedQueryOutput> { + readonly content: string; + readonly details: TDetails & { readonly truncation?: TruncationResult; readonly fullOutputPath?: string }; +} + +export function projectPath(value: unknown, path: string): unknown { + const segments = parsePath(path); + const values = projectSegments([value], segments); + return values.length === 1 ? values[0] : values; +} + +export function parsePath(path: string): string[] { + if (!path.trim()) throw new Error('select path must not be empty'); + return path.split('.').flatMap((part) => { + if (!part) throw new Error(`invalid select path: ${path}`); + const match = /^(?[^[\]]+)(?:\[(?\d+|\*)\])?$/.exec(part); + const key = match?.groups?.key; + const index = match?.groups?.index; + if (!key) throw new Error(`invalid select path: ${path}`); + return index === undefined ? [key] : [key, `[${index}]`]; + }); +} + +export function projectSelection(value: unknown, select: string | readonly string[] | undefined): unknown { + if (select === undefined) return value; + if (typeof select === 'string') return projectPath(value, select); + return Object.fromEntries(select.map((path) => [path, projectPath(value, path)])); +} + +export function rowsToText( + rows: readonly { readonly ref: TRef; readonly value: unknown }[], + labelForRef: (ref: TRef) => string, +): string { + return rows + .map((row) => + [ + `# ${labelForRef(row.ref)}`, + typeof row.value === 'string' ? row.value : JSON.stringify(row.value, null, 2), + ].join('\n'), + ) + .join('\n\n'); +} + +export async function truncateQueryOutput>( + output: string, + maxBytes: number, + details: TDetails, + tempPrefix: string, +): Promise> { + const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes }); + if (!truncation.truncated) return { content: truncation.content, details }; + + const tempDir = await mkdtemp(join(tmpdir(), tempPrefix)); + const fullOutputPath = join(tempDir, 'output.txt'); + await withFileMutationQueue(fullOutputPath, async () => { + await writeFile(fullOutputPath, output, 'utf8'); + }); + + const notice = [ + '', + `[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`, + `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`, + `Full output saved to: ${fullOutputPath}]`, + ].join(' '); + + return { + content: `${truncation.content}\n${notice}`, + details: { ...details, truncation, fullOutputPath }, + }; +} + +function projectSegments(values: readonly unknown[], segments: readonly string[]): unknown[] { + if (segments.length === 0) return [...values]; + const [segment, ...rest] = segments; + if (segment === undefined) return [...values]; + const next = values.flatMap((value) => projectSegment(value, segment)); + return projectSegments(next, rest); +} + +function projectSegment(value: unknown, segment: string): unknown[] { + if (segment === '[*]') return Array.isArray(value) ? value : []; + const indexMatch = /^\[(\d+)\]$/.exec(segment); + if (indexMatch) { + if (!Array.isArray(value)) return []; + const item = value[Number(indexMatch[1])]; + return item === undefined ? [] : [item]; + } + if (!isRecord(value)) return []; + return segment in value ? [value[segment]] : []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/.pi/extensions/system-prompts/index.ts b/src/.pi/extensions/system-prompts/index.ts index 16a9148b..ea855b27 100644 --- a/src/.pi/extensions/system-prompts/index.ts +++ b/src/.pi/extensions/system-prompts/index.ts @@ -46,7 +46,11 @@ function projectState(ctx: BeforeAgentStartContextLike | undefined) { return projectBrunchAgentState(ctx?.sessionManager?.getEntries() ?? []); } -export function registerBrunchPrompting(pi: ExtensionAPI, promptContext: BrunchPromptContextProvider): void { +export function registerBrunchPrompting( + pi: ExtensionAPI, + promptContext: BrunchPromptContextProvider, + options: { devAllowedToolNames?: readonly string[] | undefined } = {}, +): void { if (!supportsPrompting(pi)) return; pi.on('before_agent_start', async (event, ctx) => { @@ -55,7 +59,12 @@ export function registerBrunchPrompting(pi: ExtensionAPI, promptContext: BrunchP const state = projectState(ctx as BeforeAgentStartContextLike | undefined); const activeTools = typeof (pi as Partial).getAllTools === 'function' - ? activeToolNamesForBrunchAgentState(pi, state, resolvedPromptContext.spec.readinessGrade) + ? activeToolNamesForBrunchAgentState( + pi, + state, + resolvedPromptContext.spec.readinessGrade, + options.devAllowedToolNames, + ) : []; if (typeof (pi as Partial).setActiveTools === 'function') { pi.setActiveTools(activeTools); diff --git a/src/app/README.md b/src/app/README.md index d4d0dd0f..00eb56fd 100644 --- a/src/app/README.md +++ b/src/app/README.md @@ -8,8 +8,11 @@ Product host entrypoints and wiring for Brunch runtime modes. Current entrypoints: -- `brunch.ts` — CLI mode dispatch for TUI, RPC, web, and print. -- `brunch-tui.ts` — TUI launch path and embedded Pi session runtime wiring. +- `brunch.ts` — CLI mode dispatch for TUI, RPC, and print. `--mode web` is + reserved but deferred: the browser client is served only as the TUI sidecar + (a standalone headless web host is a future feature). +- `brunch-tui.ts` — TUI launch path, embedded Pi session runtime wiring, and the + web sidecar (`startWebHost` + browser auto-open). ## Does not own diff --git a/src/app/brunch-dev.ts b/src/app/brunch-dev.ts new file mode 100644 index 00000000..dce67047 --- /dev/null +++ b/src/app/brunch-dev.ts @@ -0,0 +1,3 @@ +export function isBrunchDevEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.BRUNCH_DEV === '1'; +} diff --git a/src/app/brunch-tui.test.ts b/src/app/brunch-tui.test.ts index 9f324dac..98f59af0 100644 --- a/src/app/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -3,16 +3,19 @@ import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { + createAgentSessionRuntime, SessionManager, type ExtensionCommandContext, type ExtensionContext, type ExtensionUIContext, type RegisteredCommand, + type ToolDefinition, } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; import { BRUNCH_CONTINUE_COMMAND, + BRUNCH_INTROSPECTION_COMMAND, BRUNCH_LENS_COMMAND, BRUNCH_MODE_COMMAND, BRUNCH_STRATEGY_COMMAND, @@ -20,6 +23,7 @@ import { BRUNCH_SWITCH_SHORTCUT, chromeStateForWorkspace, createBrunchPiExtensions, + createInMemoryBrunchIntrospectionStore, registerBrunchAlternatives, registerBrunchOperationalModePolicy, runBrunchWorkspaceCommand, @@ -43,6 +47,7 @@ import { createBrunchSettingsManager, createBrunchAgentSessionRuntimeFactory, runBrunchTui, + runWithScopedBrunchOfflineDefault, } from './brunch-tui.js'; describe('Brunch TUI boot', () => { @@ -73,6 +78,50 @@ describe('Brunch TUI boot', () => { } }); + it('boots the real runtime seam with ready context and BRUNCH_DEV-gated query tools', async () => { + const productBoot = await bootRuntimeThroughRunBrunchTui({ dev: false }); + try { + expect(productBoot.runtime.session.sessionManager.getHeader()).toMatchObject({ + cwd: productBoot.cwd, + id: expect.any(String), + type: 'session', + }); + await expect(readSessionContextDetails(productBoot.runtime.session)).resolves.toMatchObject({ + status: 'ready', + specId: expect.any(Number), + }); + await expect(readWorkspaceContextMarkdownFiles(productBoot.runtime.session)).resolves.toContain( + 'boot-seam.md', + ); + expect(productBoot.runtime.session.getAllTools().map((tool) => tool.name)).not.toEqual( + expect.arrayContaining(['brunch_session_query', 'brunch_introspect_query']), + ); + expect(productBoot.runtime.session.getActiveToolNames()).not.toEqual( + expect.arrayContaining(['brunch_session_query', 'brunch_introspect_query']), + ); + } finally { + await productBoot.runtime.dispose(); + productBoot.restoreEnv(); + } + + const devBoot = await bootRuntimeThroughRunBrunchTui({ dev: true }); + try { + expect(devBoot.runtime.session.sessionManager.getHeader()).toMatchObject({ cwd: devBoot.cwd }); + await expect(readSessionContextDetails(devBoot.runtime.session)).resolves.toMatchObject({ + status: 'ready', + }); + expect(devBoot.runtime.session.getAllTools().map((tool) => tool.name)).toEqual( + expect.arrayContaining(['brunch_session_query', 'brunch_introspect_query']), + ); + expect(devBoot.runtime.session.getActiveToolNames()).toEqual( + expect.arrayContaining(['brunch_session_query', 'brunch_introspect_query']), + ); + } finally { + await devBoot.runtime.dispose(); + devBoot.restoreEnv(); + } + }); + it('registers graph tools on the default product runtime path', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-tui-graph-runtime-')); const agentDir = await mkdtemp(join(tmpdir(), 'brunch-agent-dir-')); @@ -284,6 +333,141 @@ describe('Brunch TUI boot', () => { ]); }); + it('threads BRUNCH_DEV introspection state into the interactive launch context', async () => { + const previous = process.env.BRUNCH_DEV; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + const observed: unknown[] = []; + + try { + process.env.BRUNCH_DEV = '1'; + await runBrunchTui({ + cwd: '/tmp/project', + autoOpen: false, + 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 () => null, + launchInteractive: async ({ dev }) => { + observed.push(dev?.introspection.enabled); + expect(dev?.introspection.store).toBeDefined(); + }, + }); + + delete process.env.BRUNCH_DEV; + await runBrunchTui({ + cwd: '/tmp/project', + autoOpen: false, + 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 () => null, + launchInteractive: async ({ dev }) => { + observed.push(dev); + }, + }); + } finally { + if (previous === undefined) { + delete process.env.BRUNCH_DEV; + } else { + process.env.BRUNCH_DEV = previous; + } + } + + expect(observed).toEqual([true, undefined]); + }); + + it('registers TUI-gated introspection last when the launch context enables it', async () => { + const events: string[] = []; + const commands: string[] = []; + const store = createInMemoryBrunchIntrospectionStore(); + + await createBrunchPiExtensions( + chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-1')), + undefined, + { + coordinator: noOpWorkspaceCoordinator('/tmp/project'), + introspection: { enabled: true, store }, + }, + )({ + on: (event: string) => events.push(event), + registerCommand: (name: string) => commands.push(name), + registerShortcut: (_name: string, _options: unknown) => {}, + registerTool: (_tool: unknown) => {}, + registerMessageRenderer: (_type: string) => {}, + sendMessage: (_message: unknown) => {}, + getAllTools: () => [], + setActiveTools: (_tools: string[]) => {}, + } as never); + + expect(commands.at(-1)).toBe(BRUNCH_INTROSPECTION_COMMAND); + 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 } = {}; + await expect( + runWithScopedBrunchOfflineDefault({ + dev: false, + env: productEnv, + run: async () => { + expect(productEnv.PI_OFFLINE).toBe('1'); + }, + }), + ).resolves.toBeUndefined(); + expect(productEnv.PI_OFFLINE).toBeUndefined(); + + const devEnv: { PI_OFFLINE?: string } = { PI_OFFLINE: '1' }; + await expect( + runWithScopedBrunchOfflineDefault({ + dev: true, + env: devEnv, + run: async () => { + expect(devEnv.PI_OFFLINE).toBeUndefined(); + throw new Error('prove finally restore'); + }, + }), + ).rejects.toThrow('prove finally restore'); + expect(devEnv.PI_OFFLINE).toBe('1'); + }); + + it('keeps src/dev build-excluded', async () => { + const buildConfig = JSON.parse( + await readFile(join(import.meta.dirname, '..', '..', 'tsconfig.build.json'), 'utf8'), + ) as { + exclude?: string[]; + }; + + expect(buildConfig.exclude).toContain('src/dev'); + }); + it('opens the advertised sidecar route only through the injected opener', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); @@ -1247,6 +1431,83 @@ describe('Brunch TUI boot', () => { }); }); +async function bootRuntimeThroughRunBrunchTui(options: { dev: boolean }) { + const cwd = await mkdtemp(join(tmpdir(), `brunch-boot-seam-${options.dev ? 'dev' : 'prod'}-`)); + const agentDir = await mkdtemp(join(tmpdir(), 'brunch-agent-dir-')); + await writeFile(join(cwd, 'boot-seam.md'), '# Boot seam\n'); + + const previousDev = process.env.BRUNCH_DEV; + const hadPreviousDev = Object.hasOwn(process.env, 'BRUNCH_DEV'); + if (options.dev) { + process.env.BRUNCH_DEV = '1'; + } else { + delete process.env.BRUNCH_DEV; + } + + const restoreEnv = () => { + if (hadPreviousDev && previousDev !== undefined) { + process.env.BRUNCH_DEV = previousDev; + } else { + delete process.env.BRUNCH_DEV; + } + }; + + let runtime: Awaited> | undefined; + try { + await runBrunchTui({ + cwd, + autoOpen: false, + runWorkspaceDialogPreflight: async () => ({ action: 'newSpec', title: 'Boot seam smoke' }), + webSidecarRunner: async () => null, + launchInteractive: async (context) => { + runtime = await createAgentSessionRuntime(createBrunchAgentSessionRuntimeFactory(context), { + cwd, + agentDir, + sessionManager: context.workspace.session.manager, + }); + }, + }); + } catch (error) { + restoreEnv(); + throw error; + } + + if (!runtime) { + restoreEnv(); + throw new Error('runBrunchTui did not reach launchInteractive'); + } + + return { cwd, runtime, restoreEnv }; +} + +async function readSessionContextDetails(session: { + getToolDefinition(name: string): ToolDefinition | undefined; + sessionManager: unknown; +}) { + const tool = session.getToolDefinition('read_session_context'); + if (!tool) throw new Error('read_session_context tool is not registered'); + const result = await tool.execute('boot-session-context', {}, undefined, undefined, { + sessionManager: session.sessionManager, + } as never); + return result.details; +} + +async function readWorkspaceContextMarkdownFiles(session: { + getToolDefinition(name: string): ToolDefinition | undefined; + sessionManager: unknown; +}): Promise { + const tool = session.getToolDefinition('read_workspace_context'); + if (!tool) throw new Error('read_workspace_context tool is not registered'); + const result = (await tool.execute( + 'boot-workspace-context', + { mode: 'cwd_inventory' }, + undefined, + undefined, + { sessionManager: session.sessionManager } as never, + )) as { details: { data: { markdownFiles: Array<{ path: string }> } } }; + return result.details.data.markdownFiles.map((file) => file.path); +} + async function writeHostilePiSettings(cwd: string, agentDir: string): Promise { const hostileSettings = { lastChangelogVersion: '999.0.0-hostile', diff --git a/src/app/brunch-tui.ts b/src/app/brunch-tui.ts index e4a3b8d3..058258fe 100644 --- a/src/app/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -10,7 +10,12 @@ import { type CreateAgentSessionRuntimeFactory, } from '@earendil-works/pi-coding-agent'; -import { chromeStateForWorkspace, createBrunchPiExtensions } from '../.pi/brunch-pi-extensions.js'; +import { + chromeStateForWorkspace, + createBrunchPiExtensions, + createInMemoryBrunchIntrospectionStore, + type BrunchIntrospectionStore, +} from '../.pi/brunch-pi-extensions.js'; import { applyBrunchOfflineDefault, createBrunchPiSettings } from '../.pi/brunch-pi-settings.js'; import { runWorkspaceDialogPreflight } from '../.pi/components/workspace-dialog.js'; import { @@ -32,6 +37,7 @@ import { type SpecSessionActivationCoordinator, type SpecSessionActivationDecision, } from '../session/workspace-session-coordinator.js'; +import { isBrunchDevEnabled } from './brunch-dev.js'; export { BRUNCH_SETTINGS_AUDITED_GETTERS, BRUNCH_SETTINGS_POLICY, @@ -64,6 +70,14 @@ export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState; coordinator: BrunchTuiCoordinator; productUpdates?: ProductUpdatePublisher; + dev?: BrunchTuiDevOptions; +} + +export interface BrunchTuiDevOptions { + readonly introspection: { + readonly enabled: true; + readonly store: BrunchIntrospectionStore; + }; } export interface BrunchTuiOptions { @@ -88,6 +102,7 @@ export async function runBrunchTui(options: BrunchTuiOptions = {}): Promise { let currentWorkspace = await coordinator.bindCurrentSpecToReplacementSession(sessionManager); const graph = await openWorkspaceGraphRuntime(cwd); @@ -293,6 +319,7 @@ export function createBrunchAgentSessionRuntimeFactory({ 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); @@ -374,6 +401,35 @@ async function launchPiInteractive(context: BrunchTuiLaunchContext): Promise { + await new InteractiveMode(runtime).run(); + }, + }); +} + +export async function runWithScopedBrunchOfflineDefault(options: { + readonly dev: boolean; + readonly env?: { PI_OFFLINE?: string }; + readonly run: () => Promise; +}): Promise { + const env = options.env ?? process.env; + const previous = env.PI_OFFLINE; + const hadPrevious = Object.hasOwn(env, 'PI_OFFLINE'); + try { + if (options.dev) { + delete env.PI_OFFLINE; + } else { + applyBrunchOfflineDefault(env); + } + await options.run(); + } finally { + if (hadPrevious && previous !== undefined) { + env.PI_OFFLINE = previous; + } else { + delete env.PI_OFFLINE; + } + } } diff --git a/src/app/brunch.test.ts b/src/app/brunch.test.ts index 27482645..fc7a600d 100644 --- a/src/app/brunch.test.ts +++ b/src/app/brunch.test.ts @@ -1,6 +1,6 @@ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import { SessionManager } from '@earendil-works/pi-coding-agent'; @@ -12,7 +12,7 @@ import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, } from '../session/workspace-session-coordinator.js'; -import { runBrunchCli, type WebHostRunnerOptions } from './brunch.js'; +import { runBrunchCli } from './brunch.js'; function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { return { @@ -74,20 +74,14 @@ function collectStream(stream: PassThrough): string[] { } describe('Brunch CLI dispatch', () => { - it('routes --mode web through an injectable web host runner', async () => { - let launchedWith: WebHostRunnerOptions | null = null; - - const code = await runBrunchCli({ - argv: ['--mode=web'], - cwd: '/tmp/brunch-project', - coordinator: coordinator(), - webHostRunner: async (options) => { - launchedWith = options; - }, - }); - - expect(code).toBe(0); - expect(launchedWith).toMatchObject({ cwd: '/tmp/brunch-project' }); + it('rejects --mode web as a deferred feature (web UI runs only as the TUI sidecar)', async () => { + await expect( + runBrunchCli({ + argv: ['--mode=web'], + cwd: '/tmp/brunch-project', + coordinator: coordinator(), + }), + ).rejects.toThrow(/web mode is not available yet/u); }); it('routes empty argv to the TUI launch path', async () => { @@ -106,6 +100,30 @@ describe('Brunch CLI dispatch', () => { expect(launchedTui).toBe(true); }); + it('parses --cwd for TUI launch and resolves relative paths against process cwd', async () => { + const launchedCwds: string[] = []; + + await runBrunchCli({ + argv: ['--cwd', '.fixtures/workbenches/demo'], + coordinator: coordinator(), + launchTui: async (options) => { + launchedCwds.push(options?.cwd ?? ''); + }, + }); + await runBrunchCli({ + argv: ['--cwd=/tmp/brunch-absolute'], + coordinator: coordinator(), + launchTui: async (options) => { + launchedCwds.push(options?.cwd ?? ''); + }, + }); + + expect(launchedCwds).toEqual([ + resolve(process.cwd(), '.fixtures/workbenches/demo'), + '/tmp/brunch-absolute', + ]); + }); + it('routes --mode print through the coordinator state and exits', async () => { let output = ''; @@ -183,9 +201,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, @@ -233,11 +262,11 @@ describe('Brunch CLI dispatch', () => { }); }); - it('gates dev RPC methods in CLI rpc mode behind BRUNCH_DEV_RPC=1', async () => { - const previous = process.env.BRUNCH_DEV_RPC; + it('gates dev RPC methods in CLI rpc mode behind BRUNCH_DEV=1', async () => { + const previous = process.env.BRUNCH_DEV; const stdout = new PassThrough(); const chunks = collectStream(stdout); - process.env.BRUNCH_DEV_RPC = '1'; + process.env.BRUNCH_DEV = '1'; try { const code = await runBrunchCli({ argv: ['--mode=rpc'], @@ -251,9 +280,9 @@ describe('Brunch CLI dispatch', () => { expect(JSON.stringify(JSON.parse(chunks.join('')))).toContain('dev.graph.mutateGraph'); } finally { if (previous === undefined) { - delete process.env.BRUNCH_DEV_RPC; + delete process.env.BRUNCH_DEV; } else { - process.env.BRUNCH_DEV_RPC = previous; + process.env.BRUNCH_DEV = previous; } } }); diff --git a/src/app/brunch.ts b/src/app/brunch.ts index 01a47479..09951be6 100644 --- a/src/app/brunch.ts +++ b/src/app/brunch.ts @@ -1,3 +1,4 @@ +import { isAbsolute, resolve } from 'node:path'; import process from 'node:process'; import type { Readable, Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -6,31 +7,25 @@ import { projectWorkspaceState } from '../projections/workspace/workspace-state. import { renderWorkspaceState } from '../renderers/workspace/workspace-state.js'; import { createRpcHandlers, runJsonRpcLineServer } from '../rpc/handlers.js'; import { createProductUpdatePublisher } from '../rpc/product-updates.js'; -import { startWebHost } from '../rpc/web-host.js'; import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, } from '../session/workspace-session-coordinator.js'; +import { isBrunchDevEnabled } from './brunch-dev.js'; import { runBrunchTui } from './brunch-tui.js'; -export interface WebHostRunnerOptions { - cwd: string; - coordinator: WorkspaceSessionCoordinator; -} - export interface BrunchCliOptions { argv?: string[]; cwd?: string; coordinator?: WorkspaceSessionCoordinator; stdin?: Readable; stdout?: Writable | ((chunk: string) => void); - webHostRunner?: (options: WebHostRunnerOptions) => Promise; launchTui?: typeof runBrunchTui; } export async function runBrunchCli(options: BrunchCliOptions = {}): Promise { const argv = options.argv ?? process.argv.slice(2); - const cwd = options.cwd ?? process.cwd(); + const cwd = parseCwd(argv) ?? options.cwd ?? process.cwd(); const mode = parseMode(argv); const coordinator = options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }); @@ -50,7 +45,7 @@ export async function runBrunchCli(options: BrunchCliOptions = {}): Promise { - const host = await startWebHost({ - cwd: options.cwd, - coordinator: options.coordinator, - }); - process.stdout.write(`Brunch web listening on ${host.url}\n`); - await new Promise(() => {}); -} - function writeStdout(stdout: Writable | ((chunk: string) => void) | undefined, chunk: string): void { if (!stdout) { process.stdout.write(chunk); @@ -108,6 +98,25 @@ function stdoutStream(stdout: Writable | ((chunk: string) => void) | undefined): } as Writable; } +function parseCwd(argv: string[]): string | undefined { + const flagIndex = argv.indexOf('--cwd'); + if (flagIndex >= 0) { + const value = argv[flagIndex + 1]; + if (!value) throw new Error('--cwd requires a value'); + return resolveCliCwd(value); + } + + const cwdEquals = argv.find((arg) => arg.startsWith('--cwd=')); + if (!cwdEquals) return undefined; + const value = cwdEquals.slice('--cwd='.length); + if (!value) throw new Error('--cwd requires a value'); + return resolveCliCwd(value); +} + +function resolveCliCwd(value: string): string { + return isAbsolute(value) ? value : resolve(process.cwd(), value); +} + function parseMode(argv: string[]): string { const modeFlagIndex = argv.indexOf('--mode'); if (modeFlagIndex >= 0) { diff --git a/src/db/README.md b/src/db/README.md index c8ba4a98..4cc08793 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -6,8 +6,10 @@ SPEC decisions: D16-L, D41-L, D52-L, D54-L, D62-L - **Drizzle table definitions** (`schema.ts`) — the canonical column-level source of truth for persisted graph/workspace rows. It owns the SQLite table - names, column names, and shared enum `const` arrays (`INTENT_KINDS`, - `READINESS_GRADES`, `EDGE_CATEGORIES`, etc.). + names and column names. Domain enum taxonomy (`INTENT_KINDS`, + `READINESS_GRADES`, `EDGE_CATEGORIES`, etc.) is owned by + `graph/schema/kinds.ts`; `db/schema.ts` imports those literals only for + column constraints. - **Row schema derivation** (`row-schemas.ts`) — runtime insert/select schemas derived from Drizzle tables via `drizzle-typebox`. Do not hand-author parallel @@ -39,19 +41,20 @@ SPEC decisions: D16-L, D41-L, D52-L, D54-L, D62-L - `graph/` is the only application layer that imports `db/` directly. Non-`graph/` code imports graph-domain APIs instead. +- The single sanctioned `db/` → `graph/` import is `db/schema.ts` importing + the zero-import taxonomy leaf `graph/schema/kinds.ts` (D73-L). ## Enum flow ```pseudo -db/schema.ts +graph/schema/kinds.ts owns: - enum const arrays - Drizzle table definitions + domain enum const arrays + zero imports; no drizzle - ├─► db/row-schemas.ts - │ drizzle-typebox insert/select schemas - │ @sinclair/typebox 0.34 - │ persistence boundary only + ├─► db/schema.ts + │ Drizzle table definitions + │ text({ enum }) column constraints │ ├─► graph/schema/nodes.ts │ type IntentKind = typeof INTENT_KINDS[number] @@ -89,8 +92,8 @@ RPC shape: ``` Do not derive agent tools or RPC contracts directly from Drizzle row schemas. -Only enum literals should flow outward from `db/schema.ts`; object shapes are -owned by their boundary. +Enum literals flow from `graph/schema/kinds.ts`; object shapes are owned by +their boundary. ## Current schema posture diff --git a/src/db/schema.ts b/src/db/schema.ts index 6def8eae..0950cb19 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,11 +1,11 @@ /** * Drizzle table definitions — canonical column-level source of truth. * - * SPEC decisions: D16-L, D51-L, D54-L, D56-L + * SPEC decisions: D16-L, D51-L, D54-L, D56-L, D73-L * Canonical reference: docs/design/GRAPH_MODEL.md * - * Enum const arrays are exported for reuse by graph/ domain types - * and by Pi tool parameter schemas (via typebox v1.x). + * Domain enum taxonomy lives in graph/schema/kinds.ts; this persistence layer + * imports those literals for column constraints. */ import { sql } from 'drizzle-orm'; @@ -18,58 +18,16 @@ import { uniqueIndex, } from 'drizzle-orm/sqlite-core'; -// --------------------------------------------------------------------------- -// Shared enum arrays — the single source for text enum columns, -// graph/ domain types, and Pi tool parameter schemas. -// --------------------------------------------------------------------------- - -export const INTENT_KINDS = [ - 'goal', - 'thesis', - 'term', - 'context', - 'requirement', - 'assumption', - 'constraint', - 'invariant', - 'decision', - 'criterion', - 'example', -] as const; - -export const ORACLE_KINDS = ['check', 'validation_method', 'evidence', 'obligation'] as const; - -export const DESIGN_KINDS = ['module', 'interface'] as const; - -export const PLAN_KINDS = ['milestone', 'frontier', 'slice'] as const; - -export const NODE_BASES = ['explicit', 'implicit'] as const; - -export const EDGE_CATEGORIES = [ - 'dependency', - 'proof', - 'support', - 'realization', - 'boundary', - 'composition', - 'association', - 'supersession', -] as const; - -export const EDGE_STANCES = ['for', 'against'] as const; - -export const READINESS_GRADES = [ - 'grounding_onboarding', - 'elicitation_ready', - 'commitments_ready', - 'planning_ready', -] as const; - -export const READINESS_BANDS = ['grounding', 'elicitation', 'commitment'] as const; - -export const LENS_AFFINITIES = ['intent', 'design', 'oracle'] as const; - -export const ELICITATION_BACKLOG_STATUSES = ['open', 'closed'] as const; +import { + EDGE_CATEGORIES, + EDGE_STANCES, + ELICITATION_BACKLOG_STATUSES, + LENS_AFFINITIES, + NODE_BASES, + NODE_PLANES, + READINESS_BANDS, + READINESS_GRADES, +} from '../graph/schema/kinds.js'; // --------------------------------------------------------------------------- // Tables @@ -89,7 +47,7 @@ export const nodes = sqliteTable( spec_id: integer() .notNull() .references(() => specs.id), - plane: text({ enum: ['intent', 'oracle', 'design', 'plan'] }).notNull(), + plane: text({ enum: NODE_PLANES }).notNull(), kind: text().notNull(), // validated at domain layer against plane-specific enum kind_ordinal: integer().notNull(), title: text().notNull(), @@ -143,7 +101,7 @@ export const nodeKindCounters = sqliteTable( spec_id: integer() .notNull() .references(() => specs.id), - plane: text({ enum: ['intent', 'oracle', 'design', 'plan'] }).notNull(), + plane: text({ enum: NODE_PLANES }).notNull(), kind: text().notNull(), next_ordinal: integer().notNull().default(1), }, @@ -197,7 +155,7 @@ export const elicitationBacklog = sqliteTable('elicitation_backlog', { status: text({ enum: ELICITATION_BACKLOG_STATUSES }).notNull().default('open'), basis: text({ enum: NODE_BASES }).notNull().default('explicit'), readiness_band: text({ enum: READINESS_BANDS }).notNull(), - plane_affinity: text({ enum: ['intent', 'oracle', 'design', 'plan'] }), + plane_affinity: text({ enum: NODE_PLANES }), lens_affinity: text({ enum: LENS_AFFINITIES }), arose_from_entry_id: integer().references((): AnySQLiteColumn => elicitationBacklog.id), resolved_by_node_id: integer().references(() => nodes.id), diff --git a/src/dev/README.md b/src/dev/README.md index 8e8faf9a..5cfd685b 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. @@ -17,19 +17,19 @@ 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) `runBrunchIntrospectionTurn()` is the paired-run artifact writer for the dev-only introspection loop. The Pi side is the explicit, read-only `src/.pi/extensions/introspection/` registrar, included only when `createBrunchPiExtensions(..., { introspection: { enabled: true } })` is passed. Product Brunch sessions omit it by default and keep the D39-L offline default. The launcher does not mutate `process.env`; any future online real-provider lift belongs at session construction with save/restore scoping. -The passive extension tap records the final `before_provider_request` payload. The launcher then drives a subjective `session.prompt(...)` turn and writes the correlated run under `.fixtures/runs/introspection//`: +The passive extension tap records the final `before_provider_request` payload. The launcher then drives a subjective `session.prompt(...)` turn and writes the correlated scratch run under repo-root `.fixtures/scratch/introspection//`, independent of the workspace cwd it targets: - `mechanical.json` — latest passive provider-payload capture plus optional `/introspect` base-prompt report - `subjective.json` — assistant answer text from the subjective prompt diff --git a/src/dev/faux-harness.ts b/src/dev/faux-harness.ts index 129cd05c..79609bf7 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,23 +10,29 @@ import { SessionManager, SettingsManager, type AgentSession, - type ProviderConfig, + type ToolDefinition, } 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; readonly responses?: readonly FauxResponseStep[]; readonly model?: Partial; + readonly customTools?: readonly ToolDefinition[]; } export interface BrunchFauxHarness { @@ -37,49 +42,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 { @@ -110,7 +72,9 @@ export async function createBrunchFauxHarness( model: registeredModel, sessionManager: SessionManager.inMemory(options.cwd), settingsManager: SettingsManager.inMemory({ quietStartup: true }), - noTools: 'all', + ...(options.customTools?.length + ? { tools: options.customTools.map((tool) => tool.name), customTools: [...options.customTools] } + : { noTools: 'all' as const }), }); return { diff --git a/src/dev/introspection-launcher.test.ts b/src/dev/introspection-launcher.test.ts index c452206c..b9b46f0d 100644 --- a/src/dev/introspection-launcher.test.ts +++ b/src/dev/introspection-launcher.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,7 +6,11 @@ import { fauxAssistantMessage } from '@earendil-works/pi-ai'; import { describe, expect, it } from 'vitest'; import { createInMemoryBrunchIntrospectionStore } from '../.pi/brunch-pi-extensions.js'; -import { runBrunchIntrospectionTurn, type BrunchIntrospectionSession } from './introspection-launcher.js'; +import { + introspectionArtifactDir, + runBrunchIntrospectionTurn, + type BrunchIntrospectionSession, +} from './introspection-launcher.js'; describe('Brunch introspection launcher', () => { it('rejects unsafe artifact run ids before constructing paths', async () => { @@ -18,8 +22,8 @@ describe('Brunch introspection launcher', () => { }), ).rejects.toThrow('Artifact runId must be a portable single path segment'); }); - it('writes a paired run artifact keyed by the captured turn', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'brunch-introspection-launcher-')); + it('writes a paired scratch artifact keyed by the captured turn', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-introspection-workbench-')); const store = createInMemoryBrunchIntrospectionStore(); const session = createFakeSession('The tool list is clear, but the graph policy is ambiguous.', () => { store.recordPassiveCapture({ @@ -46,6 +50,8 @@ describe('Brunch introspection launcher', () => { now: () => new Date('2026-06-09T00:00:02.000Z'), }); + expect(result.artifactDir).toBe(introspectionArtifactDir('test-run')); + expect(result.artifactDir).not.toContain(`${cwd}/.fixtures/`); expect(result.artifact).toMatchObject({ runId: 'test-run', generatedAt: '2026-06-09T00:00:02.000Z', @@ -68,6 +74,7 @@ describe('Brunch introspection launcher', () => { await expect(readJson(join(result.artifactDir, 'subjective.json'))).resolves.toEqual({ answerText: 'The tool list is clear, but the graph policy is ambiguous.', }); + await rm(result.artifactDir, { recursive: true, force: true }); }); it('rejects a stale passive capture recorded before the prompted turn', async () => { @@ -138,6 +145,7 @@ describe('Brunch introspection launcher', () => { payload: { system: 'fresh prompt' }, }, }); + await rm(result.artifactDir, { recursive: true, force: true }); }); }); diff --git a/src/dev/introspection-launcher.ts b/src/dev/introspection-launcher.ts index 4c15ba61..02190243 100644 --- a/src/dev/introspection-launcher.ts +++ b/src/dev/introspection-launcher.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { AgentSession } from '@earendil-works/pi-coding-agent'; @@ -43,6 +44,11 @@ export interface BrunchIntrospectionLauncherResult { const DEFAULT_INTROSPECTION_PROMPT = 'Inspect the prompt, tools, and Brunch resources you can see. Name confusing or missing guidance.'; +const REPO_ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url))); + +export function introspectionArtifactDir(runId: string): string { + return join(REPO_ROOT, '.fixtures', 'scratch', 'introspection', assertPortableRunId(runId)); +} export async function runBrunchIntrospectionTurn( options: BrunchIntrospectionLauncherOptions, @@ -81,7 +87,7 @@ export async function runBrunchIntrospectionTurn( }, }; - const artifactDir = join(options.cwd ?? process.cwd(), '.fixtures', 'runs', 'introspection', runId); + const artifactDir = introspectionArtifactDir(runId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, 'mechanical.json'), `${JSON.stringify(artifact.mechanical, null, 2)}\n`); await writeFile(join(artifactDir, 'subjective.json'), `${JSON.stringify(artifact.subjective, null, 2)}\n`); 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') }, ]; } diff --git a/src/dev/workspace-rpc.ts b/src/dev/workspace-rpc.ts index b79d5012..7bcb9787 100644 --- a/src/dev/workspace-rpc.ts +++ b/src/dev/workspace-rpc.ts @@ -84,7 +84,7 @@ function usage(): string { 'Options:', ' -w, --workspace Brunch workspace directory (default: cwd)', ' --full-response Print the full JSON-RPC response instead of result only', - ' --no-dev-rpc Do not set BRUNCH_DEV_RPC=1', + ' --no-dev-rpc Do not set BRUNCH_DEV=1', ].join('\n'); } @@ -110,7 +110,7 @@ function runRpc(args: CliArgs): JsonRpcResponse { encoding: 'utf8', env: { ...process.env, - ...(args.devRpc ? { BRUNCH_DEV_RPC: '1' } : {}), + ...(args.devRpc ? { BRUNCH_DEV: '1' } : {}), }, }, ); diff --git a/src/graph/README.md b/src/graph/README.md index e8c2b56a..fd9465a9 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -39,7 +39,9 @@ SPEC decisions: D4-L, D20-L, D27-L, D51-L, D52-L, D53-L, D54-L, D60-L, D62-L, D6 - **Domain schema types** (`schema/`) — `GraphNode`, `GraphEdge`, `ReconciliationNeed`, `ElicitationBacklogEntry`, kind/category types, - per-kind node ordinals, and derived intent-kind grouping. + per-kind node ordinals, and derived intent-kind grouping. Raw domain enum + taxonomy lives in the zero-import `schema/kinds.ts` leaf so web-facing graph + imports do not pull in Drizzle. - **Policy** (`policy/category-policy.ts`) — the single per-category metadata table (`EDGE_CATEGORY_METADATA`): endpoint roles, impact @@ -85,8 +87,14 @@ not compare bare LSN values across sibling specs. ## Imports from -- `db/` — Drizzle table definitions, enum arrays, and connection handle. - `graph/` is the only application layer that should import `db/` directly. +- `db/` — Drizzle table definitions and connection handle. `graph/` is the + only application layer that should import `db/` directly. + +## Imported by `db/` + +- `schema/kinds.ts` — the single sanctioned `db/` → `graph/` edge (D73-L). + It is a zero-import taxonomy leaf containing only domain enum literals for + column constraints and graph-domain types. ## Imported by @@ -155,6 +163,8 @@ graph/ openWorkspaceCommandExecutor(cwd) schema/ + kinds.ts + zero-import domain enum taxonomy leaf elicitation-backlog.ts nodes.ts edges.ts @@ -171,8 +181,11 @@ graph/ ## Boundary flow ```pseudo -db/schema.ts - Drizzle rows + enum literals +graph/schema/kinds.ts + domain enum literals + │ + ├─► db/schema.ts + │ Drizzle rows + enum column constraints │ ▼ graph/schema/*.ts diff --git a/src/graph/architecture.test.ts b/src/graph/architecture.test.ts index a42ae632..61411700 100644 --- a/src/graph/architecture.test.ts +++ b/src/graph/architecture.test.ts @@ -8,10 +8,54 @@ */ import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; describe('I26-L architectural boundary', () => { + it('graph schema kinds is a zero-import taxonomy leaf', () => { + expect(existsSync('src/graph/schema/kinds.ts')).toBe(true); + const result = execSync(`rg "^import\\s" src/graph/schema/kinds.ts || true`, { + cwd: process.cwd(), + encoding: 'utf-8', + }); + + expect(result.trim()).toBe(''); + }); + + it('db imports from graph only through graph/schema/kinds.ts', () => { + const result = execSync( + `rg --files-with-matches "from ['\\"]\\.\\./graph/" src/db/ --glob '*.ts' || true`, + { cwd: process.cwd(), encoding: 'utf-8' }, + ); + + const forbiddenImports = result + .trim() + .split('\n') + .filter(Boolean) + .filter((file) => file !== 'src/db/schema.ts'); + + const schemaImports = execSync(`rg "from ['\\"]\\.\\./graph/" src/db/schema.ts || true`, { + cwd: process.cwd(), + encoding: 'utf-8', + }) + .trim() + .split('\n') + .filter(Boolean); + + expect(forbiddenImports).toEqual([]); + expect(schemaImports).toEqual([expect.stringContaining('../graph/schema/kinds.js')]); + }); + + it('db/schema.ts does not own domain enum const arrays', () => { + const result = execSync( + `rg "export const (INTENT_KINDS|ORACLE_KINDS|DESIGN_KINDS|PLAN_KINDS|NODE_PLANES|NODE_BASES|EDGE_CATEGORIES|EDGE_STANCES|READINESS_GRADES|READINESS_BANDS|LENS_AFFINITIES|ELICITATION_BACKLOG_STATUSES)" src/db/schema.ts || true`, + { cwd: process.cwd(), encoding: 'utf-8' }, + ); + + expect(result.trim()).toBe(''); + }); + it('no src/ module outside graph/ imports from db/', () => { // Find all .ts files importing from db/ (excluding graph/, db/ itself, // and test files within graph/) diff --git a/src/graph/command-executor.ts b/src/graph/command-executor.ts index 470c7e36..d5e6c311 100644 --- a/src/graph/command-executor.ts +++ b/src/graph/command-executor.ts @@ -35,9 +35,19 @@ import type { import { writeGraphMutation } from './command-executor/graph-mutation-writer.js'; import { translateReviewSetPayloadToMutateGraph } from './review-set.js'; import type { ElicitationBacklogLensAffinity } from './schema/elicitation-backlog.js'; +import { + DESIGN_KINDS, + INTENT_KINDS, + LENS_AFFINITIES, + NODE_BASES, + ORACLE_KINDS, + PLAN_KINDS, + READINESS_BANDS, + READINESS_GRADES, +} from './schema/kinds.js'; import { type NodeBasis, type NodePlane, type ReadinessBand } from './schema/nodes.js'; -export type ReadinessGrade = (typeof schema.READINESS_GRADES)[number]; +export type ReadinessGrade = (typeof READINESS_GRADES)[number]; export type { Diagnostic, EdgePatch, @@ -269,17 +279,17 @@ export interface ResolveReconNeedInput { // --------------------------------------------------------------------------- const VALID_KINDS_BY_PLANE: Record = { - intent: schema.INTENT_KINDS as unknown as string[], - oracle: schema.ORACLE_KINDS as unknown as string[], - design: schema.DESIGN_KINDS as unknown as string[], - plan: schema.PLAN_KINDS as unknown as string[], + intent: INTENT_KINDS as unknown as string[], + oracle: ORACLE_KINDS as unknown as string[], + design: DESIGN_KINDS as unknown as string[], + plan: PLAN_KINDS as unknown as string[], }; const KINDS_REQUIRING_DETAIL = new Set(['decision', 'term']); -const VALID_READINESS_GRADES = schema.READINESS_GRADES as unknown as string[]; -const VALID_NODE_BASES = schema.NODE_BASES as unknown as string[]; -const VALID_READINESS_BANDS = schema.READINESS_BANDS as unknown as string[]; -const VALID_LENS_AFFINITIES = schema.LENS_AFFINITIES as unknown as string[]; +const VALID_READINESS_GRADES = READINESS_GRADES as unknown as string[]; +const VALID_NODE_BASES = NODE_BASES as unknown as string[]; +const VALID_READINESS_BANDS = READINESS_BANDS as unknown as string[]; +const VALID_LENS_AFFINITIES = LENS_AFFINITIES as unknown as string[]; const SEEDED_ELICITATION_BACKLOG: readonly { readonly kind: string; diff --git a/src/graph/command-executor/create-graph-batch.ts b/src/graph/command-executor/create-graph-batch.ts index 7ee25e1e..5f727c8a 100644 --- a/src/graph/command-executor/create-graph-batch.ts +++ b/src/graph/command-executor/create-graph-batch.ts @@ -3,6 +3,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import type { BrunchDb } from '../../db/connection.js'; import * as schema from '../../db/schema.js'; import type { EdgeCategory, EdgeStance } from '../schema/edges.js'; +import { EDGE_CATEGORIES, EDGE_STANCES, NODE_BASES } from '../schema/kinds.js'; import { formatGraphNodeCode, type NodeKind } from '../schema/nodes.js'; import type { CreateGraphEdgeInput, @@ -12,10 +13,10 @@ import type { GraphMutationNodeRef, } from './graph-mutation-types.js'; -const VALID_CATEGORIES = schema.EDGE_CATEGORIES as unknown as string[]; +const VALID_CATEGORIES = EDGE_CATEGORIES as unknown as string[]; const STANCE_REQUIRED_CATEGORIES = new Set(['proof', 'support']); -const VALID_STANCES = schema.EDGE_STANCES as unknown as string[]; -const VALID_BASES = schema.NODE_BASES as unknown as string[]; +const VALID_STANCES = EDGE_STANCES as unknown as string[]; +const VALID_BASES = NODE_BASES as unknown as string[]; export interface PlannedBatchEndpoint { readonly kind: 'batch' | 'existing'; diff --git a/src/graph/command-executor/role-named-edge-draft.test.ts b/src/graph/command-executor/role-named-edge-draft.test.ts index 379fa260..aa562cfe 100644 --- a/src/graph/command-executor/role-named-edge-draft.test.ts +++ b/src/graph/command-executor/role-named-edge-draft.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { EDGE_CATEGORIES } from '../../db/schema.js'; import { EDGE_CATEGORY_METADATA } from '../policy/category-policy.js'; +import { EDGE_CATEGORIES } from '../schema/kinds.js'; import { normalizeRoleNamedEdgeDraft, type RoleNamedEdgeDraft } from './role-named-edge-draft.js'; const EDGE_DRAFT_FIXTURES = { diff --git a/src/graph/index.ts b/src/graph/index.ts index 48144d6a..4cce0fb2 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -17,9 +17,13 @@ export { ORACLE_KINDS, DESIGN_KINDS, PLAN_KINDS, + NODE_PLANES, NODE_BASES, READINESS_GRADES, -} from '../db/schema.js'; + READINESS_BANDS, + LENS_AFFINITIES, + ELICITATION_BACKLOG_STATUSES, +} from './schema/kinds.js'; export type { EdgeCategory, GraphEdge } from './schema/edges.js'; diff --git a/src/graph/policy/category-policy.test.ts b/src/graph/policy/category-policy.test.ts index 080c4481..06c11399 100644 --- a/src/graph/policy/category-policy.test.ts +++ b/src/graph/policy/category-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { EDGE_CATEGORIES } from '../../db/schema.js'; +import { EDGE_CATEGORIES } from '../schema/kinds.js'; import { EDGE_CATEGORY_METADATA, edgeEndpointRole } from './category-policy.js'; const EXPECTED_EDGE_CATEGORY_METADATA = { diff --git a/src/graph/review-set.ts b/src/graph/review-set.ts index 30202b7c..fb3df31d 100644 --- a/src/graph/review-set.ts +++ b/src/graph/review-set.ts @@ -14,6 +14,7 @@ import { type RoleNamedEdgeDraftOf, } from './command-executor/role-named-edge-draft.js'; import { EDGE_CATEGORY_METADATA } from './policy/category-policy.js'; +import { EDGE_CATEGORIES, EDGE_STANCES, NODE_PLANES } from './schema/kinds.js'; import type { NodePlane } from './schema/nodes.js'; import { parseGraphNodeCode } from './schema/nodes.js'; @@ -65,9 +66,9 @@ export type ReviewSetTranslationResult = ReviewSetTranslationSuccess | Structura const VALID_LENSES = ['intent', 'design', 'oracle'] as const; const VALID_EPISTEMIC_STATUSES = ['inferred', 'assumed', 'asserted', 'observed'] as const; -const VALID_PLANES = ['intent', 'oracle', 'design', 'plan'] as const; -const VALID_CATEGORIES = schema.EDGE_CATEGORIES as unknown as readonly string[]; -const VALID_STANCES = schema.EDGE_STANCES as unknown as readonly string[]; +const VALID_PLANES = NODE_PLANES; +const VALID_CATEGORIES = EDGE_CATEGORIES as unknown as readonly string[]; +const VALID_STANCES = EDGE_STANCES as unknown as readonly string[]; export function translateReviewSetPayloadToMutateGraph(options: { readonly db: Pick; diff --git a/src/graph/schema/edges.ts b/src/graph/schema/edges.ts index 1a2b5699..91ef23fb 100644 --- a/src/graph/schema/edges.ts +++ b/src/graph/schema/edges.ts @@ -10,13 +10,13 @@ * agent-facing link* command surface land with subsequent M4/M5 slices. */ -import { EDGE_CATEGORIES, EDGE_STANCES, NODE_BASES } from '../../db/schema.js'; import type { EdgeId, Lsn, NodeId } from '../atoms.js'; +import { EDGE_CATEGORIES, EDGE_STANCES, NODE_BASES } from './kinds.js'; /** * Closed set of structural edge categories. * - * Derived from `db/schema.ts` — the single enum source. + * Derived from `graph/schema/kinds.ts` — the single enum source. * * - `dependency` dependency → dependent hard upstream; cascade * - `proof` oracle → claim witness or refutation (stance required) diff --git a/src/graph/schema/elicitation-backlog.ts b/src/graph/schema/elicitation-backlog.ts index 4f4783d8..47551cd8 100644 --- a/src/graph/schema/elicitation-backlog.ts +++ b/src/graph/schema/elicitation-backlog.ts @@ -8,13 +8,13 @@ * later by capture-reflection. It is a flat table, not a graph node/plane. */ -import * as schema from '../../db/schema.js'; import type { Lsn, NodeId } from '../atoms.js'; +import { ELICITATION_BACKLOG_STATUSES, LENS_AFFINITIES } from './kinds.js'; import type { NodeBasis, NodePlane, ReadinessBand } from './nodes.js'; -type ElicitationBacklogStatus = (typeof schema.ELICITATION_BACKLOG_STATUSES)[number]; +type ElicitationBacklogStatus = (typeof ELICITATION_BACKLOG_STATUSES)[number]; -export type ElicitationBacklogLensAffinity = (typeof schema.LENS_AFFINITIES)[number]; +export type ElicitationBacklogLensAffinity = (typeof LENS_AFFINITIES)[number]; export interface ElicitationBacklogEntry { readonly id: string; diff --git a/src/graph/schema/kinds.ts b/src/graph/schema/kinds.ts new file mode 100644 index 00000000..62303f75 --- /dev/null +++ b/src/graph/schema/kinds.ts @@ -0,0 +1,49 @@ +export const INTENT_KINDS = [ + 'goal', + 'thesis', + 'term', + 'context', + 'requirement', + 'assumption', + 'constraint', + 'invariant', + 'decision', + 'criterion', + 'example', +] as const; + +export const ORACLE_KINDS = ['check', 'validation_method', 'evidence', 'obligation'] as const; + +export const DESIGN_KINDS = ['module', 'interface'] as const; + +export const PLAN_KINDS = ['milestone', 'frontier', 'slice'] as const; + +export const NODE_PLANES = ['intent', 'oracle', 'design', 'plan'] as const; + +export const NODE_BASES = ['explicit', 'implicit'] as const; + +export const EDGE_CATEGORIES = [ + 'dependency', + 'proof', + 'support', + 'realization', + 'boundary', + 'composition', + 'association', + 'supersession', +] as const; + +export const EDGE_STANCES = ['for', 'against'] as const; + +export const READINESS_GRADES = [ + 'grounding_onboarding', + 'elicitation_ready', + 'commitments_ready', + 'planning_ready', +] as const; + +export const READINESS_BANDS = ['grounding', 'elicitation', 'commitment'] as const; + +export const LENS_AFFINITIES = ['intent', 'design', 'oracle'] as const; + +export const ELICITATION_BACKLOG_STATUSES = ['open', 'closed'] as const; diff --git a/src/graph/schema/nodes.ts b/src/graph/schema/nodes.ts index 494e4717..56b895a4 100644 --- a/src/graph/schema/nodes.ts +++ b/src/graph/schema/nodes.ts @@ -8,8 +8,8 @@ * agent-facing node command surface land with subsequent slices. */ -import { DESIGN_KINDS, INTENT_KINDS, NODE_BASES, ORACLE_KINDS, PLAN_KINDS } from '../../db/schema.js'; import type { Lsn, NodeId } from '../atoms.js'; +import { DESIGN_KINDS, INTENT_KINDS, NODE_BASES, NODE_PLANES, ORACLE_KINDS, PLAN_KINDS } from './kinds.js'; // --------------------------------------------------------------------------- // Planes & basis @@ -24,18 +24,18 @@ import type { Lsn, NodeId } from '../atoms.js'; * - `design` how it's shaped * - `plan` how it's sequenced */ -export type NodePlane = 'intent' | 'oracle' | 'design' | 'plan'; +export type NodePlane = (typeof NODE_PLANES)[number]; /** * Whether this exact graph item was approved (`explicit`) or materialized from * an approved concept without per-item review (`implicit`). * - * Derived from `db/schema.ts` — same semantics as EdgeBasis. + * Derived from `graph/schema/kinds.ts` — same semantics as EdgeBasis. */ export type NodeBasis = (typeof NODE_BASES)[number]; // --------------------------------------------------------------------------- -// Kind taxonomy — derived from db/schema.ts const arrays +// Kind taxonomy — derived from graph/schema/kinds.ts const arrays // --------------------------------------------------------------------------- /** diff --git a/src/graph/seed-fixtures.test.ts b/src/graph/seed-fixtures.test.ts index 0988cfd0..149fffa1 100644 --- a/src/graph/seed-fixtures.test.ts +++ b/src/graph/seed-fixtures.test.ts @@ -12,8 +12,9 @@ import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; -import { EDGE_CATEGORIES, changeLog, edges, graphClock, nodes, specs } from '../db/schema.js'; +import { changeLog, edges, graphClock, nodes, specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; +import { EDGE_CATEGORIES } from './schema/kinds.js'; import { NODE_KIND_METADATA, type ReadinessBand } from './schema/nodes.js'; import { seedFixture, type SeedFixture } from './seed-fixtures.js'; 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[] { diff --git a/src/rpc/README.md b/src/rpc/README.md index a0ec6e79..e9568961 100644 --- a/src/rpc/README.md +++ b/src/rpc/README.md @@ -89,7 +89,7 @@ dev-enabled full RPC host only: writes: dev.graph.mutateGraph absent unless: - createRpcHandlers({devRpc: true}) or BRUNCH_DEV_RPC=1 in CLI rpc mode + createRpcHandlers({devRpc: true}) or BRUNCH_DEV=1 in CLI rpc mode still absent from: default full RPC discovery TUI-started web sidecar @@ -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/rpc/web-host.ts b/src/rpc/web-host.ts index 3715ae93..e147e657 100644 --- a/src/rpc/web-host.ts +++ b/src/rpc/web-host.ts @@ -23,7 +23,7 @@ export interface RunningWebHost { } const MISSING_WEB_BUNDLE_MESSAGE = - 'Brunch web bundle is missing. Run npm run build:web before starting web mode.'; + 'Brunch web bundle is missing. Run npm run build:web before starting the web sidecar.'; export async function startWebHost(options: WebHostOptions): Promise { void options.cwd; diff --git a/src/web/README.md b/src/web/README.md index 7368e574..e8100ad6 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -2,7 +2,7 @@ Canonical references: `docs/architecture/prd.md` §Browser / web client, `src/rpc/README.md` -This directory owns the browser client for `brunch --mode web`. The browser is a thin remote head over the Brunch host: one React app, one WebSocket-backed Brunch JSON-RPC client, TanStack Router for route/data preloading, and TanStack Query for cache ownership and update scheduling. +This directory owns the browser client served as the **TUI web sidecar**: when you launch the TUI (`brunch`, i.e. `--mode tui`), `runBrunchTui` starts a local web host and opens the browser to it. The browser is a thin remote head over the Brunch host: one React app, one WebSocket-backed Brunch JSON-RPC client, TanStack Router for route/data preloading, and TanStack Query for cache ownership and update scheduling. A standalone web-only mode (`--mode web`) is deferred — the web UI is not useful without the TUI driving the session — so it currently errors with a "not available yet" message. The web client must not read SQLite, Pi RPC, local JSONL, or `.brunch/workspace.json` directly. It speaks Brunch public RPC method names and renders product projections. Its current graph observer subset is `graph.overview` + `graph.nodeNeighborhood`; `src/graph/README.md` owns the observed-shape ledger and keeps additional graph-owned shapes deliberate rather than accidental bleed-through from agent/RPC needs. @@ -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 @@ -47,14 +47,31 @@ web/ brunch-updates.ts brunch.updated -> exact Query invalidation where possible + app-meta.ts + static product chrome (name/version/tagline) + home-path abbreviation + APP_VERSION injected from package.json via vite `__BRUNCH_VERSION__` define + + components/ + app-header.tsx global header (product identity + workspace path) + icons.tsx inline SVG glyphs (chevron / eye / eye-off), no icon dep + node-card.tsx plane-accented node presentation primitives + drawer-card.tsx reusable card-with-collapsible-drawer + routes/ root.tsx - root subscription + `/` workspace/session proof route + root subscription + global-header layout (Outlet) + `/` index route: workspace spec list spec.tsx `/spec/$specId` loader primes workspace.state + graph.overview + renders the knowledge-graph structured list - features/graph/GraphOverview.tsx - read-only selected-spec graph projection + features/graph/ + structured-list-view.tsx + read-only KnowledgeGraphView: counts sub-header + kind filter chips + + collapsible per-kind sections of node cards (ported from the prior + trunk's -structured-list-view, minus chat/annotate/inline-edit) + kind-display.ts + presentation-only kind section ordering + plural section labels *.test.tsx / *.test.ts component, route/cache, and transport oracles for current web proof @@ -285,15 +302,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 +358,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-meta.ts b/src/web/app-meta.ts new file mode 100644 index 00000000..73b2e9ff --- /dev/null +++ b/src/web/app-meta.ts @@ -0,0 +1,16 @@ +// ── App identity ────────────────────────────────────────────────────── +// +// Static product chrome for the global header. `APP_VERSION` is injected at +// build time from package.json via the Vite `define` for `__BRUNCH_VERSION__` +// (see vite.config.ts); the guard keeps it safe if the define is ever absent. + +declare const __BRUNCH_VERSION__: string | undefined; + +export const APP_NAME = 'brunch'; +export const APP_TAGLINE = 'AI-guided spec elicitation'; +export const APP_VERSION = typeof __BRUNCH_VERSION__ === 'string' ? __BRUNCH_VERSION__ : '0.0.0'; + +/** Collapse a leading `/Users/` or `/home/` to `~` for display. */ +export function abbreviateHomePath(path: string): string { + return path.replace(/^\/(?:Users|home)\/[^/]+/u, '~'); +} diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index ac2e038e..9595848a 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -167,16 +167,15 @@ afterEach(() => { }); describe('Brunch React web app', () => { - it('renders workspace chrome from workspace.state via the RPC client', async () => { + it('renders the global header and index spec list from workspace state', async () => { const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() }); render(); expect(await screen.findByText('/tmp/brunch-project')).toBeTruthy(); - expect(screen.getByText('Web spec')).toBeTruthy(); - expect(screen.getByText('session-1')).toBeTruthy(); - expect(screen.getByText('elicitation')).toBeTruthy(); - expect(screen.getByText('responding-to-elicitation')).toBeTruthy(); + expect(screen.getByText('brunch')).toBeTruthy(); + expect(screen.getByText('AI-guided spec elicitation')).toBeTruthy(); + expect(screen.getByText('No specs in this workspace.')).toBeTruthy(); }); it('lists workspace specs as links to their spec routes', async () => { @@ -190,14 +189,13 @@ describe('Brunch React web app', () => { expect(secondSpecLink.getAttribute('href')).toBe('/spec/2'); }); - it('renders selected session identity without requesting session projections', async () => { + it('renders the index without requesting session projections', async () => { const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) }); render(); - expect(await screen.findByText('Attached session: session-1')).toBeTruthy(); - expect(screen.getByText('Spec 1')).toBeTruthy(); + expect(await screen.findByText('No specs in this workspace.')).toBeTruthy(); expect(calls).toContainEqual({ method: 'workspace.state' }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); }); @@ -211,14 +209,14 @@ describe('Brunch React web app', () => { render(); - expect(await screen.findByText('Graph overview')).toBeTruthy(); + expect(await screen.findByText('Knowledge Graph')).toBeTruthy(); expect(screen.getByText('Spec A assumption')).toBeTruthy(); - expect(screen.getAllByText('intent / assumption').length).toBeGreaterThan(0); - expect(screen.getAllByText('intent / requirement').length).toBeGreaterThan(0); - expect(screen.getByText('support: 1')).toBeTruthy(); + expect(screen.getByText('Spec A requirement')).toBeTruthy(); + expect(screen.getAllByText('Assumptions').length).toBeGreaterThan(0); + expect(screen.getAllByText('Requirements').length).toBeGreaterThan(0); }); - it('derives graph overview presentation from GraphSlice arrays without count aliases', async () => { + it('derives graph overview counts from GraphSlice arrays without count aliases', async () => { window.history.pushState(null, '', '/spec/1'); const graphOverview = { nodes: populatedGraphOverview.nodes, @@ -231,10 +229,9 @@ describe('Brunch React web app', () => { render(); - expect(await screen.findByText('Graph overview')).toBeTruthy(); - expect(screen.getByText('2')).toBeTruthy(); - expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('support: 1')).toBeTruthy(); + const summary = await screen.findByLabelText('Knowledge graph summary'); + expect(summary.textContent).toContain('2 Items'); + expect(summary.textContent).toContain('1 Connection'); }); it('keeps graph query options typed to graph-owned RPC shapes', async () => { @@ -261,6 +258,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); @@ -372,8 +418,7 @@ describe('Brunch React web app', () => { render(); - expect(await screen.findByText('No session is attached for viewed Spec 2.')).toBeTruthy(); - expect(screen.getByText('The TUI is active in Spec 1/session-1.')).toBeTruthy(); + expect(await screen.findByText('No knowledge captured yet')).toBeTruthy(); expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 2 } }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); expect(calls).not.toContainEqual(expect.objectContaining({ method: 'workspace.activate' })); @@ -393,7 +438,7 @@ describe('Brunch React web app', () => { render(); expect(await screen.findByText('Spec without session')).toBeTruthy(); - expect(screen.getByText('No graph nodes yet. LSN 0; 0 nodes; 0 edges.')).toBeTruthy(); + expect(screen.getByText('No knowledge captured yet')).toBeTruthy(); expect(calls).toContainEqual({ method: 'workspace.state' }); expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 2 } }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); @@ -407,7 +452,7 @@ describe('Brunch React web app', () => { render(); - expect(await screen.findByText('No Brunch session selected.')).toBeTruthy(); + expect(await screen.findByText('No specs in this workspace.')).toBeTruthy(); expect(calls).toContainEqual({ method: 'workspace.state' }); expect(calls).toContainEqual({ method: 'workspace.selectionState' }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); @@ -418,7 +463,7 @@ describe('Brunch React web app', () => { const initialRouter = runtime.router; const initialQueryClient = runtime.queryClient; const { rerender } = render(); - await screen.findAllByText('Web spec'); + await screen.findByText('No specs in this workspace.'); rerender(); diff --git a/src/web/assets/brunch.png b/src/web/assets/brunch.png new file mode 100644 index 00000000..c24918a0 Binary files /dev/null and b/src/web/assets/brunch.png differ diff --git a/src/web/components/app-header.tsx b/src/web/components/app-header.tsx new file mode 100644 index 00000000..0894b4d2 --- /dev/null +++ b/src/web/components/app-header.tsx @@ -0,0 +1,25 @@ +import { abbreviateHomePath, APP_NAME, APP_TAGLINE, APP_VERSION } from '../app-meta.js'; +import brunchLogo from '../assets/brunch.png'; + +// ── Global header ───────────────────────────────────────────────────── +// +// Persistent app chrome shown above every route: product identity on the left, +// workspace path on the right. The mark is the canonical brunch logo (a +// sunny-side-up egg), shared with the prior trunk's route-root header. + +export function AppHeader({ cwd }: { cwd: string }) { + return ( +
+ +
+ {APP_NAME} + v{APP_VERSION} +
+
+ ); +} 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/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/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 ( -