diff --git a/.agents/skills/cli-agent-browser/SKILL.md b/.agents/skills/cli-agent-browser/SKILL.md new file mode 100644 index 00000000..1ef509f5 --- /dev/null +++ b/.agents/skills/cli-agent-browser/SKILL.md @@ -0,0 +1,81 @@ +--- +name: cli-agent-browser +description: 'Browser automation via the agent-browser CLI — a daemon-backed Chrome controller with persistent state across shell calls. Primary choice for browser tasks inside the agent-safehouse sandbox. Use when interacting with web pages — navigating, snapshotting, clicking, filling forms, taking screenshots. Triggers on: browse a page, automate browser, take a screenshot, fill a form, click a button, scrape a page, test a web app.' +--- + +# agent-browser + +The most reliable browser CLI for agents running inside the **`agent-safehouse`** +sandbox. A persistent daemon (sockets, pid, state in `~/.agent-browser/`) +spawns Chrome with the right flags and survives across one-shot Bash calls — +the daemon model that `chrome-devtools-axi` and `cdp-cli launch` cannot achieve +under sandboxing. + +## Prerequisites + +This skill's pinned launch invocation depends on two Safehouse features being +enabled in `~/.config/zsh/agents.zsh` `safe`: `agent-browser` (allows Chrome +to dlopen its framework and reach Mach ports) and `process-control` (allows +daemon liveness checks). If they're missing, `agent-browser open` fails with +`Auto-launch failed: CDP response channel closed`. + +## First Launch: Pin the Args + +Chrome inside `agent-safehouse` **must** be launched with `--no-sandbox` +(Safehouse's outer Seatbelt blocks Chrome's inner sandbox from re-initializing) +and `--ignore-certificate-errors` (the Cloudflare Zero Trust CA is plumbed to +Node but not Chrome). Pass both via `--args` on the first call after a fresh +shell or after `agent-browser close`: + +```bash +agent-browser --args "--no-sandbox,--ignore-certificate-errors" open https://example.com +``` + +**Args stick to the running daemon.** Subsequent calls do not need `--args` +and will warn "daemon already running" if you pass them anyway. To change +launch args, run `agent-browser close` first, then re-open with new args. + +## Core Workflow + +After `open`, every command targets the live page: + +```bash +agent-browser snapshot # AX tree with @ref handles +agent-browser click @e2 # click ref from snapshot +agent-browser fill @e5 "user@example.com" +agent-browser type "search query" +agent-browser press Enter +agent-browser screenshot /tmp/out.png +agent-browser open # navigate same daemon +agent-browser close # tear down +``` + +Refs (`@e1`, `@e2`, …) come from the most recent `snapshot` and are stable +within the page; re-snapshot after navigation or DOM mutations. + +## Upstream Skills (Authoritative Reference) + +The CLI ships its own version-matched documentation. Load the upstream skill +for the full command reference and patterns: + +```bash +agent-browser skills get core --full # full command reference + templates +agent-browser skills list # specialized skills (Electron, Slack, …) +``` + +Prefer the upstream skill over guessing from `agent-browser --help`. This +file's job is just to pin the sandbox-correct launch invocation and explain +the daemon-args lifecycle. + +## When Not to Use This Skill + +- **Need to drive an existing user Chrome session** (cookies, logged-in + state, extensions) — agent-browser uses its own clean profile. Use + [cli-cdp](../cli-cdp/SKILL.md) in attach mode against a Chrome the user + launched manually. +- **One-shot screenshot or PDF with no follow-up interaction, in a context + without MCP browser tools** — [cli-playwright](../cli-playwright/SKILL.md)'s + stateless `screenshot`/`pdf` commands are lighter than spinning up the + daemon. +- **MCP browser tools are available** (e.g. Amp's `mcp__chrome_devtools__*`) + — those run outside the sandbox and have richer DevTools coverage. diff --git a/.agents/skills/cli-cdp/SKILL.md b/.agents/skills/cli-cdp/SKILL.md index 8560acc9..ac27e89a 100644 --- a/.agents/skills/cli-cdp/SKILL.md +++ b/.agents/skills/cli-cdp/SKILL.md @@ -11,14 +11,55 @@ debugging. ## Prerequisites -Chrome must be running with `--remote-debugging-port`: +Chrome must be running with `--remote-debugging-port`. **First check whether +you can launch it yourself or must ask the user to launch it externally.** + +### Step 1: Detect the sandbox + +```bash +echo "${APP_SANDBOX_CONTAINER_ID:-none}" +``` + +- **`none` (unsandboxed)** — you can run `cdp-cli launch` yourself; it spawns + Chrome on port 9223 with a clean profile under `$TMPDIR`. +- **`agent-safehouse` (or any other sandbox)** — `cdp-cli launch` reports + `{"success":true}` but the Chrome it spawns crashes silently with SIGABRT + because its launch args lack `--no-sandbox` and there is no flag to add + one. **You must use attach mode** (Step 2). + +### Step 2a: Unsandboxed — self-launch + +```bash +cdp-cli launch +cdp-cli tabs # confirm a page is listed +``` + +### Step 2b: Sandboxed — ask the user to launch Chrome externally + +Stop and ask the user to run this in a **non-sandboxed Terminal window** +(Terminal.app or iTerm, not cmux): + +```bash +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --remote-debugging-port=9223 \ + --user-data-dir="$HOME/.chrome-debug-profile" \ + about:blank +``` + +The separate `--user-data-dir` keeps it isolated from the user's main Chrome +profile so both can run at the same time. Tell the user to leave that +terminal window open — closing it kills Chrome. + +After they confirm Chrome is open, verify connectivity from your shell: ```bash -cdp-cli launch # macOS: launches Chrome with debugging on :9223 +curl -s http://localhost:9223/json/version | head -3 # should return JSON +cdp-cli tabs # should list the page ``` -Or start Chrome manually with `--remote-debugging-port=9222` and pass -`--cdp-url http://localhost:9222`. +From here every other `cdp-cli` command works normally — TCP to localhost +is allowed through the sandbox; only Chrome's own dlopen and process spawn +are blocked. ## Page Identification diff --git a/.agents/skills/cli-chrome-axi/SKILL.md b/.agents/skills/cli-chrome-axi/SKILL.md index 6a32082f..86917c27 100644 --- a/.agents/skills/cli-chrome-axi/SKILL.md +++ b/.agents/skills/cli-chrome-axi/SKILL.md @@ -5,6 +5,17 @@ description: 'Uses the chrome-devtools-axi CLI for browser automation, accessibi # chrome-devtools-axi +## Sandbox Compatibility — Check First + +Run `echo "${APP_SANDBOX_CONTAINER_ID:-none}"` before using this skill. + +- **`agent-safehouse`**: **DO NOT use this skill.** The CLI's persistent + bridge daemon cannot detach from the Bash subprocess under Seatbelt and + times out at startup (`Bridge failed to start within 30s`). Use + [cli-agent-browser](../cli-agent-browser/SKILL.md) for daemon-style + browser work or [cli-playwright](../cli-playwright/SKILL.md) for one-shots. +- **`none` (unsandboxed)**: this skill works as documented below. + Use `chrome-devtools-axi` when you want Chrome DevTools automation from the shell with agent-friendly output and stable accessibility refs. ## Why This CLI diff --git a/.agents/skills/cli-cmux/SKILL.md b/.agents/skills/cli-cmux/SKILL.md index 3d1e5005..7e2d1b0a 100644 --- a/.agents/skills/cli-cmux/SKILL.md +++ b/.agents/skills/cli-cmux/SKILL.md @@ -5,6 +5,21 @@ description: 'Deep expertise in cmux — the terminal multiplexer with native br # cmux — Terminal Multiplexer with Native Browser +## Sandbox Compatibility — Check First + +Run `echo "${APP_SANDBOX_CONTAINER_ID:-none}"` and `echo "${CMUX_SURFACE_ID:-none}"` +before using this skill. + +- **`agent-safehouse` and/or `CMUX_SURFACE_ID=none`**: **DO NOT use this skill.** + The `cmux` CLI is not reachable from inside the `agent-safehouse` sandbox + (its install path is denied), and the `CMUX_WORKSPACE_ID` / `CMUX_SURFACE_ID` + env vars assumed below are not injected. For browser tasks, use + [cli-agent-browser](../cli-agent-browser/SKILL.md). For terminal/pane + interactions you actually need from inside the sandbox, ask the user to + run the cmux commands directly. +- **Unsandboxed cmux pane (both env vars present)**: this skill works as + documented below. + cmux manages terminal panes and browser views through a Unix socket CLI. You are already running inside cmux — your current pane has env vars `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` set automatically. diff --git a/.agents/skills/cli-playwright/SKILL.md b/.agents/skills/cli-playwright/SKILL.md new file mode 100644 index 00000000..aaab0bfb --- /dev/null +++ b/.agents/skills/cli-playwright/SKILL.md @@ -0,0 +1,78 @@ +--- +name: cli-playwright +description: 'One-shot browser captures via the Playwright CLI — screenshots and PDFs of arbitrary URLs with no daemon. Best stateless option for agents that need to capture a page but lack MCP browser tools. Use when you need a single screenshot or PDF of a URL without follow-up interaction. Triggers on: screenshot a page, save page as pdf, capture web page, snapshot a url.' +--- + +# Playwright CLI (one-shot) + +For agents without MCP browser tools that need a **single page capture** with +no interaction loop. Each invocation launches Chromium, performs one action, +and exits — no daemon to manage. Inside `agent-safehouse`, the `playwright-chrome` +Safehouse feature has already injected `PLAYWRIGHT_MCP_SANDBOX=false` into +the environment, so Chromium starts cleanly without you setting anything. + +For multi-step interaction loops use [cli-agent-browser](../cli-agent-browser/SKILL.md) +instead — its daemon persists state across calls; Playwright one-shots do not. + +## Two Binaries on PATH + +This skill is about the **test-runner `playwright`** (from `@playwright/test`), +which exposes one-shot commands like `screenshot` and `pdf`. The other +binary, `playwright-cli` (from a separate package), is a daemon wrapper +around Playwright-MCP — its `--ignore-https-errors` plumbing through config +files is finicky in this environment, so prefer `agent-browser` for +daemon-style work. + +## Prerequisites + +- Safehouse `playwright-chrome` feature must be enabled (auto-injects + `PLAYWRIGHT_MCP_SANDBOX=false`). Confirm with `echo $PLAYWRIGHT_MCP_SANDBOX` + — should print `false`. +- Chromium browser must be installed in `~/Library/Caches/ms-playwright/`. + If `playwright screenshot` errors with "Executable doesn't exist", run: + ```bash + playwright install chromium + ``` + This downloads to a cached location Safehouse already permits, no `sudo`. + +## Core Commands + +```bash +# Screenshot — always pass --ignore-https-errors for Cloudflare-gated sites +playwright screenshot --ignore-https-errors https://example.com /tmp/out.png + +# Full-page screenshot +playwright screenshot --ignore-https-errors --full-page + +# PDF (uses Chromium printing pipeline) +playwright pdf --ignore-https-errors /tmp/out.pdf + +# Wait for content before capturing +playwright screenshot --ignore-https-errors \ + --wait-for-selector ".loaded" \ + --wait-for-timeout 5000 \ + + +# Emulate device / color scheme +playwright screenshot --ignore-https-errors \ + --device "iPhone 11" --color-scheme dark \ + +``` + +## Always Pass `--ignore-https-errors` + +The `safe` function forwards the Cloudflare Zero Trust root CA to Node +(`NODE_EXTRA_CA_CERTS`), but **Chromium does not honor that env var** — it +uses its own cert store, which doesn't include the Cloudflare gateway CA. +Without `--ignore-https-errors`, any HTTPS URL routed through the gateway +fails with `net::ERR_CERT_AUTHORITY_INVALID`. + +## Common Pitfalls + +- **`playwright open ` is interactive** and will hang the Bash tool — + use `screenshot` or `pdf` for one-shot capture, or `cli-agent-browser` + for interactive flows. +- **`playwright codegen`** records user actions — useless from an + agent shell. +- **`playwright test`** runs a `playwright.config.ts` test suite — not a + general-purpose browser CLI. diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index 93683e9a..dda168c8 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -1,7 +1,7 @@ --- name: ln-build description: "Implement one scoped slice using TDD red-green-refactor. Use when ready to write code for a defined slice of work, or when the user wants test-driven development." -argument-hint: "[paste or reference an ln-scope card]" +argument-hint: "[scope file path under memory/cards/, an inline scope card, or a trivial direct-fix request]" --- # Ln Build @@ -10,13 +10,25 @@ Implement **one** scope card. Beck's red-green-refactor, one cycle, no scope cre ## Input -A full or light scope card from `ln-scope`, the next ready card in `memory/CARDS.md`, or a trivial direct-fix request: $ARGUMENTS +A scope file under `memory/cards/`, an inline scope card from `ln-scope`, or a trivial direct-fix request: $ARGUMENTS -Extract: target behavior / objective, acceptance criteria, and verification approach. +Extract: target behavior / objective, acceptance criteria, verification approach, and (when present) expected touched paths. -Treat the scope card as the next implementation slice inside its containing `memory/PLAN.md` frontier item. The frontier item is the plan-level work item and Linear/branch unit; the scope-card slice is just the current execution step inside it. Unless `ln-plan` has already split the frontier into separate items, do **not** infer a new Linear issue or Graphite branch from scope-card granularity; multiple consecutive slices may land on the same branch. +Treat the scope card as the next implementation slice inside its containing `memory/PLAN.md` frontier item (or, for dev/tooling/docs work, the named category prefix). The frontier item is the plan-level work item and Linear/branch unit; the scope-card slice is just the current execution step inside it. Unless `ln-plan` has already split the frontier into separate items, do **not** infer a new Linear issue or Graphite branch from scope-card granularity; multiple consecutive slices may land on the same branch — including slices that live in separate scope files but share a frontier. -If `memory/CARDS.md` exists, treat it as a derivative execution queue, not canonical planning state. Start with the next card marked `next` or the first unfinished card in that file. If that card is already satisfied on the current branch, do **not** manufacture a no-op build commit; verify the acceptance criteria, mark the card `done` or `dropped` as appropriate, reconcile the queue, and either continue to the next honest build target or route back to `ln-scope` if no build remains. +### Selecting a scope file + +`ln-build` uses a **hybrid selection policy** for choosing which scope file in `memory/cards/` to consume: + +1. **Explicit path argument wins.** If $ARGUMENTS names a scope file path (e.g. `memory/cards/live-graph-observer--observer-loop.md`), consume that file. +2. **Single active file → pick.** If $ARGUMENTS does not name a file but exactly one file under `memory/cards/` exists with `Status: active` for the current frontier (or current dev/tooling concern), consume that file and announce the choice. +3. **Otherwise → ask.** Use `tool-ask-question` to list every active scope file with a one-line summary of its next-ready card, and let the user pick. + +Never scan or pick by mtime, alphabetical order, or directory-listing heuristics. Selection is either explicit (1, 2) or user-confirmed (3). + +### Inside a scope file + +Once a file is selected, work the next card marked `next` (or the first unfinished card in file order if status markers are absent). If that card is already satisfied on the current branch, do **not** manufacture a no-op build commit; verify the acceptance criteria, mark the card `done` or `dropped` as appropriate, reconcile, and either continue to the next ready card in the same file or route back to `ln-scope` if no build remains. Re-enter before red. @@ -27,26 +39,27 @@ If this is a fresh thread or an unfamiliar area, reload: 3. `HANDOFF.md` if present 4. `docs/archive/PLAN_HISTORY.md` only if the frontier or touched area is still unclear -Write a 2-4 bullet orientation note naming the containing seam, the frontier item, any manual verification debt, and the main open risk. +Write a 2-4 bullet orientation note naming the containing seam, the frontier item (or dev/tooling concern), any manual verification debt, and the main open risk. Also name any frontier-level cross-cutting obligations the slice inherits (for example shared mutation-authority rules, side-task/event-substrate semantics, or verification-layer commitments). If the request is a direct fix and you cannot name the containing seam or whether it is settled, stop and route through `ln-scope` first. -Do not invent new planning docs, scratch histories, or alternate memory locations while building. Durable state reconciles back into `memory/SPEC.md` and `memory/PLAN.md`; temporary support artifacts stay in `HANDOFF.md`, `memory/CARDS.md`, or `memory/REFACTOR.md` only while they are still live. +Do not invent new planning docs, scratch histories, or alternate memory locations while building. Durable state reconciles back into `memory/SPEC.md` and `memory/PLAN.md`; temporary support artifacts stay in `HANDOFF.md`, the active scope file under `memory/cards/`, or `memory/REFACTOR.md` only while they are still live. ## Serial execution mode -When several prepared slice cards already exist for one settled frontier item, `ln-build` may execute them in sequence instead of routing back through the user after every commit. +When a scope file is `Mode: chain` and holds several prepared cards, `ln-build` may execute them in sequence within that one file instead of routing back through the user after every commit. Loop shape: -1. take the next ready card -2. decide whether it is still a real build target or is already satisfied / stale on the current branch -3. if it is real work, run red → green → refactor -4. run the verification harness -5. reconcile canonical state and `memory/CARDS.md` -6. commit only if the card produced a real card-sized change -7. continue only if no stop condition fires +1. take the next ready card in the active scope file +2. **re-orient checkpoint** — before starting, verify the card's premise still holds in light of what the previous card just taught you (see Stale-downstream invalidation below) +3. decide whether it is still a real build target or is already satisfied / stale on the current branch +4. if it is real work, run red → green → refactor +5. run the verification harness +6. reconcile canonical state and update the card's status in the scope file +7. commit only if the card produced a real card-sized change +8. continue only if no stop condition fires Stop the serial loop immediately when any of these becomes true: @@ -55,10 +68,23 @@ Stop the serial loop immediately when any of these becomes true: - the containing seam no longer feels settled - a manual outer-loop verification step is now required before proceeding - `memory/SPEC.md` or `memory/PLAN.md` needs non-trivial revision before the next card -- the remaining queued cards are no longer obviously valid +- the remaining cards in the file are no longer obviously valid (see below) - the user asked to pause or review between cards - context is getting fragile enough that handoff is safer than continuing +### Stale-downstream invalidation + +Even when `ln-scope` honored the hard anti-speculation gate (no card's scope was *expected* to depend on earlier-card findings), implementation can still surprise you. Between each card in a chain, perform this explicit re-orient: + +- read the next card's Target Behavior, Acceptance Criteria, and Expected touched paths +- ask: **does this card's premise still hold after what I just learned in the previous card?** + - Did the previous build change a path, name, or interface that this card references? + - Did the previous build retire or invalidate an assumption this card relies on? + - Did the previous build shift the seam such that this card's boundary crossings no longer match reality? +- if any answer is yes, mark this card and every remaining card in the file as `stale` and stop the serial loop. Route back to `ln-scope` for the rest of the chain. + +Never silently continue past a stale-downstream signal. Never silently delete a stale chain before a replacement exists. + ## Red Translate acceptance criteria into failing tests when the change benefits from them. For bugfixes or subtle seam changes, prefer one high-leverage regression test. For trivial maintenance or doc-only work, tests may be unnecessary. @@ -77,6 +103,8 @@ Honor the repo's pre-release posture: if the current schema, fixture shape, dumm No speculative abstractions. Only extract when two concrete cases force it. Do not anticipate later tests or build shape-only scaffolding; let the current behavioral test pull the interface into existence. +The card's Expected touched paths are tentative, not binding. If the build needs to diverge — a path you didn't anticipate, a file the work doesn't actually need — proceed and note the divergence briefly when updating the card's status. Significant divergence (touching new directories or seams not declared) is a signal to pause and re-check the overlap-as-independence-test against other active scope files for the same frontier. + ## Refactor With tests green, improve names, boundaries, and obvious local structure. Do not widen scope. @@ -102,6 +130,7 @@ After the build lands and verification passes, ask: - [ ] Did this retire or create an assumption? - [ ] Did this establish a new seam-level invariant? - [ ] Did this change a frontier-level cross-cutting obligation or verification architecture layer? +- [ ] Did this change the topology of a directory that owns a `README.md` (moved/renamed/retired files, changed dependency direction, completed or invalidated a migration note, or shipped a state previously described as pending)? ### If all answers are no @@ -127,7 +156,7 @@ Update only the touched traceability items. - Mark the frontier item done if this slice completed it - If the change closes, blocks, or unblocks a frontier item, reflect that in `Sequencing`, the affected `Frontier Definitions` entry, or `Recently Completed` - If the build changed a frontier-level cross-cutting obligation, update the affected frontier definition explicitly; do not hide the change behind bare traceability IDs - - Do not mirror detailed slice/card history into `memory/PLAN.md`; keep active execution queues in `memory/CARDS.md` + - Do not mirror detailed slice/card history into `memory/PLAN.md`; cards live in the scope file under `memory/cards/`. At most, the frontier definition may carry a lightweight `Current execution pointer` listing active scope file path(s). 2. **Assumptions** - evidence answered it → update to `validated` or `invalidated` @@ -144,40 +173,49 @@ Update only the touched traceability items. - same seam-level invariant gained coverage → update - genuinely independent seam/rule/proof → add +5. **Topology READMEs** (when the topology question is `yes`) + - update the `README.md` of every touched directory that owns one — ownership statement, layout sketch, dependency-direction assertion, and migration notes + - if a SPEC decision cited by the README was renumbered or retired during reconciliation, repair the citation in the same commit + - if a directory the build retires owned a README, delete the README with the directory + - if a new directory introduced by this slice will be a long-lived seam (multiple files, named in SPEC, or imported by other layers), draft a minimal topology README following the shape in `AGENTS.md` §topology READMEs — do not speculate; describe what exists + When uncertain between merge and add, add. When uncertain between update and no-op, update. If uncertain whether the seam is actually settled, promote — do not silently keep the work light. -Before finishing reconciliation, perform a quick cross-skill check: if a later agent read only `memory/SPEC.md`, `memory/PLAN.md`, and the touched frontier definition, would they miss a durable design choice or verification commitment that this build changed or relied on? If yes, reconcile it before stopping. +Before finishing reconciliation, perform a quick cross-skill check: if a later agent read only `memory/SPEC.md`, `memory/PLAN.md`, the touched frontier definition, and the touched directory READMEs, would they miss a durable design choice or verification commitment that this build changed or relied on? If yes, reconcile it before stopping. ### Retire derivative artifacts -After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones, but deletion is narrowly scoped. +After reconciliation, garbage-collect exhausted temporary files instead of leaving breadcrumbs or tombstones, but deletion is **per-file and narrowly scoped**. -Default deletion target: +Scope-file lifecycle under `memory/cards/`: -- `memory/CARDS.md` — delete only when the execution queue is fully exhausted, superseded, or empty after reconciliation. +- Delete the **specific scope file just consumed** when all its cards are `done` or `dropped` and no further build remains. Use a literal path: `git rm memory/cards/--.md` (or `rm` if untracked). Never bulk-delete the directory or operate on `memory/cards/*` with globs. +- If only some cards in the file are `done` and others remain `next` or `in progress`, leave the file in place with statuses updated. +- If the chain became `stale` mid-build, leave the file in place with `Status: superseded` at the header so `ln-scope` / `ln-sync` can decide whether to rewrite or delete on the next pass. +- Other active scope files under `memory/cards/` for the same frontier (independent concerns) are out of scope for this build's cleanup. Do not touch them. Other volatile artifacts are **review-before-delete**, not automatic cleanup: -- `HANDOFF.md` — delete only when it contains no unfinished transfer state and no future-context inventory that is not already captured in `memory/SPEC.md`, `memory/PLAN.md`, an active scope card, or a stable design memo. +- `HANDOFF.md` — delete only when it contains no unfinished transfer state and no future-context inventory that is not already captured in `memory/SPEC.md`, `memory/PLAN.md`, an active scope file, or a stable design memo. - `memory/REFACTOR.md` — delete only when every listed refactor step is done/dropped and no future sequence depends on it. - Provisional docs outside `memory/` (for example `docs/**/provisional*.md`, handoff plans, spike plans, or exploration inventories) — do **not** delete during `ln-build` cleanup unless the user explicitly asks or you first prove that all remaining future-facing inventory has been absorbed elsewhere. If only the current card is done but the artifact still contains later affordances, open questions, or scoping input, update it instead of deleting it. -Before deleting anything other than `memory/CARDS.md`, name the file, state why no future agent would need it, and prefer asking the user when uncertain. Do not create archive copies, numbered handoffs, or completion-pointer files. +Before deleting any file, name the file, state why no future agent would need it, and prefer asking the user when uncertain. Do not create archive copies, numbered handoffs, or completion-pointer files. ## Routing -If serial execution mode is active and no stop condition fired, continue to the next queued card instead of routing back to the user yet. +If serial execution mode is active and no stop condition fired, continue to the next card in the active scope file instead of routing back to the user yet. Otherwise, after verification and any necessary promotion updates, present these options to the user (use `tool-ask-question`): | # | Label | Target | Why | | --- | ---------------- | ------------ | --- | -| 1 | Scope next item | `ln-scope` | More frontier work remains or no prepared queue exists | +| 1 | Scope next item | `ln-scope` | More frontier work remains or no prepared scope file exists | | 2 | Review the code | `ln-review` | Assess quality after an implementation burst | | 3 | Revise spec | `ln-spec` | The build changed durable architecture | | 4 | Revise plan | `ln-plan` | The frontier or priorities changed | | 5 | Back to triage | `ln-consult` | Direction needs reassessment | -Recommended: **1** if more work remains and there is no active queue, **2** after multiple consecutive builds. +Recommended: **1** if more work remains and there is no active scope file, **2** after multiple consecutive builds. diff --git a/.agents/skills/ln-consult/SKILL.md b/.agents/skills/ln-consult/SKILL.md index ca4161c2..8c5f55aa 100644 --- a/.agents/skills/ln-consult/SKILL.md +++ b/.agents/skills/ln-consult/SKILL.md @@ -11,7 +11,7 @@ If context is unclear, ask **one** clarifying question — then recommend. The canonical rule is simple: durable planning state lives only in `memory/SPEC.md` and `memory/PLAN.md`, and new or uncertain work defaults to the canonical flow until a narrow exception is clearly justified. -Do not invent new planning documents, sidecar ledgers, or alternate storage locations without explicit user permission. If a fact matters beyond the current step, reconcile it into `memory/SPEC.md` or `memory/PLAN.md`; if it is temporary transfer state, keep it in `HANDOFF.md`; if it is a temporary multi-card execution queue inside one frontier item, keep it in `memory/CARDS.md`; if it is a temporary refactor execution plan, keep it in `memory/REFACTOR.md`. Derivative files stay live only while they still carry unfinished work. +Do not invent new planning documents, sidecar ledgers, or alternate storage locations without explicit user permission. If a fact matters beyond the current step, reconcile it into `memory/SPEC.md` or `memory/PLAN.md`; if it is temporary transfer state, keep it in `HANDOFF.md`; if it is one or more prepared scope cards for a frontier item, keep them in a scope file under `memory/cards/`; if it is a temporary refactor execution plan, keep it in `memory/REFACTOR.md`. Derivative files stay live only while they still carry unfinished work. Orient, then classify. @@ -62,7 +62,7 @@ Bounded exception: Bounded serial exception: -`ln-scope → ln-build → commit → ln-build ...` inside one already-settled frontier item, optionally with the prepared queue persisted in `memory/CARDS.md` +`ln-scope → ln-build → commit → ln-build ...` inside one already-settled frontier item, optionally with the prepared chain persisted as a `Mode: chain` scope file under `memory/cards/` Direct-build exception: @@ -100,7 +100,7 @@ Spikes are the escape hatch, not the default. | Spec exists, needs work sequencing | structural | `ln-plan` | | Verification strategy is the main uncertainty | structural | `ln-oracles` | | Next work item needs precise boundaries | structural or bounded | `ln-scope` | -| One settled frontier item needs several small verified commits in sequence | bounded, hardening | `ln-scope` then serial `ln-build` loop, optionally via `memory/CARDS.md` | +| One settled frontier item needs several small verified commits in sequence | bounded, hardening | `ln-scope` then serial `ln-build` loop, optionally via a `Mode: chain` scope file under `memory/cards/` | | Module interface needs exploration | structural | `ln-design` | | Full or light scope card exists, ready to code | bounded, hardening, bugfix | `ln-build` | | Technical uncertainty blocks progress, or a cheap investigation could invalidate planned work | any | `ln-spike` | diff --git a/.agents/skills/ln-handoff/SKILL.md b/.agents/skills/ln-handoff/SKILL.md index f808b8a5..b6da8999 100644 --- a/.agents/skills/ln-handoff/SKILL.md +++ b/.agents/skills/ln-handoff/SKILL.md @@ -10,7 +10,7 @@ Capture what lives in chat but not on disk. Git can reconstruct file changes. Bu The handoff must let a new thread act immediately without asking clarifying questions. -`HANDOFF.md` is derivative and temporary. It is never canonical planning state: durable truth belongs in `memory/SPEC.md` and `memory/PLAN.md`, prepared multi-card execution queues may live temporarily in `memory/CARDS.md`, and retired history belongs only in `docs/archive/PLAN_HISTORY.md`. +`HANDOFF.md` is derivative and temporary. It is never canonical planning state: durable truth belongs in `memory/SPEC.md` and `memory/PLAN.md`, prepared scope cards may live temporarily as scope files under `memory/cards/`, and retired history belongs only in `docs/archive/PLAN_HISTORY.md`. Default to one `HANDOFF.md` at the workspace root. Overwrite or replace the prior handoff; do not create numbered handoff archives, breadcrumb files, or completion tombstones without explicit permission. @@ -35,7 +35,7 @@ Be precise about state: This is the critical step. Scan the conversation for volatile artifacts — information discussed but **not yet persisted to disk**: - **Scope cards** from `ln-scope` — target behavior, boundary crossings, acceptance criteria -- **Queued scope cards** already persisted in `memory/CARDS.md` — capture only what is still volatile about them: which card is next, whether the queue is still valid, and any card-level corrections that have not been written back yet +- **Queued scope cards** already persisted in scope files under `memory/cards/` — capture only what is still volatile about them: which file is active, which card is next, whether the chain is still valid, and any card-level corrections that have not been written back yet - **Plan drafts** from `ln-plan` — slice lists, ordering decisions, dependency reasoning not yet in `memory/PLAN.md` - **Design outputs** from `ln-design` — alternative module shapes considered, the chosen shape, and rejected tradeoffs - **Oracle design outputs** from `ln-oracles` — O/R/C assessment, selected oracle families, per-frontier or per-slice verification approaches, acknowledged blind spots, and whether verification design is complete / pending / stale relative to the code @@ -57,7 +57,7 @@ What IS on disk: - **Git**: branch, recent commits (last 3-5), dirty/staged files - **Test status**: run the verification command if fast (<30s), otherwise note last known status - **Artifacts**: which of `memory/SPEC.md`, `memory/PLAN.md` exist? Are they current relative to what was discussed in conversation, or stale? -- **Derivative queues**: does `memory/CARDS.md` exist, and if so, is it still the live execution queue or already stale? +- **Derivative scope files**: do any files exist under `memory/cards/`, and if so, which are still live and which are already stale? - **Mini-sync triggers**: did manual verification happen, did frontier status change, or did residual risk surface without a doc update? If yes, name the exact drift the next thread must reconcile. ### 4. Produce handoff diff --git a/.agents/skills/ln-handoff/assets/handoff-template.md b/.agents/skills/ln-handoff/assets/handoff-template.md index 094a9e95..b734a255 100644 --- a/.agents/skills/ln-handoff/assets/handoff-template.md +++ b/.agents/skills/ln-handoff/assets/handoff-template.md @@ -60,7 +60,7 @@ | ------------------ | ------ | ------------------------- | | memory/SPEC.md | yes/no | current / stale / missing | | memory/PLAN.md | yes/no | current / stale / missing | -| memory/CARDS.md | yes/no | current / stale / n/a | +| memory/cards/ | list | files: active / stale / n/a per file | | memory/REFACTOR.md | yes/no | current / stale / n/a | ## Next steps diff --git a/.agents/skills/ln-oracles/SKILL.md b/.agents/skills/ln-oracles/SKILL.md index 121e7faf..cf85b239 100644 --- a/.agents/skills/ln-oracles/SKILL.md +++ b/.agents/skills/ln-oracles/SKILL.md @@ -81,7 +81,7 @@ Update `memory/SPEC.md` §Verification Design: Update `memory/PLAN.md` frontier annotations: - Add or refresh the `Verification` line in each in-scope frontier definition with oracle family, loop tier, and cross-reference to `memory/SPEC.md` sections -- Keep slice-level oracle detail in the current `ln-scope` card or `memory/CARDS.md` queue unless it changes the frontier definition +- Keep slice-level oracle detail in the current `ln-scope` card or its scope file under `memory/cards/` unless it changes the frontier definition ### Cross-reference integrity diff --git a/.agents/skills/ln-plan/SKILL.md b/.agents/skills/ln-plan/SKILL.md index b1496060..32ba60e8 100644 --- a/.agents/skills/ln-plan/SKILL.md +++ b/.agents/skills/ln-plan/SKILL.md @@ -8,7 +8,7 @@ argument-hint: "[feature or project area to plan]" Plan the **rolling frontier**, not the whole historical timeline. -`memory/PLAN.md` is the canonical record of what's next. `docs/archive/PLAN_HISTORY.md` is the only sanctioned archive for retired plan history. `memory/CARDS.md` is the sanctioned derivative queue for multiple prepared scope cards inside one frontier item; it is not canonical planning state. Do not invent other sidecar plan docs, milestone ledgers, or alternate memory locations without explicit permission. +`memory/PLAN.md` is the canonical record of what's next. `docs/archive/PLAN_HISTORY.md` is the only sanctioned archive for retired plan history. `memory/cards/` is the sanctioned derivative location for prepared scope cards; one file per concern, named `--.md` (or `dev--.md`, `tooling--.md`, `docs--.md` for non-frontier work). Scope files are not canonical planning state. Do not invent other sidecar plan docs, milestone ledgers, or alternate memory locations without explicit permission. ## Frontier vs slice vocabulary @@ -39,7 +39,7 @@ Within `Sequencing`, use: Archive deeper history to `docs/archive/PLAN_HISTORY.md` instead of keeping it live in `memory/PLAN.md`. -Treat frontier items as branch-sized work, not commit-sized work. If one frontier item will unfold as several consecutive verified slices, keep that execution queue in `memory/CARDS.md` or in session context instead of fragmenting `memory/PLAN.md` into a commit ledger. `memory/PLAN.md` may carry at most a lightweight pointer such as `current card queue: memory/CARDS.md`; detailed discretionary sub-slicing belongs in `memory/CARDS.md`. +Treat frontier items as branch-sized work, not commit-sized work. If one frontier item will unfold as several consecutive verified slices, keep that chain in a `Mode: chain` scope file under `memory/cards/` or in session context instead of fragmenting `memory/PLAN.md` into a commit ledger. `memory/PLAN.md` may carry at most a lightweight pointer such as `current execution pointer: memory/cards/--.md`; detailed discretionary sub-slicing belongs in the scope file itself. ## Input @@ -135,7 +135,7 @@ This sequencing pressure is distinct from "Epistemic horizon": that rule tells t 6. Add `Why now / unlocks` in a frontier definition when ordering would otherwise be opaque to a fresh thread. 7. Keep `Recently Completed` to 2-3 terse items max. Move older history to `docs/archive/PLAN_HISTORY.md`, not to handoff files or ad hoc notes. 8. Update `Dependencies` to reflect only active / next items, by frontier id. -9. If several commit-sized execution steps are already obvious inside one frontier item, keep them out of `memory/PLAN.md`; they belong in `memory/CARDS.md` or in the active thread as derivative execution detail. +9. If several commit-sized execution steps are already obvious inside one frontier item, keep them out of `memory/PLAN.md`; they belong in a scope file under `memory/cards/` or in the active thread as derivative execution detail. ### Cross-cutting obligations diff --git a/.agents/skills/ln-plan/assets/plan-template.md b/.agents/skills/ln-plan/assets/plan-template.md index 18fd2b24..37aad9d2 100644 --- a/.agents/skills/ln-plan/assets/plan-template.md +++ b/.agents/skills/ln-plan/assets/plan-template.md @@ -48,7 +48,7 @@ - **Cross-cutting obligations:** [optional: subsystem / invariant / verification-layer obligations this frontier must preserve or establish] - **Traceability:** [→ SPEC.md requirement / assumption / decision / invariant if needed] - **Design docs:** [links if relevant] -- **Current execution pointer:** [optional: `memory/CARDS.md` or next intended scope card; omit when not needed] +- **Current execution pointer:** [optional: active scope file path(s) under `memory/cards/` for this frontier — list all active; omit when not needed] ## Recently Completed diff --git a/.agents/skills/ln-refactor/SKILL.md b/.agents/skills/ln-refactor/SKILL.md index 2374293f..cb8c2305 100644 --- a/.agents/skills/ln-refactor/SKILL.md +++ b/.agents/skills/ln-refactor/SKILL.md @@ -20,7 +20,7 @@ The area to refactor: $ARGUMENTS 1. Capture the problem. Explore the codebase to verify assertions. Present alternatives the user may not have considered. Hammer out exact scope — what changes, what stays. 2. Check test coverage of the affected area. If coverage is insufficient for safe refactoring, the first step must be characterization tests (Feathers, *Working Effectively with Legacy Code*) — suggest `ln-build` for that before continuing. -3. Break the refactor into tiny commits. Order by safety: renames first (align to the lexicon in `memory/SPEC.md` if it exists), then extractions (deepen shallow modules — Ousterhout), then interface alignments, then behavioral changes last. Each commit is a complete, passing state. +3. Break the refactor into tiny commits. Order by safety: renames first (align to the lexicon in `memory/SPEC.md` if it exists), then extractions (deepen shallow modules — Ousterhout), then interface alignments, then behavioral changes last. Each commit is a complete, passing state. When a commit moves, renames, retires, or replaces files inside a directory that owns a `README.md`, the README update belongs in **the same commit as the topology change** — never deferred to a follow-up (see `AGENTS.md` §topology READMEs). 4. Write the refactor plan to `memory/REFACTOR.md`. Delete the file when the refactor is complete or superseded. ## Output @@ -52,6 +52,7 @@ Ordered list of tiny commits. Each described in plain English — no file paths - Interface changes - Architectural decisions - Schema changes, API contracts +- Topology READMEs touched (which directory READMEs the refactor will update or retire) No file paths or code snippets — they go stale. Record in `memory/SPEC.md` §Decisions when finalized. diff --git a/.agents/skills/ln-review/SKILL.md b/.agents/skills/ln-review/SKILL.md index bb5f869e..0bef1620 100644 --- a/.agents/skills/ln-review/SKILL.md +++ b/.agents/skills/ln-review/SKILL.md @@ -88,6 +88,18 @@ Concrete cues to look for: Collect findings as numbered items (category: `topography`). Frame each as: what the reader sees today, what they would have to internalize to find things, and the smallest topographic move that would make the tree teach itself. Routing for coordinated layout changes goes through `ln-refactor`; a single misplaced file can be a `ln-scope` slice. +### Topology README accuracy (category: `topography`) + +Directory `README.md` files under `src/**/` are canonical topology documentation (see `AGENTS.md` §topology READMEs). For each touched area, open the nearest README and check: + +- **Ownership statement** still matches what the directory actually owns and does not own +- **SPEC decision IDs** cited (e.g. `D52-L`) still exist in `memory/SPEC.md` and still mean what the README implies they mean +- **Dependency-direction assertions** ("`graph/` imports from `db/`; no other layer imports `db/` directly") match the actual import graph in the touched files +- **Layout sketches** still match the directory's contents — no retired files still listed, no new files unmentioned +- **Migration notes** describe state that is still pending; shipped or abandoned migrations are stale and should retire + +Collect mismatches as numbered findings. Frame each as: which README, which claim, what the code now says. Routing for coordinated README updates clusters with other topographic findings into `ln-refactor`; a single stale citation can be a `ln-scope` slice (or, if the change is mechanical, an `ln-build` direct fix). + ## Output Present findings as numbered candidates. Use the compact form for ordinary findings: diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index b4c76b8b..214f98bd 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -6,12 +6,14 @@ argument-hint: "[behavior to deliver in this slice]" # Ln Scope -Define **one** buildable scope card. The card always describes one slice, but it can carry one of two weights: +Define one or more buildable scope cards in a **scope file** under `memory/cards/`. Each card describes one slice; the file groups cards intended to be built together. + +A card carries one of two weights: - a **full scope card** for structural work - a **light scope card** for bounded feature or hardening work inside settled seams -If the target behavior needs "and", split it. +If a single card's target behavior needs "and", split it into separate cards (which may live in the same file). Apply the repo's pre-release posture while scoping: prefer correcting the model and regenerating fixtures over preserving accidental compatibility, unless live docs or the user require migration support. Include deletion/retirement work in the slice when obsolete code, data, or terminology would otherwise linger. @@ -32,36 +34,86 @@ If this is a fresh thread or an unfamiliar area, also read `HANDOFF.md` if prese Write a 2-4 bullet orientation note naming the containing seam, the relevant frontier item, volatile handoff state, and the main open risk. Also name any frontier-level cross-cutting obligations that this slice must preserve or establish (for example a shared command-layer invariant, a side-task/event-substrate rule, or a replay/property/adversarial verification layer). -Do not create new planning documents or scratch scope files without explicit permission. The canonical planning state remains `memory/SPEC.md` and `memory/PLAN.md`. The sanctioned derivative exception is `memory/CARDS.md`, which may hold several prepared scope cards for one frontier item while that execution queue is still live. +Do not create new planning documents or scratch scope stores without explicit permission. The canonical planning state remains `memory/SPEC.md` and `memory/PLAN.md`. The sanctioned derivative location for scope cards is `memory/cards/`, described below. If scoping reveals that one frontier item needs multiple sequential slices, keep them nested under that same frontier item unless the plan-level frontier must change. Do not silently turn slices into separate tracker / branch work items. -## Prepared card queue +## Scope file storage + +All scope cards — single or multi — live in a **scope file** under `memory/cards/`. + +### File naming + +``` +memory/cards/--.md +``` + +- `` is the stable id from `memory/PLAN.md` §Frontier Definitions when one applies (for example `live-graph-observer--observer-loop.md`). +- When the work is not a `memory/PLAN.md` frontier item (dev-workflow rework, tooling, repo hygiene), use a category prefix instead: `dev--.md`, `tooling--.md`, `docs--.md`. Pick whichever reads true; do not invent narrow ad-hoc categories. +- `` is short kebab-case (≤ ~5 words) capturing the concern. Discretion is fine — files are deleted when exhausted, so slug names need not be permanent. +- Double-dash `--` separates frontier from slug for readability. + +### File metadata header + +Every scope file starts with this header: + +```md +# + +Frontier: | n/a +Status: active | superseded | done +Mode: single | chain +Created: YYYY-MM-DD +``` + +`Mode: single` means one card in this file. `Mode: chain` means several cards intended as a sequential mini-queue. Independent concerns belong in **separate files**, not separate sections within one file. + +### Why one file per concern, not one file for everything + +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 execution context that one `ln-build` invocation consumes. + +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. + +## Multi-card scope files + +When the containing seam is settled and the next 2–5 commit-sized steps are obvious, write them as a `Mode: chain` scope file rather than forcing repeated rescoping. -When the containing seam is already settled and several next commit-sized steps are obvious, `ln-scope` may prepare a short queue of consecutive scope cards in `memory/CARDS.md` instead of stopping after exactly one card. +**Hard anti-speculation gate (this rule comes first):** no card in a chain may depend on implementation findings from earlier cards in the same chain. If card B's scope would shift based on what you learn while building card A, stop after A. Pre-scoped chains are for already-legible follow-through, not for guessing ahead. -Use this queue only when all of these are true: +A chain is appropriate only when all of these are true: -- the work stays inside one existing frontier item -- each queued card is still small enough to verify and commit independently -- no queued card is expected to change requirements, assumptions, decisions, or invariants +- the work stays inside one existing frontier item (or one coherent dev/tooling concern) +- each card is still small enough to verify and commit independently +- no card is expected to change requirements, assumptions, decisions, or invariants - the next few cards are sequentially obvious enough that pre-scoping them reduces churn rather than hiding uncertainty -- later queued cards are **not expected to change shape based on the implementation findings of earlier cards** +- later cards remain valid even if implementation of earlier cards surprises you -A short serial queue is for already-legible follow-through, not for guessing ahead. If card B or C depends on what you learn while building card A, stop after scoping card A (or at most the last card whose validity is still implementation-independent). +Multi-card preparation is a **bias when these conditions hold**, not a default to maximize. Prefer fewer cards over more. If in doubt, write one card. -Queue discipline: +Chain discipline: -- keep the queue short — usually 2-5 cards +- keep chains short — typically 2–5 cards - keep each card in full or light scope-card format -- mark status clearly (`next`, `in progress`, `done`, `dropped`) -- do **not** pre-scope speculative downstream cards just because the work is serial; only queue cards whose scope would still be valid if you paused before building the earlier one -- overwrite or delete `memory/CARDS.md` when the queue is exhausted or superseded -- if any queued card trips the promotion checklist, reveals a frontier split, or turns out to depend on unknown results from an earlier card, stop the queue and route back through `ln-spec` or `ln-plan` as appropriate +- mark card status clearly (`next`, `in progress`, `done`, `dropped`) +- if any card trips the promotion checklist, reveals a frontier split, or turns out to depend on unknown results from an earlier card, stop the chain and route back through `ln-spec` or `ln-plan` as appropriate +- delete the scope file when its chain is exhausted or superseded (per-file deletion only) + +## Overlap-as-independence-test + +When considering whether to write *another* scope file for the same frontier alongside an existing one, apply the overlap test: compare declared **Expected touched paths** across the two proposed files. + +If their primary write paths overlap, the concerns are not independent. Resolve before writing: + +- **merge** them into one file (`Mode: chain`) if the work is naturally sequential, or +- **reshape** the boundary so the two files own disjoint write paths + +Shared read-only paths or shared test-fixture paths are not overlap. The test applies to files the cards will create, modify, or delete as primary write targets. + +Path overlap declared at scope time = collision at build time. The touched-paths section is a manifest, not just navigation. ## Scope-weight decision -Choose one before writing the scope card. +Choose one before writing each scope card. ### Full scope card @@ -148,6 +200,24 @@ List any shared subsystem, invariant, or verification-layer obligations inherite - [obligation] ``` +### Expected touched paths (tentative) + +Required. Declare the directories and files this card will create, modify, or delete, using `pseudo tree` notation with overlay markers (`+` add, `~` modify, `-` delete, `?` uncertain). + +Scope to directory/file level — not function-level. Show the focused subtree, not the whole repo. The paths are **tentative** — `ln-build` may diverge during red/green, but the declared set is the manifest used by the overlap-as-independence-test and by parallel agents to detect collision. + +Example: + +``` +src/observer/ +├── loop.ts ~ +├── loop.test.ts ~ +└── handlers/ + ├── tool.ts + + └── tool.test.ts + +src/legacy/observer.ts ? +``` + ## Light scope card ### Objective @@ -186,6 +256,12 @@ State one of: If a light card would have to mark `Depends on:` a high-impact unvalidated assumption, promote to a full scope card and apply the **Tracer-bullet check**. +### Expected touched paths (tentative) + +Required when the card creates or deletes files, crosses a seam, or expects to touch more than ~3 paths. Optional for genuinely tiny edits (one or two files inside a settled module). + +Use the same `pseudo tree` form as full scope cards. + ### Promotion checklist If any answer is yes, stop treating the work as light and promote it to a full scope card before routing to `ln-build`. Do not quietly carry durable change under a light card. @@ -206,7 +282,7 @@ Canonical reconciliation is **mandatory**; durable updates are **conditional**. - Full scope card: update `memory/SPEC.md` / `memory/PLAN.md` as needed during or after scoping. - Light scope card: run the promotion checklist explicitly. If it stays light, canonical reconciliation may be a no-op; if it promotes, reconcile the durable change before build. -- Multi-card queue: keep the queue itself in `memory/CARDS.md`, but do not mirror those queued slice cards into `memory/PLAN.md` unless the frontier item itself changes. At most, add a lightweight `Current execution pointer` in the frontier definition. +- Multi-card scope file: keep the cards inside the scope file itself; do not mirror them into `memory/PLAN.md` unless the frontier item itself changes. At most, add a lightweight `Current execution pointer` in the frontier definition listing the active scope file path(s). Do not let the scope card strip away cross-cutting obligations just because the implementation slice is narrow. The card should make visible any shared architecture or verification rule the builder must carry while working locally. @@ -216,15 +292,17 @@ When adding or updating an assumption, apply the same-item test first: ## Routing -After the scope card is complete, present these options to the user (use `tool-ask-question`): +After the scope file is complete, present these options to the user (use `tool-ask-question`): | # | Label | Target | Why | | --- | -------------- | ------------ | --- | -| 1 | Build it | `ln-build` | The scope card is defined and verified enough to implement | +| 1 | Build it | `ln-build` | The scope file is defined and verified enough to implement | | 2 | Design oracles | `ln-oracles` | The verification strategy still needs explicit design | | 3 | Spike first | `ln-spike` | Technical uncertainty should be retired before coding | | 4 | Revise spec | `ln-spec` | Scoping revealed a durable architectural change | | 5 | Revise plan | `ln-plan` | The work no longer fits the current frontier | | 6 | Back to triage | `ln-consult` | Scope revealed unclear state | -Recommended: **1** in nearly all cases — including when the **Tracer-bullet check** fires, because the preferred resolution is to reshape, not defer. Recommend **3 (Spike first)** only when no vertical slice would be cheaper than a pure probe. Recommend **2 (Design oracles)** only when verification for the reshaped slice is still genuinely unclear. If a short prepared queue is warranted, write it to `memory/CARDS.md` and let `ln-build` consume the next ready card from there. +Recommended: **1** in nearly all cases — including when the **Tracer-bullet check** fires, because the preferred resolution is to reshape, not defer. Recommend **3 (Spike first)** only when no vertical slice would be cheaper than a pure probe. Recommend **2 (Design oracles)** only when verification for the reshaped slice is still genuinely unclear. + +When routing to `ln-build`, name the scope file path explicitly (for example: "build `memory/cards/--.md`"). `ln-build` uses a hybrid selection policy and prefers an explicit path argument. diff --git a/.agents/skills/ln-spec/SKILL.md b/.agents/skills/ln-spec/SKILL.md index f4327629..1cd65306 100644 --- a/.agents/skills/ln-spec/SKILL.md +++ b/.agents/skills/ln-spec/SKILL.md @@ -125,6 +125,7 @@ Every amendment must close its reference chain as far as the current lifecycle s - **New future direction** → has: PLAN frontier/horizon pointer or design-doc pointer; not full acceptance detail unless already active - **New constraint** → has: rationale for exclusion - **New inner-loop oracle item** → names the invariant(s) it protects +- **Retired, renumbered, or materially rewritten ID** → grep topology READMEs under `src/**/` for the affected ID (`rg -l 'D52-L|A47-L|I12-L' src`); repair stale citations in this pass, do not leave them for `ln-sync`. See `AGENTS.md` §topology READMEs. ### Cross-skill preservation check diff --git a/.agents/skills/ln-sync/SKILL.md b/.agents/skills/ln-sync/SKILL.md index d35309d3..ab302e9c 100644 --- a/.agents/skills/ln-sync/SKILL.md +++ b/.agents/skills/ln-sync/SKILL.md @@ -28,8 +28,9 @@ Prefer `ln-sync` at these moments: | `memory/PLAN.md` | what's next | sequencing, frontier definitions, near-horizon items, recent completions | | `docs/archive/PLAN_HISTORY.md` | historical ledger | older completed phases and retired plan history | | `HANDOFF.md` | derivative volatile transfer | only unfinished chat state not yet reconciled | -| `memory/CARDS.md` | derivative execution queue | only unfinished prepared scope cards inside one frontier item | +| `memory/cards/--.md` | derivative scope files | only unfinished prepared scope cards; one file per concern; multiple files per frontier permitted for independent concerns | | `memory/REFACTOR.md` | derivative temporary execution plan | only unfinished refactor steps | +| `src/**/README.md` | canonical topology documentation | ownership statement, SPEC decision references, dependency rules, layout sketch, live migration notes (see `AGENTS.md` §topology READMEs) | **Notation aid.** When refreshing SPEC or PLAN: @@ -50,7 +51,7 @@ Ask whether each file is still serving re-entry. - If `memory/SPEC.md` is carrying embedded truths, old implementation detail, closed historical debates, or validated assumptions that no longer shape frontier work, prune it. - If `memory/PLAN.md` is mostly completed history, collapse it to a rolling frontier and archive the rest. -- If `HANDOFF.md`, `memory/CARDS.md`, or `memory/REFACTOR.md` no longer carry live temporary state, delete them. +- If `HANDOFF.md`, any scope file under `memory/cards/`, or `memory/REFACTOR.md` no longer carries live temporary state, delete it. For `memory/cards/`, delete per-file with literal paths — never bulk-operate on the directory. ### 3. SPEC pass — keep only live architecture @@ -127,7 +128,7 @@ Rules: - treat **frontier items** as the canonical plan/Linear/branch units - treat **slices** as scoped execution units from `ln-scope` / `ln-build`, usually inside one frontier item - edit `Sequencing` for ordering/status churn; do not move or rewrite `Frontier Definitions` merely to reorder work -- keep detailed scope-card queues out of `memory/PLAN.md`; use `memory/CARDS.md` for temporary slice execution queues and at most a lightweight pointer from the frontier definition +- keep detailed scope-card chains out of `memory/PLAN.md`; use scope files under `memory/cards/` for temporary slice execution chains and at most a lightweight pointer from the frontier definition listing active scope file path(s) - move older completed items to `docs/archive/PLAN_HISTORY.md` - keep only the last 2-3 completed items live - only active / next frontier definitions need detailed acceptance or traceability @@ -144,18 +145,19 @@ Scan recent code / commits for: - active work not represented in `memory/PLAN.md` sequencing or frontier definitions - stale references between `memory/PLAN.md` and `memory/SPEC.md`, especially PLAN links to retired assumptions / decisions / invariants - equivalent facts that should merge instead of coexisting -- prepared cards in `memory/CARDS.md` that should be retired, re-scoped, or reconciled into the next thread's live state +- prepared cards in scope files under `memory/cards/` that should be retired, re-scoped, or reconciled into the next thread's live state - stale derivative artifacts that should be deleted after reconciliation - cross-cutting subsystems that appear only in glossary/design-doc links but are required by multiple active/next frontiers - verification strategy that is present in canonical docs or frontier definitions but absent from `memory/SPEC.md` §Verification Design - chosen module/API shapes or seam obligations from `ln-design` output that active frontier work still depends on +- **topology READMEs under `src/**/` out of sync with reality**: SPEC decision IDs cited in a README that this sync just renumbered or retired; named files/modules that have moved, been renamed, or been retired; dependency-direction assertions that no longer match actual imports; layout sketches whose entries no longer match the directory's contents; migration notes describing state that has since shipped or been abandoned (see `AGENTS.md` §topology READMEs) ### 6. Garbage-collect derivative artifacts Delete exhausted temporary artifacts after their useful state has been reconciled: - remove stale `HANDOFF.md` files instead of preserving them as archive breadcrumbs -- remove exhausted `memory/CARDS.md` queues instead of letting old prepared cards masquerade as live work +- remove exhausted scope files under `memory/cards/` (per-file, literal paths) instead of letting old prepared cards masquerade as live work - remove completed `memory/REFACTOR.md` files instead of leaving completion notes or pointers - if an ad hoc planning/status file was created with explicit permission and is now exhausted, reconcile any durable facts, then delete it unless the user asked to keep it @@ -190,6 +192,7 @@ Before finishing, perform a cross-skill preservation check: - If a later agent read only `memory/SPEC.md` and `memory/PLAN.md`, what durable design choices from `ln-design` would they miss? - What verification architecture or loop-tier strategy from `ln-oracles` or canonical docs would they miss? - What cross-cutting obligations would disappear because they are carried only by links, not by live rows or frontier definitions? +- Do any topology READMEs under `src/**/` still cite SPEC IDs or describe topology this sync just changed? Reconcile those READMEs as part of the sync, not as a follow-up. If any answer is non-empty, sync is incomplete. diff --git a/.fixtures/workbenches/live-graph-observer/README.md b/.fixtures/workbenches/live-graph-observer/README.md new file mode 100644 index 00000000..24bf2717 --- /dev/null +++ b/.fixtures/workbenches/live-graph-observer/README.md @@ -0,0 +1,116 @@ +# Workbench — live-graph-observer + +A reusable cwd for manually exercising the `live-graph-observer` (FE-795) frontier +end-to-end. Treat this directory as the project cwd when launching `brunch-cli` +so that `.brunch/` and `data.db` scaffold here rather than in the repo root. + +## Why it exists + +The frontier's middle/outer verification needs a stable, throwaway cwd where the +TUI writer and the web observer host can both run against a fresh +`.brunch/data.db`. Committing this directory (and only this README) guarantees +every contributor agrees on where the manual smoke happens. + +## How to use it + +From the repo root, run: + +```sh +# Dev build, against TS source (no build step needed) +( cd .fixtures/workbenches/live-graph-observer && npx tsx ../../../src/brunch.ts --mode print ) + +# Built bin (after `npm run build`) +( cd .fixtures/workbenches/live-graph-observer && node ../../../bin/brunch-cli.js --mode print ) + +# Once installed (e.g. via `npm link` or a published install) +( cd .fixtures/workbenches/live-graph-observer && brunch-cli --mode print ) +``` + +On first launch Brunch scaffolds a local `.brunch/` directory containing +`data.db` and Pi session files **inside this workbench directory**, not in the +repo root. That state is per-cwd by design and must not be committed. + +## What is and is not committed + +- ✅ This `README.md` is committed. +- 🚫 `.brunch/` (created on first launch) is ignored by the repo-level + `.gitignore` and must stay uncommitted. If anything else appears here later + (logs, scratch transcripts), prefer keeping them ignored rather than + whitelisting them. + +## Modes you will exercise here + +- `--mode print` — non-interactive workspace projection; smoke for CLI identity + and DB scaffolding. +- `--mode tui` — interactive writer session; once the `live-graph-observer` + observer host card lands, this is also the launch path that exposes a local + web observer URL. +- `--mode web` — standalone web host; useful for web-only iteration before the + TUI-hosted observer path is wired in. + +## Browser feedback loop + +Use Chrome DevTools Protocol tooling as the primary browser observer. Playwright +can still be useful for future scripted cross-browser checks, but the branch's +manual feedback loop is CDP-first because it gives quick console, network, and +accessibility-tree inspection without becoming product runtime behavior. + +Launch the web host from this workbench: + +```sh +# Terminal A: standalone web observer host +( cd .fixtures/workbenches/live-graph-observer && node ../../../bin/brunch-cli.js --mode web ) +``` + +The host prints a localhost URL such as: + +```text +Brunch web sidecar listening on http://127.0.0.1:/spec/ +``` + +or, when no selected spec route is available yet: + +```text +Brunch web sidecar listening on http://127.0.0.1: +``` + +Open and inspect that URL with `cdp-cli`: + +```sh +# Terminal B: browser observer +cdp-cli launch +cdp-cli new "http://127.0.0.1:/spec/" +cdp-cli tabs + +# Accessibility tree / page content +cdp-cli snapshot "127.0.0.1" +cdp-cli snapshot "127.0.0.1" --format text + +# Runtime signals +cdp-cli console "127.0.0.1" -t error -d 2 +cdp-cli network "127.0.0.1" -d 2 -t fetch +``` + +If the page title or URL is ambiguous, use the page id from `cdp-cli tabs` +instead of the `127.0.0.1` title/URL substring in later commands. + +### Annotation tooling + +`agentation`, if used, is complementary to CDP tooling: CDP observes the browser +(console, network, accessibility tree, screenshots), while `agentation` annotates +a running browser so an agent can fetch human/agent notes through its own CLI. +This card does not enable `agentation`, add a dependency, or import it into +`src/web/*`. If a future slice wants annotated web UI feedback inside product +code, that slice owns the dependency/import change. + +### Current verification note + +- `npm run build` passed during the Card 2 builder attempt. +- `brunch-cli --mode web` launched from this workbench and printed a localhost + URL. +- Browser automation is still pending on this machine until a local browser + backend works. Observed blockers: harness Chromium crashed, Playwright's + default Chrome path crashed under macOS sandbox/framework loading, + WebKit/Firefox were not installed, and the Chromium browser install attempt + was interrupted. Until that is fixed, the documented CDP loop is the intended + command path but the page-observable browser smoke is not yet complete. diff --git a/AGENTS.md b/AGENTS.md index 7666ec87..5b6d12d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,20 @@ This is not permission for unrelated rewrites: keep changes scoped to the active Use a lightweight fractal sub-tree pattern when a file outgrows its current mini-library boundary. Keep the original file as the public entry point (for example, `context-pack.ts`) and place private implementation modules in a same-named folder (for example, `context-pack/observer-capture.ts`). External consumers should continue importing from the public root file; only that root file should import from its private sub-tree. Split along semantic purpose, not file shape, and avoid speculative folder scaffolding until the file has real pressure. +## topology READMEs + +Directory-level `README.md` files under `src/**/` are **canonical documentation co-located with the code they describe**. They materialize architectural intent into the file topology: what the directory owns and does not own, its dependency direction, the SPEC decision IDs (`D52-L`, `D40-L`, …) that lock its layout, the resource taxonomy or layout sketch, and any in-flight migration state. Treat them as drift-prone canonical artifacts alongside `memory/SPEC.md` and `memory/PLAN.md` — not as ambient prose. + +Common drift sources: + +- a SPEC decision cited by the README is renumbered, retired, or rewritten +- a file or module the README names is moved, renamed, retired, or replaced +- the dependency direction the README asserts no longer matches actual imports +- migration notes describe state that has since shipped or been abandoned +- the directory layout sketch no longer matches the directory's contents + +Skills that touch canonical state (`/ln-sync`, `/ln-build`, `/ln-spec`, `/ln-review`, `/ln-refactor`) include topology READMEs in their drift checks and reconciliation. New topology READMEs should follow the established shape: short ownership statement, SPEC decision references, dependency rules, layout sketch when useful, and migration notes when relevant. Keep them short — they are an orientation surface, not a design doc; deep rationale belongs in `memory/SPEC.md` or `docs/`. + ## planning Two canonical documents in `memory/`: diff --git a/bin/brunch.js b/bin/brunch-cli.js similarity index 100% rename from bin/brunch.js rename to bin/brunch-cli.js diff --git a/docs/README.md b/docs/README.md index e351f547..ddbccd27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# brunch-next +# brunch This is the canonical line for the Brunch POC over `pi-coding-agent`. Prior implementation, planning memory, and design docs have been archived under diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 26a9cb72..5613572a 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -148,8 +148,8 @@ Every Brunch session should open with a concrete action or answer surface rather - A structured-exchange result details payload carrying enough projection data to stand alone: schema/version, status (`answered | skipped | cancelled | unavailable`), mode, prompt/questions, options, answers, and transport metadata. - A Brunch-owned TUI helper built on Pi custom UI patterns for radio, checkbox, questionnaire, and optional freeform input. - JSON-prefill / validation helpers for RPC editor fallback. This is a compatibility seam over Pi RPC, not a second Brunch product API. -- A private Pi RPC adapter that translates `extension_ui_request(editor)` into product-shaped pending elicitation state for Brunch public clients, then translates the product response back into Pi's documented `extension_ui_response`. -- Elicitation-exchange projection that treats terminal structured-exchange toolResults as response-side entries when their details carry the typed Brunch payload; ordinary toolResults remain prompt-side by default. +- A private Pi RPC adapter that translates `extension_ui_request(editor)` into product-shaped pending structured-exchange state for Brunch public clients, then translates the product response back into Pi's documented `extension_ui_response`. +- Session-exchange projection that treats terminal structured-exchange toolResults as response-side entries when their details carry the typed Brunch payload; ordinary toolResults remain prompt-side by default. - Brunch custom entry schemas for product-native offers that are not ordinary questions, such as `brunch.establishment_offer`, `brunch.review_set_proposal`, and later review-cycle responses. ### Capture-aware response payload @@ -545,7 +545,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 --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 four 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. @@ -565,7 +565,7 @@ These adoptions do not change the POC milestone ladder. They are deferred and ad - **M0–M9** proceed against pi-coding-agent as planned. - A post-M9 sandbox milestone introduces `BrunchSandbox` as the abstraction layer between pi tool execution and the host or a remote provider. -- A separate post-M9 remote-deployment milestone introduces `brunch --mode serve` against the same Brunch host, with the interactive adapters replaced by a headless transport adapter. +- A separate post-M9 remote-deployment milestone introduces `brunch-cli --mode serve` against the same Brunch host, with the interactive adapters replaced by a headless transport adapter. Both items should be tracked in `memory/PLAN.md` as deferred frontier items rather than POC scope. diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 9b05ff84..a310b086 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -25,7 +25,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **Live structured-exchange RPC oracle:** `npm run test -- src/probes/structured-exchange-rpc-proof.test.ts` launches a real Pi RPC subprocess with a minimal Brunch structured-exchange proof extension, observes the documented `extension_ui_request(method: "editor")`, responds with `extension_ui_response(value: schema-tagged JSON)`, and asserts the persisted terminal result details use the same self-contained `brunch.structured_exchange.result` payload as the TUI/helper path. -- **Public Brunch RPC parity oracle:** `src/probes/public-rpc-parity-proof.ts` now drives the deterministic structured-exchange permutation set through Brunch JSON-RPC only (`rpc.discover`, `workspace.*`, `session.*`, `elicitation.respond`) and persists `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/{session.jsonl,transcript.md,report.json}` as reviewable tuple-parity evidence. +- **Public Brunch RPC parity oracle:** `src/probes/public-rpc-parity-proof.ts` now drives the deterministic structured-exchange permutation set through Brunch JSON-RPC only (`rpc.discover`, `workspace.*`, `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`) and persists `.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/{session.jsonl,transcript.md,report.json}` as reviewable tuple-parity evidence. - **Web observation oracle:** WebSocket subscription tests now prove RPC-originated structured-exchange mutations notify browser clients, which then refetch canonical projection handlers rather than reading a parallel view store. ## Command inventory and containment matrix @@ -180,7 +180,7 @@ Runtime should **not** invoke Chafa on startup. The logo should be deterministic Startup now runs through Brunch-owned inventory and activation before Pi `InteractiveMode` starts. `.brunch/state.json` accelerates defaults but does not implicitly resume the prior transcript; the pure spec/session picker UI returns `continue` / `openSession` / `newSession` / `newSpec` / `cancel`, and `WorkspaceSessionCoordinator.activateWorkspace()` owns all session creation/opening, binding, and state-file effects. -The executable pty probe oracle is `src/probes/scripts/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains the compact Brunch wordmark, version/Pi line, spec/session picker markers, pre-agent-loop selection copy, and not the stale transcript text. A local run on 2026-05-30 passed with raw/stripped captures under the script-created `brunch-startup-oracle.*` workspace. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. +The executable pty probe oracle is `src/probes/scripts/verify-startup-no-resume.sh`. It builds the project, seeds a scratch workspace with a unique stale transcript sentinel, launches `brunch-cli --mode tui` under `script`, strips ANSI/control sequences, and asserts the first captured startup screen contains the compact Brunch wordmark, version/Pi line, spec/session picker markers, pre-agent-loop selection copy, and not the stale transcript text. A local run on 2026-05-30 passed with raw/stripped captures under the script-created `brunch-startup-oracle.*` workspace. This is a middle-loop/manual oracle, not part of `npm run verify`, because pty behavior is host-sensitive. Persistent chrome still needs qualitative live-host observation after explicit activation: the startup probe deliberately stops before selecting a spec/session so it can prove `I22-L` no-resume behavior without driving the agent loop. Manual closeout should confirm the post-activation header/footer/widget/title read as Brunch-owned, include the active session id/label and spec title, avoid Pi-branded primary surface leakage, and preserve the `brunch.chrome` widget/status-key discipline. @@ -233,7 +233,7 @@ The policy must run before interactive-mode built-in dispatch and before autocom ## Structured-exchange product relay status -The remaining FE-744 closeout is no longer generic structured-exchange relay work or branded/themed chrome recovery. Brunch has proven the private adapter/projection parts of the structured-exchange loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, web clients observe RPC-originated structured-exchange updates through the product invalidation/refetch path, and elicitation-exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. Brunch has also recovered product-owned startup/persistent chrome identity through shared TUI primitives, the chrome wrapper, and the branded startup pty oracle. The remaining residue is the accepted A18-L command-containment limitation: strict built-in command suppression still requires a Pi API seam. +The remaining FE-744 closeout is no longer generic structured-exchange relay work or branded/themed chrome recovery. Brunch has proven the private adapter/projection parts of the structured-exchange loop and the public product relay: present/request structured-exchange tools persist semantic display and response state through `toolResult.content`/`details`, rich TUI paths can collect answers through `ctx.ui.custom()`, raw Pi RPC can round-trip schema-tagged JSON through `ctx.ui.editor()` in a live subprocess proof, real Pi RPC validates same-assistant-message sequential `present_options → request_choice` result/UI/persistence ordering, public Brunch RPC drives ten distinct assistant-first structured-exchange tuples from a fresh cwd without raw Pi RPC, web clients observe RPC-originated structured-exchange updates through the product invalidation/refetch path, and session exchange projection classifies terminal structured-exchange `toolResult.details` (including cancelled/unavailable) as response-side transcript entries while preserving ordinary tool results as prompt-side. Brunch has also recovered product-owned startup/persistent chrome identity through shared TUI primitives, the chrome wrapper, and the branded startup pty oracle. The remaining residue is the accepted A18-L command-containment limitation: strict built-in command suppression still requires a Pi API seam. Pi source/docs already give strong evidence for the primitive: @@ -244,7 +244,7 @@ Pi source/docs already give strong evidence for the primitive: - `examples/extensions/rpc-demo.ts` and `examples/rpc-extension-ui.ts` prove Pi RPC can carry supported extension UI requests, including `editor`, through `extension_ui_request` / `extension_ui_response`. - `examples/extensions/message-renderer.ts` proves custom transcript display, but display alone does not collect a response. -The seam Brunch has now proven is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch elicitation state/event over the single public RPC surface → product response from a CLI probe over Brunch RPC → durable present/request tool results in Pi JSONL → response-side exchange projection → browser observer invalidation/refetch from canonical projection handlers. TUI-originated observation remains acceptable only if it reuses the same product invalidation path rather than inventing a parallel browser view store. +The seam Brunch has now proven is the product relay and parity loop around that composition: assistant structured-exchange tools → pending Brunch structured-exchange state/event over the single public RPC surface → product response from a CLI probe over Brunch RPC → durable present/request tool results in Pi JSONL → response-side exchange projection → browser observer invalidation/refetch from canonical projection handlers. TUI-originated observation remains acceptable only if it reuses the same product invalidation path rather than inventing a parallel browser view store. | Residual affordance | Current posture | Carry-forward obligation | | --- | --- | --- | diff --git a/docs/architecture/pi-wrapper-comparative.md b/docs/architecture/pi-wrapper-comparative.md new file mode 100644 index 00000000..0e8e8d44 --- /dev/null +++ b/docs/architecture/pi-wrapper-comparative.md @@ -0,0 +1,281 @@ +# Pi wrapper comparative — howcode vs Brunch + +This document compares two architectures that wrap the same upstream coding-agent SDK (`@earendil-works/pi-coding-agent`, "Pi") behind a web UI: + +- **howcode** ([github.com/IgorWarzocha/howcode](https://github.com/IgorWarzocha/howcode)) — a single-user Electron desktop workbench built around live Pi sessions. +- **Brunch** — this repository; a pre-release POC product whose canonical artifact is an intent-graph spec, with Pi acting as one engine among several. + +The point of the comparison is not to evaluate howcode. It is to use a contemporaneous, opposite-thesis wrapper as a mirror for Brunch's own architectural commitments, so that **the risks and debts we already own are visible against an external baseline**. + +The recommendations at the end of this document are Brunch-specific. They are written as guard-rails for ongoing work, not as a sweeping refactor agenda. The dependencies listed against each recommendation use SPEC.md decision and assumption IDs. + +## 1. Architectures at a glance + +### howcode + +```pseudo +processes: + Renderer (Chromium) + React + Vite + Tailwind v4 + window.piDesktop (contextBridge preload) + DesktopServiceClient (typed IPC) + Electron main + window lifecycle + IPC router + spawns service host child process + Service host (Node child) + imports @earendil-works/pi-coding-agent in-process + PiRuntime envelope around AgentSession + live-runtime-registry: cache by sessionPath, mutation locks, idle disposal + SQLite (better-sqlite3) mirror for inbox/sidebar/running-state + terminal manager (PTY) +``` + +- Pi binding: SDK imported once, lazily, via dynamic `import('@earendil-works/pi-coding-agent')` after applying `PI_CODING_AGENT_DIR`. A `PiRuntime` envelope adds `cwd`, `branchName`, `chatGroupId`, and `attachmentFileAccess` around each live `AgentSession`. +- Public surface: a typed `DesktopRequestMap` / `DesktopEventMap` exposed as `window.piDesktop`. The verb set is **Composer-centric** — `getComposerState`, `sendComposerPrompt`, `stopComposerRun`, `setComposerModel`, `setComposerThinkingLevel`, `openThreadRuntime`, `answerNativeAskQuestions`, etc. — each verb wraps a Pi mutation and emits domain events. +- Native UI substitutions for Pi tools: `ask_questions` is intercepted at the tool layer and answered through the React UI. +- Mode axis: `composerMode: 'chat' | 'code'` — a binary product-level switch that picks model and thinking-level defaults. +- Persistence: Pi JSONL is authoritative for transcripts. SQLite mirrors thread summaries, inbox snippets, running state, and chat-group membership for fast list-view rendering. +- Escape hatch: an embedded xterm.js Pi TUI takeover for cases the wrapper does not model. + +### Brunch + +```pseudo +modes (same Node process for tui/print, separate processes for rpc/web sidecar): + tui + embeds Pi in-process via InteractiveMode + sealed Brunch Pi profile (no ambient .pi/ discovery) + statically imported Brunch extension shell + workspace coordinator owns spec/session/graph state + optional web sidecar attaches read-only + rpc + JSON-RPC line server over stdio + same coordinator, full mutation surface + web + HTTP + WebSocket sidecar + read-only JSON-RPC handlers + serves the React+Vite SPA from dist-web + print + snapshot render of workspace state, no agent loop + +stores: + Pi JSONL transcript (.brunch/sessions/*.jsonl) + canonical for: session_binding, agent_runtime_state, structured_exchange tuples, worldUpdate + SQLite via Drizzle (.brunch/graph.db) + canonical for: specs, nodes, edges, change_log, reconciliation_needs + .brunch/workspace.json + project identity, posture, current/default spec+session acceleration +``` + +- Pi binding: SDK imported in-process. A "sealed Brunch Pi Profile" disables ambient `.pi/` discovery and injects an explicit, statically-imported extension shell. +- Public surface: Brunch's own JSON-RPC — never Pi's RPC. Named product method families: `workspace.*`, `session.*`, `graph.*`, `rpc.discover`. Mutation methods route through a Brunch `CommandExecutor` independently of Pi. +- Product reframing of Pi: + - `workspace(cwd) → spec → session` is the canonical hierarchy. Threads are not first-class; sessions are durable linear JSONL transcripts bound to exactly one spec. + - The product artifact is the **intent graph** in SQLite. Mutations flow through the `CommandExecutor`; Pi just invokes `commit_graph` and `accept_review_set` tools that route to it. + - The agent loop is **elicitation-first** / **offer-first**: at idle the user responds to structured exchanges (`present_question`/`request_answer`, `present_options`/`request_choice|choices`, `present_review_set`/`request_review`). + - Pi's `extension_ui_request(editor)` is relayed through Brunch as a product-shaped pending exchange; clients answer through Brunch methods, and Brunch synthesizes the `extension_ui_response` back to Pi. +- Mode axes: transport (TUI/RPC/print/web) × operational mode (`elicit` / future `execute`) × agent role (`elicitor` / `reviewer` / `reconciler` / future `executor`) × strategy × lens. SPEC D23-L holds these as separate axes by design. +- Concurrency: one-writer / many-observer. Web attaches read-only by SPEC D33-L. Session identity is never inferred from transport connection. +- Read-model discipline: SPEC D19-L forbids generic read APIs, REST view stores, and canonical cross-store event spines. `brunch.updated` is a process-local invalidation hint only — clients refetch through named projections. + +## 2. The actual disagreement + +```diagram +╭─────────────────────────────────╮ ╭───────────────────────────────────╮ +│ howcode │ │ Brunch │ +│ │ │ │ +│ Pi AgentSession │ │ Brunch product state │ +│ ▲ │ │ (workspace → spec → session, │ +│ │ surface adapts to Pi │ │ intent graph in SQLite, │ +│ │ │ │ reconciliation needs) │ +│ Composer verbs │ │ ▲ │ +│ (send/stop/setModel/...) │ │ │ surface IS the product │ +│ ▲ │ │ │ │ +│ │ │ │ Named JSON-RPC families │ +│ Typed IPC channel maps │ │ (workspace.*, session.*, │ +│ ▲ │ │ graph.*, rpc.discover) │ +│ │ │ │ ▲ │ +│ React renderer │ │ Pi (in-process, sealed profile) │ +│ │ │ is one engine populating │ +│ │ │ product state, not the center │ +╰─────────────────────────────────╯ ╰───────────────────────────────────╯ + "wrap Pi as the product" "wrap Pi as infrastructure" +``` + +| Axis | howcode | Brunch | +|----------------------------------|---------------------------------------------------------------|------------------------------------------------------------------------------------------| +| Canonical human object | thread / session | spec graph (sessions bound under specs) | +| Public contract | typed IPC verb maps, Composer-centric | named JSON-RPC method families + `rpc.discover` | +| Pi visibility to clients | implicit — verbs mirror Pi mutations | explicitly forbidden by SPEC R27 | +| Persistence | Pi JSONL + SQLite mirror for list views | Pi JSONL = transcript truth; SQLite = graph truth | +| Mutation authority | every verb flows through `AgentSession` | graph mutations through `CommandExecutor`, independent of Pi (D4-L, D20-L) | +| Mode axes | `composerMode: chat | code` preset switch | transport × op-mode × agent role × strategy × lens (D23-L) | +| Concurrency | single window owns the runtime cache | one-writer / many-observer; web attaches read-only (D33-L) | +| Pi profile | inherits ambient `.pi/` (it *is* a Pi UX) | sealed profile (D39-L); ambient `.pi/` disabled | +| Escape hatch | embedded xterm Pi TUI takeover | none — leaving the product model means leaving Brunch | + +The disagreement is not cosmetic. It determines API shape, persistence model, concurrency model, what counts as truth, and what future features are easy. + +- **howcode says**: wrapping means *adapting Pi to a better UI/runtime environment* while preserving Pi's session worldview. The agent session is the product. +- **Brunch says**: wrapping means *preventing Pi's worldview from leaking out* and forcing all interactions through product semantics. The graph spec is the product; Pi is infrastructure. + +Neither is "more correct"; they are optimizing for different truths. The risks below follow from the truth Brunch is optimizing for. + +## 3. Tradeoffs + +### What each architecture makes cheap and expensive + +#### howcode +- **Cheap**: Pi-adjacent feature velocity; closeness to the live `AgentSession`; low-latency native shell affordances (terminal, git, diff, attachments, dictation); responsive list/inbox UI via the SQLite mirror; bespoke per-tool UI substitutions like native `ask_questions`. +- **Expensive**: anything that is not naturally a thread/session; durable product semantics beyond conversation history; headless automation or third-party clients; multi-observer consistency; generalized structured interaction beyond per-tool patches; a clean public API story. + +#### Brunch +- **Cheap**: canonical product semantics independent of Pi phrasing; named projections over stable stores; deterministic probe verification; transport independence; one-writer/many-observer; swapping agent roles without changing client contract; strong authority around graph mutation. +- **Expensive**: ambient freeform agent UX; Pi feature parity; reuse of the Pi customization ecosystem; anything that does not fit the Brunch ontology; flexible browser-side writing today; iterating fast on product shape if the ontology turns out to be wrong. + +### The hostile critiques worth keeping + +- **Against howcode**: it is accumulating product state around Pi sessions without a strong canonical model, so future non-chat features will turn into synchronization debt. The SQLite "mirror" is already partly authoritative for inbox/sidebar/running-state. +- **Against Brunch**: it is building a rich product ontology and transport discipline before fully proving that users want that much semantic control instead of just a better agent console. + +The second critique is the more important one for our purposes: it is a warning against architecture outrunning evidence. + +## 4. Cross-pollination — what is safe to borrow + +### Brunch borrowing from howcode + +| Tactic | Verdict | Notes | +|-------------------------------------------------|---------------------|--------------------------------------------------------------------------------------------------------| +| Composer-centric verbs as public surface | ❌ do not borrow | Would leak Pi/session semantics into our public contract and undercut D5-L, D19-L, R27 (discovery). | +| Selective mirror / projection cache for hot reads | ⚠️ bounded only | Acceptable only as a strictly disposable cache, never as a second truth plane. SPEC D19-L is the line. | +| Typed IPC-style channels | ⚠️ adapter only | Fine as a local desktop adapter implementation detail if we ever ship one; not as the product contract.| +| Per-tool UI substitution (e.g. `ask_questions`) | ❌ do not borrow | We already have a general product-owned pending-exchange abstraction (D17-L); bespoke per-tool patches would compete with it. | +| Embedded TUI takeover escape hatch | ❌ do not borrow | Direct contradiction of D2-L and D39-L. Brunch is not a Pi distribution; leaving the model means leaving Brunch. | + +### What howcode could (hypothetically) gain from Brunch + +This is included only so the comparison is symmetric; it has no Brunch action items. + +- Product RPC + `rpc.discover` for an automation/probe/multi-client story. +- Transport-vs-operational-mode separation as `chat|code` grows past preset switching. +- Sealed-profile / spec-hierarchy / graph-as-canonical — these would actively damage howcode's strength as a Pi-native workbench and are not appropriate for it. + +## 5. Risks already inside Brunch + +Each item below is an accidentally load-bearing seam or an unfinished commitment that the SPEC has already articulated but the code has not fully discharged. Severity is relative to Brunch's stated goals, not absolute. + +### R1. Private `SessionManager` flush internals (severity: high) + +Brunch currently depends on private-ish Pi behavior — `_rewriteFile()`, `setSessionFile(...)` — to force pre-assistant persistence and avoid duplicate prefixes. This is classic accidental load-bearing: a Pi minor version could break Brunch silently or noisily. + +- **Why it matters**: Pi JSONL persistence ordering is canonical for many Brunch projections (`session.pendingExchange`, exchange extraction, world-update entries). If the private flush behavior shifts, every projection that assumes "the entry is durable before we project it" can drift. +- **Owning SPEC items**: A1-L (Pi seam sufficiency), D1-L (depend on `pi-coding-agent`), D17-L (Pi transcript substrate). +- **Mitigation**: convert each private call site into a Brunch-side adapter with a single chokepoint, and either upstream a stable seam to Pi or write a contract test that fails on Pi upgrades that change the observable behavior we rely on. + +### R2. Public RPC vocabulary drift (resolved) + +`src/rpc/README.md` is now the canonical method contract, and dispatch/discovery are generated from one registry. The active public session names are `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`, and `session.runtimeState`; removed names are quarantined in the RPC README's absent-name list and are not compatibility aliases. + +- **Why it mattered**: every external client and probe written against stale names would have become a constraint on future renames. +- **Owning SPEC items**: D5-L (single public protocol), D19-L (named method families), R11 (JSON-RPC primary), R27 (Brunch-owned discovery). +- **Resolved by**: FE-795 RPC registry refactor; no aliases or deprecation adapters were added under Brunch's pre-release/free-rewrite posture. + +### R3. Pi lifecycle/timing coupling (severity: medium-high) + +Beyond R1, Brunch leans on specific Pi semantics: `prepareNextTurn` ordering, structured `toolResult` ordering, extension UI request/response flow, compaction hooks, and session lifecycle hooks. These are reasonable seams to depend on, but the dependency is currently undocumented and untested as a contract. + +- **Why it matters**: a Pi behavior change in any of these will manifest as subtle projection or elicitation bugs, not as a clear API break. +- **Owning SPEC items**: A1-L, A11-L, D17-L, D33-L (Pi extension UI relay). +- **Mitigation**: enumerate the exact Pi behaviors we depend on in `docs/architecture/pi-seam-extensions.md`, and back each with a probe or contract test under `src/probes/*`. Currently `structured-exchange-rpc-proof.ts` does this for the editor relay; expand the pattern. + +### R4. Read-model discipline vs UI ambition (severity: medium) + +SPEC D19-L forbids mirror stores, generic read APIs, and view databases. This is correct for the POC, but the moment the web UI wants something like "list every session across all specs, ordered by last activity, with the latest pending exchange inlined," we will be tempted to add a mirror table or a join-shaped read endpoint. + +- **Why it matters**: silent erosion of D19-L is how Brunch would acquire a parallel canonical store without ever deciding to. howcode's mirror DB is the cautionary case. +- **Owning SPEC items**: D19-L, D6-L, NO-3 (no DB-backed chat/turn projection). +- **Mitigation**: when a UI need looks like it wants a mirror, add a *disposable, rebuildable* projection cache scoped to the request lifecycle and labelled as such, or push back on the UI design. Never add a table whose rows are derivative of canonical truth without a hard-coded rebuild path. + +### R5. Linear-transcript-only commitment (severity: medium, narrow blast radius) + +SPEC R8 and decisions around session binding reject branched/forked transcripts for the POC. This is a deliberate simplification, but it is also a hard incompatibility surface if Pi evolves branch semantics or users want branching. + +- **Why it matters**: a future "branch this session to try a different elicitation path" feature would require touching projection, persistence, session binding, and the structured-exchange substrate at once. +- **Owning SPEC items**: R8 (linear sessions), D33-L (transport vs session identity). +- **Mitigation**: tag the rejection explicitly at the code seams that assume linearity (currently in `src/session/session-transcript.ts`, projection readers, and the binding entry). Note in those seams that branching is a known future migration, not a missed case. Do not defensively code for it. + +### R6. Forward-designed axis matrix outrunning user evidence (severity: medium) + +SPEC D23-L commits to transport × operational mode × agent role × strategy × lens as separate axes. Today the runtime only meaningfully populates *transport* and *operational mode = elicit*; `reviewer` and `reconciler` are partial, `executor` is future, and the lens/strategy axes only have a small set of populated values. + +- **Why it matters**: building infrastructure for axes we have not exercised risks freezing wrong assumptions into the schema, prompts, and runtime-state projection. The oracle's pointed warning was: *you are proving you are not Pi before proving users want the non-Pi worldview strongly enough.* +- **Owning SPEC items**: D23-L, D40-L (runtime state), D59-L (objective axes). +- **Mitigation**: when designing for an axis that has not been exercised end-to-end, prefer leaving the seam stubbed-but-honest (signature + intent comment) over building a working substrate. Walking-skeleton over second-system. + +### R7. `sessionPath` / session-identity assumptions (severity: low-medium, but easy to grow) + +We have not (yet) made the howcode mistake of keying everything off `sessionPath` — our identity story routes through workspace coordinator state and `session_binding` entries. But the temptation will arise as soon as we add a second long-running consumer of session state (e.g., a background reviewer that needs to address a specific session). + +- **Why it matters**: identity sprawl across cache, DB, and routing is what makes howcode's runtime lifecycle gnarly. Brunch is currently disciplined here and should stay that way. +- **Owning SPEC items**: D33-L, D11-L (workspace → spec → session). +- **Mitigation**: keep `(specId, sessionId)` the only identity primitive for session-scoped state. Refuse to add session-path-keyed caches unless there is a non-replaceable reason (e.g., file-system mtime watchers). + +### R8. Web sidecar as read-only attachment is a product bottleneck (severity: low now, rising) + +SPEC D33-L holds web read-only as a deliberate POC posture. Once the web UI becomes more than an observer dashboard, this constraint will be felt — especially if browser-side commit-graph proposals or review-set authoring become product surfaces. + +- **Why it matters**: lifting read-only without revisiting the one-writer assumption would introduce silent multi-writer races against the same Pi JSONL transcript. +- **Owning SPEC items**: D33-L, R12, NO-3 (one writer in POC). +- **Mitigation**: when we plan to lift this, do it through an explicit posture change (probably a "write lease" concept) and not by adding mutation methods to `createReadOnlyRpcHandlers`. Treat the read-only handler set as a load-bearing fence. + +## 6. Rabbit holes to refuse + +The patterns below are not active risks today but are the kinds of design that the oracle and this comparison flagged as plausible futures. Refuse them by default; require explicit revisitation of SPEC if they look attractive. + +- **A "Composer" object as Brunch's public verb surface.** It is the natural shape if we ever build a desktop client. It is also the shape that undoes D19-L by giving clients a Pi-shaped API. +- **A SQLite mirror of session/exchange state to speed up list views.** If a view feels slow, profile the projection first; if necessary, cache *inside the projection handler* rather than building a parallel table. +- **A "Pi takeover" / shell-out-to-real-Pi escape hatch.** Direct contradiction of D2-L and D39-L. If Brunch cannot model something, the answer is a Brunch decision (build it, defer it, decline it), not bypass. +- **Tool-by-tool UI substitution for Pi tools** (the howcode `ask_questions` pattern). We already have `present_*` / `request_*` structured exchanges and the editor relay as the general path (D17-L, D33-L). Per-tool patches would compete with that abstraction and erode it. +- **A second event spine** (Kafka-style topic bus, generic `events.subscribe`, or canonical cross-store changelog beyond the in-place graph `change_log` and Pi JSONL). SPEC D19-L is explicit: `brunch.updated` is a hint, not a fact. +- **Inferring session identity from transport connection.** Tempting in WebSocket code; forbidden by D33-L. +- **Generic `records.*` / `entities.*` RPC families.** Forbidden by D3-L; the vocabulary is graph-native and session-native. +- **Per-relation policy registries on graph edges.** D51-L closed this; the edge category set is fixed. +- **Treating `composerMode`-style binary mode switches as a Brunch concept.** Operational mode, agent role, strategy, and lens are the axes (D23-L, D40-L). + +## 7. Prioritary debts to pay down + +In recommended order; each links back to risks above. + +1. **Pay down R1 — private `SessionManager` flush dependency.** Centralize the call sites behind a single Brunch-side adapter; add a contract test that exercises pre-assistant flush ordering against the installed Pi version. If the seam is genuinely necessary, open the upstream conversation. This is the single most fragile point of contact with Pi. + +2. **Keep R2 resolved — do not reopen RPC compatibility aliases.** The canonical vocabulary now lives in `src/rpc/README.md`, dispatch/discovery share one registry, and retired names are absent rather than aliased. Future client work should use the discovered canonical names and keep retired names only in the RPC README's absent-name list. + +3. **Pay down R3 — make Pi lifecycle dependencies legible.** Enumerate every Pi behavior we depend on (timing, ordering, hook semantics) in `docs/architecture/pi-seam-extensions.md` and back each with a probe under `src/probes/*`. This turns a class of silent breakage into a class of loud breakage. + +4. **Fence R4 — write the read-model discipline down as a code-level rule.** Add a short `src/rpc/READ_MODEL_DISCIPLINE.md` (or expand `src/rpc/README.md`) with: no mirror tables; projections live next to their owning store; caching is request-scoped and disposable; `brunch.updated` is a hint, never a fact. Reference it from PR templates. + +5. **Tag R5 in code, not just in docs.** Add narrow `// linear-transcript: see SPEC R8` markers at the projection and persistence sites that assume linearity. The goal is to make the assumption visible to anyone reading the seam, so that "small additions" do not accidentally branch us. + +6. **Hold R6 by default; require evidence to lift it.** For any new axis-related substrate (reviewer policy, lens registry, strategy registry), the default posture is *stub-with-intent-comment* until two real callers exist (rule of three / proving posture per `AGENTS.md`). New runtime substrate requires an explicit assumption-status update in SPEC. + +7. **Defer R8 explicitly.** No new mutation handlers in `createReadOnlyRpcHandlers`. When lifting read-only becomes a real requirement, open a posture discussion in SPEC first; do not let the read-only fence erode silently. + +## 8. Operating principles to keep + +These are not new rules. They are the things this comparison reaffirmed; pinning them here so they stay legible. + +- **Pi is infrastructure, not the product.** Every time a feature is easier to ship by exposing a Pi concept directly, that is a signal to design the product abstraction, not to ship the leak. The Composer-verb temptation is the canonical example. +- **The graph is canonical; Pi JSONL is canonical for transcripts; everything else is projection.** No mirror, no second event spine, no shadow store. If we find ourselves wanting one, audit the projection first. +- **Discovery beats convention.** `rpc.discover` is the contract; method names that exist but aren't discoverable should be treated as private. +- **Stub-with-intent over speculative substrate.** For axes/roles/lenses not yet exercised, leave honest stubs. Architecture should follow user evidence, not lead it by more than one move. +- **Make Pi seams legible.** Every dependency on Pi behavior — public or private — should be findable, named, and probe-backed. +- **One writer for the POC, by posture.** Lifting this requires an explicit SPEC change, not just code that happens to work. + +## 9. References + +- `memory/SPEC.md` — canonical specification; particularly Capability Requirements R8, R11, R12, R27 and Active Decisions D1-L, D2-L, D3-L, D4-L, D5-L, D17-L, D19-L, D20-L, D23-L, D33-L, D39-L, D40-L, D51-L. +- `docs/architecture/prd.md` — product requirements. +- `docs/architecture/pi-seam-extensions.md` — Pi seam inventory and Brunch-owned extensions. +- `src/rpc/README.md` — current RPC surface, discovery contract, and absent-name list. +- `src/.pi/README.md` — extension/profile sealing notes. +- howcode source ([github.com/IgorWarzocha/howcode](https://github.com/IgorWarzocha/howcode)) — comparative reference, especially `desktop/pi-module.ts`, `desktop/runtime-host/live-runtime-service.ts`, `desktop/runtime/composer-state.ts`, `src/electron/preload/create-desktop-api.ts`. diff --git a/docs/architecture/prd.md b/docs/architecture/prd.md index 939054df..5b92c1bd 100644 --- a/docs/architecture/prd.md +++ b/docs/architecture/prd.md @@ -98,10 +98,10 @@ If they can, JSONL remains the transcript authority for the POC. If they cannot, Brunch should expose one local product with four presentation modes: -1. `brunch` - default TUI over the local agent host. -2. `brunch --mode web` - launches a local HTTP server and browser UI over the same host. -3. `brunch --mode rpc` - exposes the local host over stdio JSON-RPC for other programs. -4. `brunch --mode print` - runs one-shot, headless prompts for scripting and pipelines. +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. These modes are not four different products. They are four ways of driving one Brunch host. diff --git a/docs/design/REVIEW_SETS.md b/docs/design/REVIEW_SETS.md index 71a2c9a5..1dfa7234 100644 --- a/docs/design/REVIEW_SETS.md +++ b/docs/design/REVIEW_SETS.md @@ -160,8 +160,8 @@ The reviewer is an agent-mode mirror of observer, instantiated to handle batch-a | Property | Observer | Reviewer | |---|---|---| -| Trigger | completion of single elicitation exchange | acceptance of batch proposal | -| Scope | one elicitation exchange | the accepted batch + its graph neighborhood | +| Trigger | completion of a single session exchange | acceptance of batch proposal | +| Scope | one session exchange | the accepted batch + its graph neighborhood | | Extracts | implicit info → small graph mutations or reconciliation needs | coherence / completeness / gap analysis | | Latency posture | async, per-exchange job | async, per-batch job | | Job key | `(session_id, exchange_entry_range)` | `(session_id, batch_acceptance_entry_id)` | diff --git a/docs/praxis/graphite-workflow.md b/docs/praxis/graphite-workflow.md index 50129248..cefc5089 100644 --- a/docs/praxis/graphite-workflow.md +++ b/docs/praxis/graphite-workflow.md @@ -32,7 +32,7 @@ Then create or track the corresponding Graphite branch. If another tool creates - Branch / Linear-issue granularity follows the containing `memory/PLAN.md` frontier item. - A frontier item is the plan-level work item; scope cards and implementation slices are execution detail inside it. - `ln-scope` may narrow one frontier item into multiple buildable slices or consecutive scope cards; keep them on one branch. -- If several consecutive scope cards are prepared ahead of time, keep that execution queue in `memory/CARDS.md`; do not split branches or duplicate detailed slice history in `memory/PLAN.md` just to mirror commit-sized steps. +- If several consecutive scope cards are prepared ahead of time, keep that chain in a single `Mode: chain` scope file under `memory/cards/`. Multiple scope files for the same frontier (independent concerns) do **not** imply multiple branches, and detailed slice history does not belong in `memory/PLAN.md`. - Only create a new branch when starting a different frontier item, or after `ln-plan` explicitly splits the frontier into separate PLAN.md items that should stack independently. - If scoping shows the current frontier item is too large, revise `memory/PLAN.md` first, then align the branch stack to the revised frontier. diff --git a/docs/praxis/ln-skills.md b/docs/praxis/ln-skills.md index 3a389199..adfe8a43 100644 --- a/docs/praxis/ln-skills.md +++ b/docs/praxis/ln-skills.md @@ -11,7 +11,7 @@ The skills are a development workflow for keeping product intent, planning, impl | `memory/SPEC.md` | What and why: product contract, live assumptions, decisions, invariants, lexicon, verification stance. | | `memory/PLAN.md` | What's next: frontier items, sequencing, acceptance, verification notes. | | `HANDOFF.md` | Temporary resumability state when a session ends or context is fragile. | -| `memory/CARDS.md` | Temporary multi-card execution queue inside one settled frontier item, when explicitly created. | +| `memory/cards/--.md` | Scope files holding one or more prepared scope cards. Multiple files per frontier permitted for independent concerns; one file = one execution context for `ln-build`. | | `memory/REFACTOR.md` | Temporary refactor execution plan, when explicitly created. | Do not invent alternate planning stores. If a fact matters durably, promote it through `ln-spec`, `ln-plan`, or `ln-sync`. diff --git a/drizzle/0000_jazzy_warbound.sql b/drizzle/0000_deep_maria_hill.sql similarity index 83% rename from drizzle/0000_jazzy_warbound.sql rename to drizzle/0000_deep_maria_hill.sql index 5d5fe6fe..1dd68637 100644 --- a/drizzle/0000_jazzy_warbound.sql +++ b/drizzle/0000_deep_maria_hill.sql @@ -7,6 +7,7 @@ CREATE TABLE `change_log` ( --> statement-breakpoint CREATE TABLE `edges` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `spec_id` integer NOT NULL, `category` text NOT NULL, `source_id` integer NOT NULL, `target_id` integer NOT NULL, @@ -15,6 +16,7 @@ CREATE TABLE `edges` ( `rationale` text, `created_at_lsn` integer NOT NULL, `updated_at_lsn` integer NOT NULL, + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`source_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`target_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action ); @@ -28,6 +30,7 @@ INSERT INTO `graph_clock` (`id`, `lsn`) VALUES (1, 0); --> statement-breakpoint CREATE TABLE `nodes` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `spec_id` integer NOT NULL, `plane` text NOT NULL, `kind` text NOT NULL, `title` text NOT NULL, @@ -36,11 +39,13 @@ CREATE TABLE `nodes` ( `source` text, `detail` text, `created_at_lsn` integer NOT NULL, - `updated_at_lsn` integer NOT NULL + `updated_at_lsn` integer NOT NULL, + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint CREATE TABLE `reconciliation_need` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `spec_id` integer NOT NULL, `target_kind` text NOT NULL, `target_edge_id` integer, `target_a_id` integer, @@ -50,6 +55,7 @@ CREATE TABLE `reconciliation_need` ( `reason` text, `created_at_lsn` integer NOT NULL, `resolved_at_lsn` integer, + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`target_edge_id`) REFERENCES `edges`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`target_a_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`target_b_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 2e7def4c..d93a1037 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "9a22b1aa-7dfa-4f47-95f8-5dd93ffcf7c1", + "id": "48abb3fa-9eca-4194-b9ff-343f96ed32cf", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "change_log": { @@ -53,6 +53,13 @@ "notNull": true, "autoincrement": true }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "category": { "name": "category", "type": "text", @@ -113,12 +120,29 @@ }, "indexes": {}, "foreignKeys": { + "edges_spec_id_specs_id_fk": { + "name": "edges_spec_id_specs_id_fk", + "tableFrom": "edges", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "edges_source_id_nodes_id_fk": { "name": "edges_source_id_nodes_id_fk", "tableFrom": "edges", "tableTo": "nodes", - "columnsFrom": ["source_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" }, @@ -126,8 +150,12 @@ "name": "edges_target_id_nodes_id_fk", "tableFrom": "edges", "tableTo": "nodes", - "columnsFrom": ["target_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -171,6 +199,13 @@ "notNull": true, "autoincrement": true }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "plane": { "name": "plane", "type": "text", @@ -237,7 +272,21 @@ } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "nodes_spec_id_specs_id_fk": { + "name": "nodes_spec_id_specs_id_fk", + "tableFrom": "nodes", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} @@ -252,6 +301,13 @@ "notNull": true, "autoincrement": true }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "target_kind": { "name": "target_kind", "type": "text", @@ -319,12 +375,29 @@ }, "indexes": {}, "foreignKeys": { + "reconciliation_need_spec_id_specs_id_fk": { + "name": "reconciliation_need_spec_id_specs_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "reconciliation_need_target_edge_id_edges_id_fk": { "name": "reconciliation_need_target_edge_id_edges_id_fk", "tableFrom": "reconciliation_need", "tableTo": "edges", - "columnsFrom": ["target_edge_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "target_edge_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" }, @@ -332,8 +405,12 @@ "name": "reconciliation_need_target_a_id_nodes_id_fk", "tableFrom": "reconciliation_need", "tableTo": "nodes", - "columnsFrom": ["target_a_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "target_a_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" }, @@ -341,8 +418,12 @@ "name": "reconciliation_need_target_b_id_nodes_id_fk", "tableFrom": "reconciliation_need", "tableTo": "nodes", - "columnsFrom": ["target_b_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "target_b_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -401,4 +482,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f6240af3..a675ec85 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "6", - "when": 1780414439815, - "tag": "0000_jazzy_warbound", + "when": 1780478757603, + "tag": "0000_deep_maria_hill", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index cb0286c8..ece4e0a0 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -31,7 +31,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### Active -1. `live-graph-observer` — not-started — P0 visual black triangle: TUI writer + web observer + selected-spec graph view over WebSocket RPC. +- None — `live-graph-observer` is tied off on FE-795; next action is to start `agents-composition-layer` on its Graphite branch. ### Next @@ -63,10 +63,10 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### live-graph-observer - **Name:** Live selected-spec graph observer over web RPC -- **Linear:** unassigned -- **Branch:** to create — `ln/-live-graph-observer` +- **Linear:** [FE-795](https://linear.app/hash/issue/FE-795/live-selected-spec-graph-observer-over-web-rpc) +- **Branch:** `ln/fe-795-live-over-web-rpc` - **Kind:** bounded feature / tracer bullet -- **Status:** not-started +- **Status:** done — tied off 2026-06-04. - **Objective:** Make the graph visible as live product state while the TUI remains the writer. Add product RPC graph reads/subscriptions and a minimal web graph panel so a graph mutation from the TUI/agent path updates the browser's selected-spec graph view. - **Why now / unlocks:** This is the primary POC observability mark. Without a simultaneous TUI session and web graph view, the graph-native workspace remains mostly invisible even though persistence and tools exist. - **Acceptance:** @@ -75,17 +75,17 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - A graph commit made through the real product path invalidates/notifies the web client, which refetches from canonical graph readers and shows the updated selected-spec graph without page reload. - The TUI remains the writer; the web surface is read-only unless a later explicit product command changes that. - Multi-spec discipline: graph reads target the selected/current spec; no workspace-global graph projection is introduced. -- **Verification:** Inner — RPC handler/discovery/schema tests; web query/render tests around empty graph and populated graph. Middle — integration test or probe that performs a real `commitGraph`/graph-tool write and observes WebSocket notification/refetch over the public RPC surface. Outer — manual TUI + web smoke: fresh cwd, activate spec/session in TUI, open web dashboard, create/commit graph, see web update. -- **Topology materialization:** `graph/` remains the read/domain owner; `rpc/` owns graph method handlers and subscriptions; `web/` owns graph rendering; no `web/` or `.pi/` import of `db/`; no duplicate graph DTOs outside projected/read-model types. -- **Cross-cutting obligations:** Preserve D19-L thin named RPC methods, D33-L client attachment semantics, D35-L product chrome/projection discipline, and D52-L source dependency direction. Do not introduce a generic read gateway or view store. -- **Traceability:** R7, R10, R11, R12 / D5-L, D10-L, D19-L, D33-L, D52-L, D60-L / I21-L, I35-L / A3-L, A4-L. -- **Design docs:** `memory/SPEC.md` D19-L, D33-L, D52-L, D60-L; `src/rpc/README.md`; `src/web/README.md`; `docs/design/GRAPH_MODEL.md`. +- **Verification:** Inner — RPC handler/discovery/schema tests; web query/render tests around empty graph and populated graph. Middle — integration test/probe evidence performs a real graph-tool write and observes `brunch.updated` notification/refetch over the public RPC surface. Outer — 2026-06-04 browser-observable `agent-browser` smoke opened a fresh selected-spec web dashboard, observed empty graph state, committed a node through the default Brunch runtime `commit_graph` tool path with the shared product-update bus, and observed the browser update without page reload. Literal keyboard-driven TUI smoke was not rerun at tie-off; `brunch-tui.test.ts` covers the TUI launch path starting the same read-only sidecar with the shared publisher. +- **Topology materialization:** `graph/` remains the read/domain owner; `session/` owns transcript-backed runtime-state projection; `rpc/` owns graph/session method handlers plus the process-local product update publisher; `web/` owns graph rendering, route loaders, Query keys, and notification invalidation; no `web/` or `.pi/` import of `db/`; no duplicate graph/runtime DTOs outside projected/read-model types. +- **Cross-cutting obligations:** Preserve D19-L thin named RPC methods, D33-L client attachment semantics, D35-L product chrome/projection discipline, D40-L transcript-backed runtime state, and D52-L source dependency direction. `brunch.updated` is an invalidation hint over transports, not canonical truth or a durable event store. Do not introduce a generic read gateway or view store. +- **Traceability:** R7, R10, R11, R12 / D5-L, D10-L, D19-L, D33-L, D40-L, D52-L, D60-L / I21-L, I25-L, I35-L / A3-L, A4-L. +- **Design docs:** `memory/SPEC.md` D19-L, D33-L, D40-L, D52-L, D60-L; `src/rpc/README.md`; `src/session/README.md`; `src/web/README.md`; `docs/design/GRAPH_MODEL.md`. ### agents-composition-layer - **Name:** Agent prompt-resource composition, runtime manifests, and snapshot contexts -- **Linear:** unassigned -- **Branch:** to create — `ln/-agents-composition-layer` +- **Linear:** [FE-806](https://linear.app/hash/issue/FE-806/agent-prompt-resource-composition-runtime-manifests-and-snapshot) +- **Branch:** to create — `ln/fe-806-agents-composition-layer` - **Kind:** structural - **Status:** next - **Objective:** Build the D58-L/D59-L/D60-L `agents/` layer so runtime state changes behavior: `agents/state.ts` legal tuples and resource manifest metadata; `agents/compose.ts` runtime header + gated manifests; Brunch-owned markdown resources for definitions/goals/strategies/lenses/methods; agent-context snapshot renderers; and migration/deletion of the old `src/.pi/context` composer. @@ -106,8 +106,8 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### capture-response-to-graph - **Name:** Structured response capture into selected-spec graph truth -- **Linear:** unassigned -- **Branch:** to create — `ln/-capture-response-to-graph` +- **Linear:** [FE-807](https://linear.app/hash/issue/FE-807/structured-response-capture-into-selected-spec-graph-truth) +- **Branch:** to create — `ln/fe-807-capture-response-to-graph` - **Kind:** structural / tracer bullet - **Status:** next - **Objective:** Prove the single-exchange path: a typed structured-exchange response is captured synchronously into high-confidence graph mutations through `CommandExecutor`, and the resulting graph change is visible through web/TUI projections. @@ -127,8 +127,8 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### graph-tool-resilience - **Name:** Broaden direct graph-tool proof beyond the A14 happy path -- **Linear:** unassigned -- **Branch:** to create — `ln/-graph-tool-resilience` +- **Linear:** [FE-808](https://linear.app/hash/issue/FE-808/broaden-direct-graph-tool-proof-beyond-the-a14-happy-path) +- **Branch:** to create — `ln/fe-808-graph-tool-resilience` - **Kind:** hardening / tracer bullet - **Status:** next - **Objective:** Extend the real `read_graph`/`commit_graph` product-path proof to cover representative failure and complexity cases: existing-node references, structural-illegal diagnostics with bounded retry, and an ambiguity/no-overcommit case. @@ -147,8 +147,8 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### project-graph-review-cycle - **Name:** Project-graph review-set proposal and atomic acceptance -- **Linear:** unassigned -- **Branch:** to create — `ln/-project-graph-review-cycle` +- **Linear:** [FE-809](https://linear.app/hash/issue/FE-809/project-graph-review-set-proposal-and-atomic-acceptance) +- **Branch:** to create — `ln/fe-809-project-graph-review-cycle` - **Kind:** structural / bounded feature - **Status:** next - **Objective:** Wire the `project-graph` strategy from real agent proposal generation through `present_review_set` / `request_review`, dry-run gating, approve/request-changes/reject response handling, and atomic `acceptReviewSet` commit. @@ -168,8 +168,8 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### minimal-authority-shell - **Name:** Minimal POC authority shell over graph/session actions -- **Linear:** unassigned -- **Branch:** to create — `ln/-minimal-authority-shell` +- **Linear:** [FE-810](https://linear.app/hash/issue/FE-810/minimal-poc-authority-shell-over-graphsession-actions) +- **Branch:** to create — `ln/fe-810-minimal-authority-shell` - **Kind:** hardening - **Status:** next - **Objective:** Fill only the authority behavior required for a credible POC: graph writes keep returning structured command results, `elicit` suppresses obvious side-effecting tools, and headless/RPC paths surface structured `needs_human` where the POC actually reaches human-only actions. @@ -188,8 +188,8 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### poc-live-ship-gate - **Name:** POC live ship gate and runbook oracle -- **Linear:** unassigned -- **Branch:** to create — `ln/-poc-live-ship-gate` +- **Linear:** [FE-811](https://linear.app/hash/issue/FE-811/poc-live-ship-gate-and-runbook-oracle) +- **Branch:** to create — `ln/fe-811-poc-live-ship-gate` - **Kind:** hardening / release gate - **Status:** next - **Objective:** Create and pass the final POC runbook that exercises the real entrypoints together: fresh cwd, multi-spec selection, TUI session, web observer, runtime switch, structured exchange, capture/commit, graph update, and probe artifacts. @@ -238,6 +238,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ## Recently Completed +- 2026-06-04 `live-graph-observer` (FE-795) — Done: `graph.overview` and `graph.nodeNeighborhood` are discoverable selected-spec RPC reads; graph readers remain in `graph/`; TUI/agent `commit_graph` publishes graph invalidation topics through the shared product-update bus; the TUI launch path starts a read-only web sidecar over the same bus; the React web app attaches over one WebSocket RPC client, renders the selected-spec graph overview, and invalidates/refetches canonical graph readers on `brunch.updated`. Verified: targeted FE-795 test set (`src/rpc/handlers.test.ts`, `src/rpc/web-host.test.ts`, `src/web/app.test.tsx`, `src/brunch-tui.test.ts`, `src/graph/snapshot.test.ts`, `src/graph/spec-ownership.test.ts`), `npm run build`, and a 2026-06-04 `agent-browser` smoke that observed empty graph state then a `commit_graph`-created node in the browser without reload. Watch: richer node-neighborhood UI remains optional polish; the current proof exposes/query-backs the focused read and renders the overview. - 2026-06-02 `agent-graph-integration` enabling slices — Done inside FE-785: runtime vocabulary fixed; source moved from `src/tui-client/.pi` to `src/.pi`; real `read_graph`/`commit_graph` Pi tools route through `CommandExecutor`; default Brunch runtime factory registers graph tools; A14 `propose-graph → commitGraph` probe persisted 4 nodes + 4 edges on first attempt; review-set dry-run gate validates/filters proposal payloads. Verified: targeted tests, `.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/`, and `npm run verify`. Watch: broad FE-785 bucket is now split into delivery frontiers above. - 2026-06-02 `spec-persistence-and-startup` — Done: specs are DB rows with integer ids and `readiness_grade`; `createSpec` / `getSpec` / `updateReadinessGrade` route through `CommandExecutor` with change-log audit; startup scaffolds `.brunch/workspace.json` + `.brunch/data.db`; session binding collapsed to `{schemaVersion,specId}` and is fork-portable; inventory resolves spec names from DB. Verified: `npm run verify` and real `brunch --mode print` against a fresh cwd. Watch: richer multi-spec initiative/claim model remains deferred by D61-L. - 2026-06-01 `graph-data-plane` (FE-741) — Done: Drizzle schema/init, graph clock seed, `CommandExecutor` result contract, one-transaction LSN/change-log skeleton, `commitGraph` atomic batch mutation, graph snapshot readers, and reconciliation-need substrate. Verified: `npm run verify`. Watch: graph is now real but must be surfaced by `live-graph-observer` and exercised by capture/review frontiers. @@ -248,7 +249,7 @@ Older history (including `sealed-pi-profile-runtime-state`, `pi-ui-extension-pat ```text nodes: - live-graph-observer [active · P0] lights up TUI-writer/web-observer graph visibility + live-graph-observer [done · P0] lights up TUI-writer/web-observer graph visibility agents-composition-layer [next · P0] makes runtime goal/strategy/lens behavior real capture-response-to-graph [next · P0] structured answer -> graph truth -> observer update graph-tool-resilience [next · P0] broadens A14 direct graph tool proof diff --git a/memory/SPEC.md b/memory/SPEC.md index b08e0de8..f27c1b4f 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -73,7 +73,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape 16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. -17. Brunch must support action, radio (single-select), checkbox (multi-select), freeform, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user-authored `comment` as additional context separate from custom/Other answers. Multi-question/questionnaire surfaces are deferred; when a complex shape is needed before a bespoke UI lands, Brunch may use just-in-time schema-tagged JSON over `ctx.ui.editor` or an equivalent product relay. In TUI mode a pending response request may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs. Brunch must be able to project elicitation exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. +17. Brunch must support action, radio (single-select), checkbox (multi-select), freeform, and freeform-plus-choice response surfaces as typed transcript-backed interactions. Every option-selection structured exchange must allow an optional user-authored `comment` as additional context separate from custom/Other answers. Multi-question/questionnaire surfaces are deferred; when a complex shape is needed before a bespoke UI lands, Brunch may use just-in-time schema-tagged JSON over `ctx.ui.editor` or an equivalent product relay. In TUI mode a pending response request may replace the default input surface with custom UI; in RPC/probe/web-relay contexts the same semantic interaction may travel through Brunch product handlers or Pi's supported extension UI dialogs. Brunch must be able to project session exchanges from Pi JSONL for post-exchange capture, including registered structured-exchange tool results whose `toolResult.details` is the self-contained structured response payload. 18. Brunch must support `#`-mentions of graph entities anchored to stable IDs, with session-scoped staleness tracking that produces discretionary re-read hints during `prepareNextTurn`. 19. Brunch must enforce a workspace state hierarchy `workspace(cwd) → spec → session`, where the workspace is only the current working directory invocation root, the user explicitly picks or creates one spec within that workspace before any agent loop runs, and then picks or creates a session within that spec. Spec selection persists across `/new`, and each session binds to exactly one spec. 20. Brunch must support multiple elicitation lenses within the `elicitor` agent role, with the agent owning lens selection and offer through transcript-native structured exchanges; lens metadata is carried on elicitor-emitted structured-exchange payload facets for downstream routing. @@ -108,7 +108,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A8-L | One reconciliation-need substrate, sharing the same global LSN as the change log, can absorb impasses, conflicts, gaps, and process debt without needing finer kind subtypes in the POC. | medium | open | D8-L | M8 + adversarial fixtures ("contradictory requirements") exercise the substrate; subtype split deferred per Open Question #10. | | A9-L | A session-scoped mention ledger of (`entity_id`, `snapshotted_lsn`) is the right granularity for staleness hints; transcript-scoped or graph-scoped ledgers are not needed for the POC. | low | open | I7-L | M7 — turn-boundary reconciliation slice; observed via fixture runs that stress re-read decisions. | | A11-L | Pi's `prepareNextTurn` plus custom-message delivery are sufficient to express side-task result delivery without inventing a second event plane or forking pi. | medium | open | D15-L | M5 + M7: side-task registry wiring and next-turn delivery proof. | -| A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and elicitation-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | +| A13-L | If Brunch later adds deferred observer/auditor jobs, a durable queue keyed by session id and session-exchange entry range can recover async audit/backfill after process interruption without reintroducing canonical chat/turn tables; whether this shares storage with a generalized work-item/reconciliation table can be deferred. | medium | open | D18-L, I14-L | Deferred until async audit/backfill lands: restart/idempotence tests exercise exchange-keyed jobs once graph writes exist. | | A14-L | LLM elicitor agents can reliably produce graph-structurally-legal graph mutations — both `commitGraph` batches (D53-L) and review-set proposals (D27-L) — as well-formed entity drafts and category-typed edge drafts per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md) that pass `CommandExecutor` structural validation. The `commitGraph` path under `propose-graph` strategy (D26-L) is the primary proof target: the agent must produce valid multi-node multi-edge batches with intra-batch references from a graph-vocabulary prompt after concept-level user acceptance. | medium | partially validated | D27-L, D51-L, D53-L | **CommitGraph subclaim validated 2026-06-02** by the product-path `propose-graph-commit` probe: the default Brunch runtime factory registered real `read_graph`/`commit_graph` tools, `claude-opus-4-7` produced one structurally legal `commit_graph` batch on the first attempt, and `CommandExecutor` persisted 4 nodes + 4 edges (LSN 2). Artifacts: `.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/`. **Review-set dry-run substrate validated 2026-06-02** by `review-set-proposal.test.ts`: reviewable proposals must carry lens/epistemic/grounding metadata, translate to `commitGraph` input, pass `CommandExecutor.dryRunCommitGraph`, and stay off the review surface when structurally illegal. Remaining open subclaim: real LLM `project-graph` proposal generation against that substrate. Fallback options remain constrained generation, retry-with-feedback, or NL-parse-at-accept if later legality rates regress. | | A15-L | Establishment hints carried as structured-exchange payload facets provide sufficient inspectability, fixture-ability, and ambient-affordance source without a separate establishment-needs graph substrate or standalone `brunch.establishment_offer` entry family; whether such a substrate ever shares storage with reconciliation needs can be deferred. | medium | open | D25-L, D30-L | M5+: fixture inspection confirms establishment-offer facets are reconstructable from transcript-backed structured exchanges; chrome/web orientation regions render ambient affordances from the latest such facet. | | A16-L | Reviewer triggering policy (always-on vs lens-keyed) and reviewer scope (batch + how-far-neighborhood) can be deferred to per-lens decisions without architectural commitment now. | low | open | D29-L | M5+: empirical — reviewer integration reveals which policy avoids unacceptable next-turn latency without losing relevant findings. | @@ -126,10 +126,10 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D1-L — Depend on `pi-coding-agent`, not only `pi-agent-core`.** The POC reuses the coding-agent service bundle, TUI/print adapters, RPC machinery, session logging, and tool plumbing. Dropping down to `pi-agent-core` is a fallback if Brunch proves too different. Depends on: A1-L. Supersedes: —. - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns a sealed Pi Profile around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. The profile includes settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. Current known posture routes TUI launch policy through `src/brunch-pi-profile.ts`, creates an in-memory Brunch-owned `SettingsManager` policy instead of reading ambient global/project `.pi/settings.json`, disables ambient context files, extensions, prompt templates, skills, and themes while loading Brunch's inline extension shell, and defaults Brunch-launched Pi to offline mode; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. Brunch-owned Pi extensions are loaded by an explicit product shell (`src/.pi/pi-extension-shell.ts`) rather than ambient discovery; *explicit* means the shell statically imports its product extensions and registers them from a fixed ordered list — it must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the shell wires those dependencies at the call site; the `default` exports under `src/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/.pi/extensions/*`, and reusable Pi TUI components live under `src/.pi/components/*`, so they can also be iterated by launching Pi from `src/` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; extension/component tests live under `src/.pi/__tests__/`. The profile boundary now owns the audited behavior-shaping settings list in code (`BRUNCH_SETTINGS_POLICY` / `BRUNCH_SETTINGS_AUDITED_GETTERS`), with hostile ambient settings and reload-resilience tests covering shell path/prefix, npm command, ambient resources, skill commands, double-escape behavior, compaction/retry, image/terminal/UI, transport/theme/changelog, and telemetry settings. Remaining profile work is runtime-state/prompt/tool posture, not ambient settings file leakage. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/.pi/extensions/brunch/`, or replacing the explicit static shell list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. -- **D40-L — Runtime state is a transcript-backed Brunch session-agent record, not hidden extension memory.** Brunch projects one session-agent record from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation. Its axes are `op_mode` (`elicit`, future `execute`) plus the optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L); a deferred `world_view` watermark group (last-seen LSN, graph mentions, optional git head) is reserved for M7. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, owned by `src/.pi/extensions/operational-mode.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception). Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. +- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the pure projection. It reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/operational-mode.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned state definitions. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/command-policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state snapshot rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the real activated session id, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are likewise product projections over Pi session metadata: Brunch may append Pi `session_info` entries, but generated names must characterize the selected spec/session transcript rather than replace spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, or consuming the status-key namespace for chrome's own static summary. -- **D52-L — Source topology is `src/{.pi, agents, db, graph, session, rpc, web}` with directed layer dependencies.** `graph/` is the domain layer: CommandExecutor, readers, policy, validators, snapshot bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, and LSN staleness tracking over Pi JSONL. `agents/` is organized by registry/resource family (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`) and imports snapshot functions from `graph/` and `session/`; it owns prompt composition, context building, prompt resources, and the state definitions that drive `op_mode`/goal/strategy/lens selection. `.pi/extensions/` houses Pi adapter registrars (agent tools, TUI commands, TUI enhancements); `.pi/components/` houses reusable TUI components. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/extensions/` and `rpc/` may import from `graph/`, `session/`, and `agents/`; `agents/` imports from `graph/` and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; nesting prompt composition exclusively under `src/.pi/context/`. +- **D52-L — Source topology is `src/{.pi, agents, db, graph, session, rpc, web}` with directed layer dependencies.** `graph/` is the domain layer: CommandExecutor, readers, policy, validators, snapshot bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state projection, and LSN staleness tracking over Pi JSONL. `agents/` is organized by registry/resource family (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`) and imports snapshot/state projection functions from `graph/` and `session/`; it owns prompt composition, context building, prompt resources, and future agent registry definitions that drive `op_mode`/goal/strategy/lens selection. `.pi/extensions/` houses Pi adapter registrars (agent tools, TUI commands, TUI enhancements) and may re-export/use session-owned runtime-state helpers; `.pi/components/` houses reusable TUI components. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/extensions/` and `rpc/` may import from `graph/`, `session/`, and `agents/`; `agents/` imports from `graph/` and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; nesting prompt composition exclusively under `src/.pi/context/`. #### Data model & vocabulary @@ -156,7 +156,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D5-L — Brunch JSON-RPC is the single public product protocol.** Brunch exposes one public product RPC surface over stdio, WebSocket, and in-process handlers. Product clients — web UI, CLI probes, TUI adapters, and future relays — call Brunch method families and should not coordinate raw Pi RPC plus Brunch product RPC themselves. Pi RPC may be used behind a Brunch adapter for agent-loop mechanics and Pi extension UI, but it is not a second public product API. HTTP exists only as a transport shim (static bundle, health, uploads, webhooks). The Brunch stdio surface is also the agent-as-user probe driver interface, even when that driver internally relays Pi RPC events. Depends on: A5-L. Supersedes: treating raw Pi RPC as the product API for Brunch data. - **D10-L — Web client is a native Brunch React app over one WebSocket RPC client.** TanStack Router + TanStack Query + Brunch-owned elicitation/transcript primitives (Vercel AI SDK UI or TanStack AI style). `pi-web-ui` is not reused. The browser is a thin remote head over Brunch RPC method families, not a second product runtime or REST-backed data client. Depends on: D5-L. Supersedes: —. - **D17-L — Brunch semantics ride one transcript/event substrate, not parallel channels.** Pi JSONL transcript entries — ordinary messages, assistant tool-call/toolResult exchanges, and custom messages/entries — plus `deliverAs: "nextTurn" | "followUp"` and `prepareNextTurn` are the load-bearing mechanism for structured elicitation prompts/responses, `worldUpdate`, mention-staleness hints, and side-task-result delivery. New product semantics should compose onto this substrate before inventing a second event plane or a parallel chat/turn store. Depends on: D5-L, D6-L, D12-L, D15-L. Supersedes: custom-message-only interpretations of structured elicitation. -- **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes 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.snapshot`, `workspace.selectionState`, `workspace.activate`; `session.promptExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.submitMessage`, and `session.exchanges`; future graph projections such as `graph.overview`, `graph.nodeNeighborhood`, `graph.changesSince`, and graph-adjacent `graph.coherenceSummary`. 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. Subscriptions are first-class and may provide initial state plus updates, and adapter-only agent/UI events may be relayed into product-shaped notifications, but Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Proof-era names such as `session.startElicitation`, `elicitation.respond`, `session.elicitationExchanges`, and `session.transcriptDisplay` are implementation debt, not target vocabulary. 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 two-public-RPC-surface split. +- **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.snapshot`, `workspace.selectionState`, `workspace.activate`; current selected-spec graph reads `graph.overview` and `graph.nodeNeighborhood`; and session methods `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`, and `session.runtimeState`. Reserved future target names such as `session.submitMessage`, `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. 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 a snapshot of product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/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. @@ -200,14 +200,14 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ``` - **D48-L — Brunch owns public RPC method discovery.** `rpc.discover` is the product-level discovery method for Brunch JSON-RPC. It returns Brunch method names, descriptions, parameter schemas, result schemas, and compact examples for the public surface that the current host supports. Schemas are JSON-Schema-shaped per D41-L, regardless of whether their source authoring library is Zod or TypeBox; discovery is not a promise to expose every internal handler or every raw Pi RPC command. Pi `get_commands` remains slash-command/prompt-template/skill discovery for Pi's `prompt` command and must not be treated as Brunch method discovery. Depends on: D5-L, D19-L, D41-L. Supersedes: hardcoded private probe knowledge and any plan to copy Pi's non-JSON-RPC command union as Brunch's protocol shape. -- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The stable public lifecycle is session-native, not elicitation-mode-native: `session.promptExchange` starts, resumes, or advances the assistant-first loop until there is a pending exchange, idle/completed state, `needs_human`, or blocker; `session.pendingExchange` reads the current unresolved structured exchange without advancing the loop; `session.submitExchangeResponse` submits exactly one terminal response for a pending `request_*` tool shape (`request_answer`, `request_choice`, `request_choices`, `request_review`, or future variants); `session.submitMessage` handles ordinary non-exchange user text or explicit interruptions without silently answering a pending exchange; and `session.exchanges` projects structured exchange history from transcript truth. `session.transcriptDisplay` is a debug/print/diagnostic projection, not a core web-product state API. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but public clients speak the Brunch methods named in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md). Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly; the proof-era public names `session.startElicitation`, `elicitation.respond`, and `session.elicitationExchanges`. +- **D49-L — Pending structured exchange lifecycle is Brunch-owned over public RPC.** The stable public lifecycle is session-native, not elicitation-mode-native: `session.triggerExchange` starts, resumes, or advances the assistant-first loop until there is a pending exchange, idle/completed state, `needs_human`, or blocker; `session.pendingExchange` reads the current unresolved structured exchange without advancing the loop; `session.submitExchangeResponse` submits exactly one terminal response for a pending `request_*` tool shape (`request_answer`, `request_choice`, `request_choices`, `request_review`, or future variants); future `session.submitMessage` will handle ordinary non-exchange user text or explicit interruptions without silently answering a pending exchange when real behavior is scoped; and `session.exchanges` projects structured exchange history from transcript truth. The retired transcript-display projection is not part of the product web sidecar surface; any future transcript/debug projection must be explicitly diagnostic-only and absent from read-only product discovery unless rescoped. The implementation may delegate internally to Pi RPC/editor fallback or in-process structured-exchange handlers, but public clients speak the Brunch methods named in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md). Polling these methods is sufficient for the first proof; subscriptions stay required by R12 but are not prerequisite for the initial deterministic permutation parity run. Depends on: D5-L, D12-L, D19-L, D33-L, D37-L, D38-L, D48-L. Supersedes: command-first probes where the client sends a raw Pi slash command and answers `extension_ui_request(editor)` directly; retired proof-era public session/elicitation method names. #### Persistence - **D6-L — JSONL-first transcript persistence in `.brunch/sessions/`; SQLite-backed graph persistence in `.brunch/`.** Two durability surfaces with distinct responsibilities. Transcript starts on pi `SessionManager` redirected to the project-local directory; graph plane is SQLite from M4. Brunch does not recreate canonical `chat` or `turn` tables while Pi JSONL remains viable for Brunch-supported linear sessions. Validated by M2. Supersedes: —. - **D15-L — Side tasks are a first-class Brunch subsystem delivered through the same transcript/event substrate.** Side tasks are main-agent-invoked, non-blocking work items: the main agent fires them and continues without awaiting a return value. A Brunch-owned `SideTaskRegistry` tracks status; the only path a side task influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary through the existing `prepareNextTurn` path — never mid-turn. Side-task writes remain subject to the same command-layer authority as primary-agent writes. This is distinct from D44-L Subagent (main-agent-invoked **blocking** tool call whose result is returned directly as tool content). Depends on: A11-L, D4-L. Supersedes: —. - **D16-L — Graph persistence uses Drizzle over `better-sqlite3`, with one-LSN-per-commit and no bypass paths.** The command layer owns precondition checks, structural validation, entity writes, LSN allocation, change-log append, and any coherence updates inside one transaction. This rule applies equally to migrations and maintenance code; there is no privileged write path outside the command-executor protocol. Runtime row/insert/update schemas are derived from Drizzle table definitions through `drizzle-typebox` (`createInsertSchema`, `createSelectSchema`) rather than hand-authored alongside the table. **Settled by A20-L spike (2026-06-01):** `drizzle-orm@0.45.2` + `drizzle-kit@0.31.10` + `better-sqlite3@12.8.0` + `drizzle-typebox@0.3.3` + `@sinclair/typebox@0.34.14`. Pi tool parameter schemas use `typebox` v1.x (Pi's package) separately; Drizzle-derived row schemas stay internal to `db/`→`graph/`; shared enum `const` arrays bridge both. Depends on: A3-L, A4-L, A20-L (validated). Refined by: D41-L. Supersedes: —. -- **D18-L — Post-exchange capture is synchronous elicitor work for the POC; observer/auditor queues are deferred backstops, not primary extraction authority.** After a user response closes an elicitation exchange, the elicitor may run a post-exchange capture step in the same turn-boundary flow: commit high-confidence extractive facts, concrete reconciliation needs, and justified spec-readiness updates through the `CommandExecutor`; fold low-confidence implications into later questions rather than graph truth. Brunch may still introduce durable observer/auditor jobs keyed by session id plus exchange entry ids for restartable audit, quality checks, or later backfill, but those jobs are not the load-bearing path for keeping the next turn's world fresh. Any async job writes still route through the command layer and remain operational queue state unless they surface semantic work as reconciliation needs. Depends on: A13-L, A22-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model and the earlier observer-owned primary extraction path. +- **D18-L — Post-exchange capture is synchronous elicitor work for the POC; observer/auditor queues are deferred backstops, not primary extraction authority.** After a user response closes a session exchange, the elicitor may run a post-exchange capture step in the same turn-boundary flow: commit high-confidence extractive facts, concrete reconciliation needs, and justified spec-readiness updates through the `CommandExecutor`; fold low-confidence implications into later questions rather than graph truth. Brunch may still introduce durable observer/auditor jobs keyed by session id plus exchange entry ids for restartable audit, quality checks, or later backfill, but those jobs are not the load-bearing path for keeping the next turn's world fresh. Any async job writes still route through the command layer and remain operational queue state unless they surface semantic work as reconciliation needs. Depends on: A13-L, A22-L, D4-L, D13-L, D16-L. Supersedes: the old DB-backed `chat` / `turn` mental model and the earlier observer-owned primary extraction path. - **D28-L — Regenerated review-set proposals are appended as successor `present_review_set` toolResult payloads in the linear Pi JSONL session; projection helpers filter to the accepted set for context economy.** When the user requests changes, the agent appends a successor structured-exchange proposal payload that references its predecessor via `supersedes`; prior proposal payloads are *not* deleted from JSONL but remain visible as raw transcript history. This stays within Brunch's linear transcript policy — no Pi branching is created. Pi JSONL is treated as a "capture everything" store for replay and audit. Projection helpers used to drive the agent (context injection, summarization) walk the `supersedes` chain and surface only the latest (or ultimately accepted) proposal — the agent does not re-process every superseded proposal as live context. The reviewer likewise sees only the accepted set, not the regeneration history. Depends on: D6-L, D12-L, D17-L, D24-L, D27-L. Supersedes: any "in-place edit" or "fork-on-regenerate" mental model, and the retired standalone `brunch.review_set_proposal` entry family. - **D29-L — Reviewer is an async advisory role with narrow write authority.** After a batch acceptance closes, Brunch may enqueue a reviewer job keyed by session id plus the batch-acceptance entry id; the job survives process restart and analyzes the accepted batch plus its graph neighborhood for coherence, completeness, and gaps. **Reviewer writes only `reconciliation_need` records via the `CommandExecutor`**; it never writes graph entities, edges, change-log entries directly, or any other record class. Findings reach the user through next-turn delivery as advisory items on the reconciliation-need surface — the batch acceptance remains the user's atomic commitment and the reviewer cannot amend it. (Suggestion-shaped findings may later route to candidate-artefacts when that substrate exists; the POC routes everything to reconciliation needs.) Depends on: A16-L, D4-L, D8-L, D15-L, D17-L, D18-L, D20-L, D27-L. Supersedes: any "reviewer may quietly amend the graph" mental model. - **D24-L — Brunch POC enforces a linear transcript policy over Pi JSONL.** Pi's session tree is a substrate capability, not a Brunch product surface. Until branch-aware continuity/coherence is explicitly designed, Brunch-controlled interactive/runtime flows block `/tree`, `/fork`, and `/clone` through the thinnest available Pi hooks; transcript readers reject non-linear session files instead of flattening, adapting, migrating, or selecting a branch. This is intentional fail-fast pre-release posture: avoid compatibility debt with Pi internals or earlier Brunch revisions, and keep wrapper/adapter layers minimal. Depends on: D6-L, D11-L, D13-L. Supersedes: treating active-branch projection as Brunch product semantics. @@ -225,7 +225,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D12-L — Elicitation-first interaction, transcript-native structured prompts.** Brunch treats system/assistant prompts and user responses as Pi transcript truth. Structured action/choice/freeform surfaces are preferably represented by registered structured-exchange `present_*` / `request_*` toolResult families when durable structure is needed; there is no DB-owned prompt/response entity. At idle, the session waits on a system/assistant-originated elicitation prompt. Depends on: D6-L, D11-L, D37-L. Supersedes: standalone custom-entry carriers as the default structured interaction shape. - **D37-L — Structured elicitation is Pi-transcript-native; structured exchanges use durable toolResult families.** A system/assistant-originated structured interaction should use the thinnest Pi-supported transcript seam for its shape. The preferred Brunch seam is registered Pi tool results: `present_*` tools persist and display assistant-originated offer/question/proposal material, `request_*` tools collect and persist the user response, and future `capture_*` tools persist assistant analysis of candidate semantic changes without mutating graph truth. The assistant `toolCall` supplies call identity and arguments, but durable semantic display is the `toolResult` row rendered by that tool's `renderResult`; `renderCall` is transient header/progress only and must not carry Brunch semantic display. `toolResult.content` is rich markdown that is both transcript display content and model-readable context; `toolResult.details` is the structured projection/recovery payload. The landed Zod-authored target details model under `src/.pi/extensions/structured-exchange/schemas/` uses checked `schema` + `v` discriminants, `exchange_id`, compact `tool_meta` sequence/sibling metadata, exactly-one request outcome presence (`answered` | `cancelled` | `unavailable`), user-authored `comment` versus runtime-authored `message`, strict `present_candidates` rubrics/`graph_refs`, and intentionally minimal no-graph `capture_*` details. Runtime tools/projections still use the existing tuple details model until a deliberate migration slice rewires them to these exports. Implemented present/request tools use `executionMode: "sequential"`; FE-744's real Pi RPC ordering proof validates that same-assistant-message `present_options → request_choice` persists the present `toolResult` before the request `toolResult` and emits the present `tool_execution_end` before the request UI opens, and the public Brunch RPC parity proof drives the current deterministic tuple-shaped permutation set over product methods only. RPC event consumers should not assume `request_*` `tool_execution_start` precedes its extension UI request, because Pi may emit the UI request first. Standalone Brunch custom entries remain valid for genuinely non-exchange session facts such as `brunch.session_binding`, `brunch.agent_runtime_state`, lens switches, side-task results, and mention/world-update delivery; they are not the default carrier for establishment offers, review-set proposals, intent hints, or structured response surfaces. RPC/web paths answer the same semantic pending interaction through Brunch product handlers or Pi-supported dialog fallbacks rather than depending on TUI-only `ctx.ui.custom()`. Depends on: D12-L, D13-L, D17-L, D19-L, D38-L, D41-L. Supersedes: treating all structured offers as Brunch custom entries, treating render lifecycle state as durable transcript state, relying on ephemeral dialog results detached from transcript truth, modeling a structured exchange as one split-brain tool row whose present half lives in `renderCall`, or treating the retired scope-card contract as canonical after the schema README and tests have landed. - **D38-L — JSON-over-editor is the Pi-RPC compatibility seam for complex extension UI, not a second product API.** Pi RPC supports `ctx.ui.select`, `confirm`, `input`, and `editor`, but not `ctx.ui.custom()`. When a structured-exchange tool needs a complex shape (multi-select, review-style response, or a deferred multi-question/questionnaire shape) over raw Pi RPC, the tool may call `ctx.ui.editor()` with schema-tagged JSON prefill and validate the returned JSON before producing normal `toolResult.content` plus self-contained `toolResult.details`. A Brunch-aware adapter may render that JSON as a native product form and translate the user response back into Pi's documented `extension_ui_response`; public clients still speak Brunch RPC methods/events, not ad hoc raw Pi RPC extensions. Depends on: D5-L, D19-L, D33-L, D37-L. Supersedes: inventing unsupported Pi RPC command types for Brunch interactions or exposing raw editor JSON as the product UX. -- **D13-L — Capture-aware elicitation exchange projection.** Post-exchange capture consumes derived elicitation exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text and/or terminal structured-exchange `request_*` toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. +- **D13-L — Capture-aware session exchange projection.** Post-exchange capture consumes derived session exchanges: a prompt-side span (system/assistant/tool-side entries since the previous response, including structured/internal prompt content) plus a response-side span (user text and/or terminal structured-exchange `request_*` toolResults whose `details` encode the answer). Role/span alternation is the default projection in Brunch-supported linear sessions, but typed structured-exchange results override the naive "all toolResults are prompt side" rule where needed for deterministic replay. Depends on: D12-L, D24-L, D37-L. Supersedes: treating Pi message role alone as sufficient to classify structured elicitation response spans. - **D14-L — `#`-mentions are stable-handle text references resolved by Brunch, with a session-scoped mention ledger.** Pi autocomplete persists only the inserted `AutocompleteItem.value` as ordinary transcript text; popup labels/descriptions are UI-only. Brunch autocomplete may search by title/description, but insertion must rewrite to a stable handle (`#A12`, `#I7`, or equivalent node handle) that Brunch can resolve to the graph entity id through a read-only lookup/re-read tool when the agent needs detail. Brunch prompt injection (`before_agent_start`) teaches agents how to interpret the handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. Per-session `(entity_id, snapshotted_lsn)` ledger drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata. - **D25-L — Strategy and lens are two orthogonal session-agent axes within the `elicitor` role, not separate roles or operational modes.** *Strategies* describe interaction shape (`step-wise-decision-tree`, `step-wise-disambiguate`, `propose-graph`, `project-graph`); *lenses* describe topical focus (`intent`, `design`, `oracle`; future execute-mode `plan`, `sync`, `scope`). Both are optional, AUTO-able fields of the projected session-agent record (D40-L) and are stamped onto structured-exchange payload facets (for example establishment offers, intent hints, and review/proposal material) when those facets need downstream routing; capture/reviewer/audit routing may filter on lens. Strategy determines the commitment mechanism (D26-L); the catalogue is expected to grow. Depends on: D23-L, D40-L. Supersedes: lens-as-role, strategy-as-mode, and standalone elicitor-intent/establishment/review custom-entry families as the default carrier. - **D26-L — Elicitation flows split by capture and commitment mechanism, not by a hard extractive/generative phase boundary.** Three commitment mechanisms: (1) Single-exchange flows (`step-wise-decision-tree`, `step-wise-disambiguate`, and ordinary structured questions) are captured synchronously by the elicitor post-exchange per D18-L. (2) Review-set flows (`project-graph` strategy) carry structured entity-draft payloads at proposal time and become durable only through review-set approval (D27-L). (3) Direct-commit flows (`propose-graph` strategy) present a concept to the user via structured exchange with rubric axes, choices, and a recommendation; when the user accepts a concept, the agent autonomously generates and persists the full subgraph through `commitGraph` (D53-L) without intermediate entity-level user review — the user accepts a concept, not a graph shape. Design/oracle lenses may appear during ordinary elicitation; commitment (`commit-converge` goal and active review-set state, D59-L) changes what can be pinned, not what topics may be explored. Depends on: D18-L, D25-L, D45-L, D53-L. Supersedes: a single uniform "agent asks questions" mental model, the observer-owned extractive vs elicitor-owned generative split as the primary architecture, and assuming all batch-graph writes require review-set approval. @@ -242,7 +242,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c This division mirrors the batch-proposal flow in D26-L: `propose-graph` and `project-graph` strategies can delegate variant generation to fan-out `proposer` invocations while `intent` / `design` / `oracle` lenses frame the proposal subject; purely extractive single-exchange work may stay main-agent-only. Worker-style write-capable subagents are deferred until an execute operational mode lands. Cross-extension agent registration (Amos's `globalThis.__pi_subagents` bridge) is deferred because it conflicts with profile sealing; the POC registry is Brunch-owned only. NDJSON stream events from the subprocess drive TUI tool-progress UI; a `subagent.progress` RPC subscription for headless/web is deferred. Subagents are an optional enhancement to candidate-proposal diversity, not a load-bearing M0–M9 substrate: they enhance R20/D27-L proposal generation when bandwidth permits. Depends on: D2-L, D26-L, D27-L, D30-L, D31-L, D39-L, D41-L. Distinct from: D15-L Side task (non-blocking, status-via-custom-message), the deferred Side chat (user-invoked overlay; see Future Direction Register). Supersedes: —. - **D36-L — Spec/session selection is a reusable hierarchical decision model with transport-specific presentations.** Brunch owns a pure spec/session selection model that renders cwd-scoped inventory without calling the user-created object a “workspace”. In TUI mode, the model may present a fast “continue last session” affordance when `.brunch/workspace.json` points to a valid spec+session; otherwise, or after “other spec/session”, the durable tree is: `create new spec → provide spec name → session created automatically`; `resume existing spec → choose existing spec → create a new session OR resume existing session → choose existing session`. The UI should not list every spec as a top-level action label; “resume existing spec” is the top-level intent, and the spec list is the next screen/scrollable selector. The model returns a product decision (`new spec`, `new session for spec`, `open session`, `continue selected session`, `cancel/quit`) without opening Pi sessions or mutating `.brunch/workspace.json` itself. The `WorkspaceSessionCoordinator` activates that decision and owns all persistence/session-binding effects. TUI startup and in-session paths share branded `pi-tui` components and colocated logo assets under `src/.pi/components/workspace-dialog`; adapters differ only in terminal lifecycle and Pi session-replacement mechanics (`ProcessTerminal`/`TUI.showOverlay` before Pi starts, `ctx.ui.custom(..., { overlay: true })` inside Pi), not in product semantics. RPC/headless transports must not invoke the TUI picker; they expose the same initial-selection requirement and activation decisions as JSON-RPC/product results so CLI JSON-RPC clients can select or create spec/session correctly. Depends on: D11-L, D21-L, D24-L, D33-L. Supersedes: implicit resume of `.brunch/workspace.json` on TUI launch, Pi `/resume`/`/new` as Brunch's product session chooser, one-off startup-only picker implementations, a flat action list that says “workspace” for specs, top-level `resume spec X` labels, and a separate intermediate action chooser for switching. - **D42-L — Session naming is a lifecycle side task over Pi `session_info`, not spec identity.** Brunch should use Pi session lifecycle hooks to opportunistically generate a short human-readable session name that characterizes what happened in the transcript. The preferred trigger is `session_shutdown` for `quit`, `new`, and `resume` replacements because it sees the just-finished transcript and can name it before later picker lists need to distinguish sessions; `session_before_compact` or post-compaction (`session_compact`) may be used to refresh names after major summarization, and a manual command can force regeneration for debugging. The naming call should mirror the model-selection pattern in the local `summarize.ts` extension example: choose a cheap/fast authorized model, extract user/assistant text plus salient tool calls from the current branch, ask for a concise title, and append a Pi `session_info` entry through `SessionManager.appendSessionInfo`. Naming must be best-effort and non-blocking with a tight budget: failures, missing auth, empty transcripts, or shutdown aborts leave the session unnamed rather than blocking session replacement or exit. Generated names label sessions in pickers and chrome, but do not affect spec ids, session bindings, graph truth, or replay semantics. Depends on: D6-L, D17-L, D21-L, D35-L. Supersedes: using spec title or session UUID alone as the only durable display label once transcripts have meaningful content. -- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `agents/compose(agentId, sessionState, spec, workspace, snapshots)` runs before Pi provider requests through Brunch's prompt extension and emits: **(1) agent control header** — keyed agent identity, model/thinking expectation, foreground role derived from `op_mode`, and mode/tool-authority summary; **(2) runtime-state header** — current pinned/AUTO `goal`, `strategy`, and `lens`, `spec.readiness_grade`, and workspace posture; **(3) resource manifests** — XML-style ``, ``, ``, and `` entries filtered by `agents/state.ts` legal tuples, grade, `op_mode`, and the agent allow-list, each carrying `{name, description, location}` for a Brunch-owned markdown resource under `src/agents/`; the `{name, description, location}` triples are code-owned in `agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal snapshot summary/handles needed to orient the turn, with detailed snapshot content still governed by D60-L. Detailed goal/strategy/lens/method instructions live in Brunch prompt resources and are loaded by the agent with `read` when needed, following the same simple mechanism Pi uses for skills. `AUTO` means the axis is unpinned: the manifest lists legal choices and router instructions tell the agent to choose only from the current manifest, reading the selected resource before applying it when detail matters. Pinned axes point to the pinned resource; code enforces legality and tool gating but does not choose or concatenate large semantic packs on the agent's behalf. Pi-native skills may still carry startup-scoped capabilities, but runtime-state-gated availability is Brunch's manifest, not ambient Pi discovery. `agents/` is a keyed resource registry (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`); `agents/contexts/` is the D60-L snapshot render layer (code), not a manifest resource family; composition is projection, not a behavioral state machine. Depends on: D23-L, D25-L, D39-L, D40-L, D52-L, D59-L, D60-L. Supersedes: the flat "base + mode + role + strategy + lens + grade + …" layering; the fixed all-packs concatenation in `compose-brunch-prompt.ts`; "role preset / runtime bundle" as the composition unit; direct Layer-2 eager prompt-pack injection as the default mechanism; and `capability` as a parallel name for `method` / ``. +- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `agents/compose(agentId, sessionState, spec, workspace, snapshots)` runs before Pi provider requests through Brunch's prompt extension and emits: **(1) agent control header** — keyed agent identity, model/thinking expectation, foreground role derived from `op_mode`, and mode/tool-authority summary; **(2) runtime-state header** — current pinned/AUTO `goal`, `strategy`, and `lens`, `spec.readiness_grade`, and workspace posture; **(3) resource manifests** — XML-style ``, ``, ``, and `` entries filtered by `agents/state.ts` legal tuples, grade, `op_mode`, and the agent allow-list, each carrying `{name, description, location}` for a Brunch-owned markdown resource under `src/agents/`; the `{name, description, location}` triples are code-owned in `agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal snapshot summary/handles needed to orient the turn, with detailed snapshot content still governed by D60-L. Detailed goal/strategy/lens/method instructions live in Brunch prompt resources and are loaded by the agent with `read` when needed, following the same simple mechanism Pi uses for skills. Method resources are the prompt-level home for Brunch tool-routing and sequencing guidance; tool definitions remain boundary schemas/execution hooks, not the whole Brunch guide to when or how tools should be composed. `AUTO` means the axis is unpinned: the manifest lists legal choices and router instructions tell the agent to choose only from the current manifest, reading the selected resource before applying it when detail matters. Pinned axes point to the pinned resource; code enforces legality and tool gating but does not choose or concatenate large semantic packs on the agent's behalf. Pi-native skills may still carry startup-scoped capabilities, but runtime-state-gated availability is Brunch's manifest, not ambient Pi discovery. `agents/` is a keyed resource registry (`definitions/`, `goals/`, `strategies/`, `lenses/`, `methods/`, `contexts/`); `agents/contexts/` is the D60-L snapshot render layer (code), not a manifest resource family; composition is projection, not a behavioral state machine. Depends on: D23-L, D25-L, D39-L, D40-L, D52-L, D59-L, D60-L. Supersedes: the flat "base + mode + role + strategy + lens + grade + …" layering; the fixed all-packs concatenation in `compose-brunch-prompt.ts`; "role preset / runtime bundle" as the composition unit; direct Layer-2 eager prompt-pack injection as the default mechanism; and `capability` as a parallel name for `method` / ``. - **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. `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. - **D60-L — "Snapshot" splits into pull / render / surface, and names two distinct subjects.** **Agent-context snapshot** = content the agent reasons over: `cwd` (filesystem kickoff heuristic — `.brunch?`, session count/length, README/markdown sizes, file counts), `graph` (overview), `node` (variable-hop neighborhood). **PULL** is typed, read-only data access owned by the data layer (`graph/snapshot.ts` for graph/node; `session/` for cwd) and bypasses `CommandExecutor` (reads only); the typed value *is* the JSON form. **RENDER** turns the typed value into either an LLM-friendly string (owned solely by `agents/contexts/`, scaled by lens-plane and grade-depth) or JSON (trivial serialization). **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (thin `snapshot-*` Pi tools wrap the renderer — markdown in `toolResult.content`, typed JSON in `toolResult.details` per I33-L), or *rpc/ui*. The separate **workspace projection** (`workspace.snapshot` — workspace/session/spec/chrome product state) is a different subject and keeps that name; reserve "snapshot" for the agent-context family. Depends on: D35-L, D52-L, D53-L. Supersedes: pre-rendering snapshots to strings in the pull layer, and scattering snapshot build logic across `graph/`, `agents/contexts/`, and the `snapshot-*` tool stubs. @@ -259,29 +259,29 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I7-L | ~~Every `framing_as` value belongs to the allowed matrix for that node's base kind.~~ **Retired.** `framing_as` absorbed by D54-L/D56-L node kinds; no node carries a `framing_as` field. | — | D7-L (retired) | | I8-L | Spec selection persists across pi `switchSession` (i.e. `/new`); the selected session file is reopened consistently by headless projection/capture paths; each session has exactly one `brunch.session_binding`, and a session's bound spec never changes. | partially covered (M0 coordinator/TUI boot integration tests + store-only probe checker; M1 no-injected-coordinator capture regression; M2 coordinator-created JSONL reload tests; manual TUI smoke still planned) | D11-L, D21-L | | I9-L | Every `brunch.mention` payload resolves a transcript `#` handle to a stable graph entity id; the ledger never stores title-anchored references or relies on autocomplete popup metadata. | planned (M7 invariant) | D14-L | -| I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported elicitation exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | +| I10-L | Structured elicitation prompts/responses live in the Pi transcript when structure is needed; Brunch-supported session exchanges are projected only from linear coordinator-bound sessions, and no parallel canonical chat/turn table carries elicitation state. | covered for projection shape and current read surfaces (M1 exchange projection tests, M2 JSONL/RPC projection tests, M3 canonical Brunch session-envelope validation and explicit custom-entry classifiers) | D12-L, D13-L, D18-L, D24-L | | I11-L | No durable graph mutation path — including migrations, maintenance scripts, elicitor-capture writes, deferred observer/auditor writes, or side-task-attributed writes — may bypass the `CommandExecutor` path that performs authority/result classification, version checks, structural validation, transaction execution, LSN allocation, and change-log append. | planned (M4 architectural + migration invariants; M5 caller-boundary tests) | D4-L, D15-L, D16-L, D20-L | | I12-L | Side-task results are delivered only at turn boundaries; no side-task result may steer or mutate the active turn outside the next-turn delivery path. | planned (M7 side-task delivery invariant) | D15-L | | I13-L | At any idle linear session leaf, the latest unresolved interaction state is system/assistant-originated: user input is a response to an elicitation prompt, not ambient chat. | partially covered (structured-exchange pending/respond projection tests and FE-744 public-RPC parity probe; richer idle-state probes still planned) | D12-L, D24-L | -| I14-L | If Brunch introduces deferred observer/auditor jobs, they are keyed by session id plus elicitation-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate jobs for the same exchange, and job writes never become the primary freshness path for the next elicitor turn. | deferred/planned only if observer-audit queue lands (M5+ restart/idempotence tests) | D18-L, D4-L | +| I14-L | If Brunch introduces deferred observer/auditor jobs, they are keyed by session id plus session-exchange entry-range ids and have durable status; replay/restart cannot enqueue duplicate jobs for the same exchange, and job writes never become the primary freshness path for the next elicitor turn. | deferred/planned only if observer-audit queue lands (M5+ restart/idempotence tests) | D18-L, D4-L | | I15-L | Every review-set acceptance routes through `CommandExecutor` as one atomic `acceptReviewSet` command producing one LSN, one change-log entry, and one transaction over the entire batch. Partial acceptance is not representable through any product API. | planned (M5+ batch-acceptance command tests; review-set fixture parity) | D20-L, D27-L; I1-L, I11-L | | I16-L | Reviewer-attributed writes target only the `reconciliation_need` substrate; no reviewer-attributed `CommandExecutor` call writes graph entities, edges, change-log entries directly, or any other record class. | planned (M5+ architectural test on reviewer command writers; reviewer-attributed command-result audit) | D29-L; I2-L, I11-L | | I17-L | Every batch-proposal or review-set structured-exchange payload declares an `epistemic_status` (`inferred | assumed | asserted | observed`) and enough grounding/support coverage to justify that status at proposal time; UI renderings honor this status as a presentation contract. | partially covered (`review-set-proposal.test.ts` covers the current product proposal helper rejecting missing `epistemicStatus` and empty grounding/support before surfacing a reviewable payload; thin-vs-rich grounding fixture semantics and structured-exchange carrier migration remain future work) | D30-L, D46-L; A14-L | | I18-L | Every elicitor-emitted prompt/proposal payload facet that needs downstream routing (establishment offer, intent hint, review/proposal material) carries a `lens` field inside the structured-exchange details; capture, reviewer, and future observer/auditor routing filters on this field. | partially covered (`review-set-proposal.test.ts` covers current proposal lens validation; establishment/intent-hint routing tests and structured-exchange carrier migration remain planned with capture/reviewer slices) | D25-L, D26-L, D29-L | | I19-L | Brunch-controlled flows do not create or navigate Pi session branches, and Brunch transcript readers fail fast on non-linear JSONL rather than flattening, migrating, or branch-selecting. | partially covered (M3 transcript loader requires exactly one Pi session header, rejects malformed non-header entry shapes, and rejects non-linear child graphs, `parentSession`, and `branch_summary`; product-facing exchange projection helper preserves the non-linear error discriminant and is used by RPC and fixture replay assertions; `session.exchanges` returns a product-shaped error for non-linear selected sessions over stdio and WebSocket JSON-RPC; Brunch TUI extension cancels `session_before_tree` and `session_before_fork`; Pi command-containment source tests prove no exposed Brunch command path creates branches) | D24-L, D34-L | | I20-L | Every user-reviewable review-set proposal payload has already passed proposal-time dry-run structural/policy validation against `CommandExecutor`; proposals that fail dry-run validation do not surface through `present_review_set` as reviewable review sets. | partially covered (`CommandExecutor.dryRunCommitGraph` and `review-set-proposal.test.ts` cover product-helper dry-run validation, invalid proposal-payload rejection, no graph mutation during dry-run, and dry-run/commit validation parity; real agent-generated `project-graph` proposal fixtures remain planned) | D27-L; A14-L | -| I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | +| I21-L | WebSocket/stdio/TUI client attachment state never becomes the canonical spec/session binding: every session-consuming projection validates the durable `brunch.session_binding`, and write-capable session operations must target an explicit session or future write lease rather than whichever transport connection happens to be open. | partially covered (M3 RPC/WebSocket explicit session projection tests validate durable `brunch.session_binding` for read paths; FE-744 web live-update tests prove WebSocket notifications only invalidate/refetch canonical projection handlers after RPC-originated structured-exchange mutations; FE-795 TUI observer-host tests prove the TUI launch path starts a same-process WebSocket observer attachment with the shared product-update publisher, and selected-spec `commit_graph` publishes graph invalidation topics on that same bus; future write-lease tests remain planned when web input lands) | D10-L, D19-L, D21-L, D33-L | | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result and, when required, exactly one matching terminal `request_*` result before the next agent turn consumes it. The target details model is checked by `schema` + `v`, `exchange_id`, and `tool_meta`; request outcomes are an exactly-one property-presence union; user-authored text is `comment` and runtime-authored text is `message`; present-side status/kind/expected-request aliases and capture graph payloads are invalid in the Zod-authored schema layer. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current FE-744 structured-exchange tools (registered sequential `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. The Zod-authored schema layer is covered by JSON Schema export and drift-rejection tests for present/request/capture details; runtime tools still need a deliberate migration to those exports. `present_review_set`, `present_candidates`, and `request_review` remain named stubs and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L, D41-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless the Brunch Pi Profile explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch profile 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/brunch-pi-profile.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; 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. | covered (`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 snapshots, 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). | D17-L, D23-L, D40-L, D58-L, D59-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. | 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 snapshots, 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). | D17-L, D23-L, D40-L, D58-L, D59-L | | I27-L | Session-name generation is best-effort presentation metadata only: lifecycle hooks may append Pi `session_info` entries, but naming failures never block shutdown/session replacement and generated names never mutate spec identity, session binding, or graph truth. | planned (session-lifecycle naming tests with empty transcript/auth failure/success paths; picker projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/.pi/extensions/structured-exchange/schemas/`; TypeBox remains valid for Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export; 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/auto-compaction-anchors.json](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/auto-compaction-anchors.json) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor config) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec 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. | planned (M5 capture fixtures comparing committed graph facts and preface-only interpretations against transcript evidence) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable; all grade mutations route through `CommandExecutor` and carry audit through the change log. | partial (Card 1: specs table plus `createSpec` / `getSpec` / `updateReadinessGrade` command tests; M5 prompt/tool-policy tests for grade-gated availability remain) | D20-L, D45-L | -| I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `session.submitExchangeResponse`, and the deterministic permutation run produces linear Pi JSONL whose structured exchange projection preserves the same prompt/answer/status/comment artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity under proof-era method names (`rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/exchange parity assertions); target method rename coverage remains to be implemented per D49-L. | D5-L, D48-L, D49-L; I10-L, I13-L, I21-L, I23-L | +| I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `session.submitExchangeResponse`, and the deterministic permutation run produces linear Pi JSONL whose structured exchange projection preserves the same prompt/answer/status/comment artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity under canonical session method names (`session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`): `rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/exchange parity assertions. | D5-L, D48-L, D49-L; I10-L, I13-L, I21-L, I23-L | | I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | partially covered (minimum capture details schemas parse/export and reject graph payload fields; future runtime capture-analysis schema/rendering tests plus transcript renderer fixtures still need to prove persisted result rendering and TUI hide/collapse behavior; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | | I34-L | `commitGraph` batch validation is all-or-nothing: if any node or edge in the batch is structurally illegal, the entire batch is rejected and no partial state is persisted; the agent receives diagnostics sufficient for bounded self-correction retry. | covered (22 tests in `command-executor.test.ts` — edge failure rolls back nodes, mixed-batch rejection, diagnostic sufficiency) | D53-L; I1-L, I11-L | | I35-L | Graph context snapshots support multiple detail levels: a cursory/compact full-graph overview for orientation, and detailed node-neighborhood snapshots with configurable hop depth for focused work. Context builders in `agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | partially covered (`getGraphOverview` + `getNodeNeighborhood` in `snapshot.ts` with 10 tests; context-builder integration deferred to M5) | D52-L, D53-L, D58-L | @@ -353,7 +353,7 @@ src/agents/ - WebSocket connections are persistent transport/client attachments with request IDs, pending calls, and subscriptions; they are not durable Brunch sessions. Session-specific RPC calls should name `sessionId`/`specId` explicitly or be scoped by an explicit attachment handshake. - Live client views should use subscriptions over the same RPC method families rather than pair REST GETs with a separate event channel. - Query/subscription helpers may exist as implementation conveniences, but they must remain subordinate to concrete product methods (`session.*`, `workspace.*`, `graph.*`, `coherence.*`) and must not become a generic platform Brunch now owns. -- Initial POC read methods should stay close to current needs: linear transcript validation, elicitation-exchange projection, chrome/workspace state, and later graph/coherence projections. +- Initial POC read methods should stay close to current needs: linear transcript validation, session exchange projection, chrome/workspace state, and later graph/coherence projections. - A companion web dashboard may observe a TUI-driven session/spec from the same host process; independent multi-process writers over the same cwd/session remain out of scope until a write-lease/concurrency design exists. ### Elicitation UI primitive choice @@ -398,7 +398,7 @@ src/agents/ | **Brunch Pi Profile** | The sealed programmatic wrapper around embedded Pi: settings policy, resource-loader policy, extension factories, keybinding/command policy, tool policy, and prompt policy. It allows Brunch-owned resources while suppressing ambient `.pi/` behavior. | | **Prompt resource** | A Brunch-owned markdown file under `src/agents/` containing detailed goal, strategy, lens, method, or agent-definition guidance. Prompt resources are loaded by the agent with `read` when needed; they are product control-plane assets, not ambient Pi prompt templates. | | **Prompt-resource manifest** | The small per-turn D58-L manifest injected into the system prompt, listing only runtime-legal Brunch resources with `name`, `description`, and `location`. The `name`/`description`/`location` for each entry are code-owned in `agents/state.ts` (not filesystem-discovered), honoring D39-L sealing; `agents/contexts/` snapshot renderers are not manifest resources. It mirrors Pi's skill-list pattern but is filtered by Brunch runtime state, grade, and allow-lists. | -| **Method** | A tool-usage or workflow competence advertised as a Brunch prompt resource (`agents/methods/*.md`): run structured exchanges, infer-and-capture (D50-L), generate proposals/projections, read snapshots, mutate the graph, review for gaps. A method may also be backed by a Pi-native skill, but actual tool authority remains code-owned through `op_mode` policy and active-tool gating. `capability` is retired as a synonym — use `method` and ``. | +| **Method** | A tool-usage or workflow competence advertised as a Brunch prompt resource (`agents/methods/*.md`): run structured exchanges, infer-and-capture (D50-L), generate proposals/projections, read snapshots, mutate the graph, review for gaps. Method resources explain when to use a tool family and how to sequence it with other tools; executable tool definitions should stay focused on schemas, authority, and runtime behavior. A method may also be backed by a Pi-native skill, but actual tool authority remains code-owned through `op_mode` policy and active-tool gating. `capability` is retired as a synonym — use `method` and ``. | | **Snapshot** | An *agent-context* content view the agent reasons over — `cwd`, `graph`, or `node` (D60-L): pulled (typed, read-only) from `graph/`/`session/`, rendered to LLM-string (in `agents/contexts/`) or JSON, surfaced pushed (compose) or pulled (`snapshot-*` tools). Distinct from the **workspace projection** (`workspace.snapshot`), which is product/UI state, not agent content. | | **Readiness grade** | Spec-owned forward gate stored on the `specs` row: `grounding_onboarding | elicitation_ready | commitments_ready | planning_ready`. It unlocks later strategies, review sets, and eventual export/plan/execute posture, but never forbids earlier gathering or refinement. | | **Elicitation posture** | Retired as persisted spec state. Use readiness grade plus active strategy/lens/review-set state to explain elicit behavior. | @@ -432,7 +432,7 @@ src/agents/ | **project-graph** | Elicitor strategy for deriving nodes and edges from existing graph truth (e.g. projecting requirements from upstream goals/constraints). Uses review-set commitment (D27-L). Extractive rather than inventive; lower structural-legality risk than propose-graph. | | **Brunch public RPC surface** | The one product-facing JSON-RPC surface exposed over stdio, WebSocket, and in-process handlers. Product clients use this surface for workspace, session, graph, and coherence projections plus session-native interaction methods; raw Pi RPC is hidden behind adapters when needed. | | **RPC discovery** | Brunch-owned `rpc.discover` method output: public method names, descriptions, parameter/result schemas, and examples for the current Brunch host. It is distinct from Pi `get_commands`, which only lists slash commands/prompt templates/skills invokable through Pi's `prompt` command. The concrete public vocabulary is maintained in `src/rpc/README.md`. | -| **RPC method family** | A named group of Brunch JSON-RPC methods (`rpc.*`, `workspace.*`, `session.*`, future `graph.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second public API surface. Proof-era `elicitation.*`, `session.startElicitation`, `session.elicitationExchanges`, `session.transcriptDisplay`, and public `command.*` method names are rename debt, not product vocabulary for new work. | +| **RPC method family** | A named group of Brunch JSON-RPC methods (`rpc.*`, `workspace.*`, `session.*`, future `graph.*`) that exposes product behavior through stdio, WebSocket, or in-process handler calls without creating a second public API surface. Retired proof-era session/elicitation names, transcript-display debug projections, and public `command.*` names are not product vocabulary for new work. | | **Projection handler** | A thin handler that reads or subscribes to a canonical store and returns product-shaped state for a mode/client. It is not a canonical store itself. | | **Subscription** | A long-lived RPC operation that delivers live updates, often with an initial snapshot, for views that must stay current with session, workspace, graph, or coherence state. | | **Transport adapter** | The stdio, WebSocket, HTTP-shim, Pi-RPC relay, or in-process wrapper around the same Brunch handlers. Transport adapters do not own product semantics. | @@ -440,7 +440,7 @@ src/agents/ | **Canonical store** | The persistence surface that owns a fact: Pi JSONL for session transcript truth, `.brunch/workspace.json` for lightweight workspace binding state, SQLite graph/change log for graph truth and coherence substrates. | | **Elicitation prompt** | System- or assistant-originated transcript span that prompts/directs the user's next response. At idle, a Brunch-supported linear session ends with an unresolved elicitation prompt. | | **User response** | User-originated text and/or structured action selection responding to the current elicitation prompt. There is no ambient chat input in the POC model. | -| **Elicitation exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-exchange results) plus response-side span (the user's text and/or terminal structured-exchange `request_*` toolResult details). This is the default post-exchange capture unit. | +| **Session exchange** | A derived projection over Brunch-supported linear Pi JSONL: prompt-side span (system/assistant/tool-side entries since the prior response, excluding terminal structured-exchange results) plus response-side span (the user's text and/or terminal structured-exchange `request_*` toolResult details). This is the default post-exchange capture unit. | | **Structured exchange** | Transcript-native `present_*` / `request_*` / future `capture_*` toolResult tuple used when an elicitation prompt/offer/response carries durable actions, choices, review payloads, or other deterministic UI structure. Plain generative prompts can remain ordinary Pi messages. | | **Structured offer** | A system/assistant-originated prompt, proposal, or question that owns the response surface until answered, cancelled, marked unavailable, or explicitly declared display-only. A distinct `skipped` terminal state is deferred until product pressure distinguishes “declined but continue” from cancellation or an explicit `none`/`other` answer. The target carrier is a registered Pi `present_*`/`request_*` tool tuple whose result details carry the structured display and response; older custom-entry carriers are proof/history, not the preferred product shape. | | **Pending exchange** | Product-shaped view of the current unresolved structured offer for one activated spec/session. Public RPC clients read it through `session.pendingExchange` and close it through `session.submitExchangeResponse`; it is a projection/adapter state over transcript truth and in-flight Pi extension UI, not a canonical turn table. | @@ -456,7 +456,7 @@ src/agents/ | **Offer response** | The terminal structured answer to a structured offer, represented as self-contained `request_*` toolResult details. It is transcript truth, not an ephemeral UI return value. | | **JSON-editor fallback** | A Pi-RPC-compatible adapter for complex interactive shapes: the tool calls `ctx.ui.editor()` with schema-tagged JSON prefill; a Brunch-aware client renders a real form and returns filled JSON through Pi's documented `extension_ui_response`; the tool validates and persists a normal structured result. | | **Elicitation UI relay** | The adapter path that translates Pi extension UI requests (including JSON-editor fallback) into Brunch public RPC pending-exchange events/methods, then translates product responses back into Pi `extension_ui_response` messages. | -| **Deferred observer/auditor job** | Optional durable async work item keyed by session id and elicitation-exchange entry-range ids. If introduced, it audits or backfills exchange analysis and survives process restart, but it is not the primary path for next-turn graph freshness. | +| **Deferred observer/auditor job** | Optional durable async work item keyed by session id and session-exchange entry-range ids. If introduced, it audits or backfills exchange analysis and survives process restart, but it is not the primary path for next-turn graph freshness. | | **Lens switch** | A durable `brunch.lens_switch` transcript entry recording that the active agent/session changed lenses. The switch event is distinct from the lens concept itself. | | **Side task** | Main-agent-invoked, non-blocking work item tracked by the Brunch `SideTaskRegistry`. The main agent fires it and does not await a return value; the only path it influences the main agent is by appending a custom-message status update to the session log that arrives at the next-turn boundary via `prepareNextTurn`. Side-task writes route through the `CommandExecutor`. Distinct from Subagent (blocking) and Side chat (user-invoked). | | **Subagent** | Main-agent-invoked, **blocking** Pi tool call (`subagent`) that runs an isolated `pi` subprocess with a per-agent tool allowlist and per-agent model. Has no inherited conversation context, no `CommandExecutor` access, and no Brunch RPC access. Result text returns directly as tool result content. POC starter agents split into **data gatherers** (scout / researcher / graph-reader — read-only context fetchers that ground proposals) and a **variant proposer** (proposer — system-prompt-only; one variant per invocation, fan-out via parallel mode realizes the "design it twice" pattern). | @@ -557,13 +557,13 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | Inner | Type-aware lint, type checks, fast unit tests | Local module correctness, typed command/result shapes (including `acceptReviewSet` and reviewer-writable record-class types), projection helper behavior (including `supersedes`-chain filtering). | D12-L, D13-L, D20-L, D21-L, D27-L, D28-L, D29-L. | | Inner | Schema/shape validation at boundaries | JSON-RPC payloads, command results, structured elicitation entries, Zod-authored structured-exchange present/request/capture details with JSON Schema export, probe report metadata, graph exports, runtime-gated prompt-resource manifests, and structured-exchange payload facets for review proposals, establishment offers, and elicitor intent hints (lens presence, `epistemic_status`, grounding coverage, entity-draft shape). | R8, R10, R11, R17, R20, R21, R23; I3-L, I10-L, I11-L, I17-L, I18-L, I23-L, I26-L, I38-L. | | Middle | **Probe oracles**: prose manual actions plus executable postcondition checkers | Interactive seams leave correct durable state. Early M0 checkers may inspect stores only; once handlers exist, prefer projection-including checks. Extends to workspace-dialog startup behavior, in-flight reviewer-signal chrome behavior, and ambient-affordance rendering from latest establishment-offer structured-exchange facet. | D11-L, D21-L, D22-L, D25-L, D29-L, D36-L; I8-L, I13-L, I22-L. | -| Middle | Round-trip tests | JSONL reload, linear transcript validation, elicitation exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | +| Middle | Round-trip tests | JSONL reload, linear transcript validation, session exchange projection, compaction, graph export/import, command result serialization, `supersedes`-chain reconstruction across regeneration. | D6-L, D13-L, D24-L, D28-L; I3-L, I8-L, I10-L, I19-L. | | Middle | Property-based / model-based tests | LSN monotonicity, change-log replay, reconciliation-need invariants, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one LSN / one change-log entry, partial-batch impossible even under mid-batch validation failure)**, **`supersedes`-chain acyclicity and unique-leaf-per-thread**, **lens-routing correctness (generated elicitor entries route to the right consumer)**, **reviewer-finding turn-boundary delivery ordering**. | A4-L, A8-L, A9-L, A11-L; I1-L, I4-L, I5-L, I6-L, I9-L, I12-L, I15-L, I16-L, I18-L. | -| Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; `session.promptExchange` / `session.pendingExchange` / `session.submitExchangeResponse` / `session.exchanges` preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | +| Middle | Contract tests | Named RPC method families and transport adapters share handler semantics; `rpc.discover` describes public methods with usable schemas/examples; `session.triggerExchange` / `session.pendingExchange` / `session.submitExchangeResponse` / `session.exchanges` preserve transcript truth; subscriptions deliver initial snapshot plus ordered updates; `CommandExecutor` hides policy/transaction details; `acceptReviewSet` returns expected structured discriminants; only prevalidated proposals become reviewable review sets. | D5-L, D19-L, D20-L, D27-L, D48-L, D49-L; R11, R12, R27, R28. | | Middle | Architectural boundary tests | No direct ORM/SQLite mutation outside `CommandExecutor`; no canonical chat/turn store; TUI/RPC/fixture code does not write `brunch.session_binding`; spec/session picker UI returns decisions rather than opening/mutating sessions; RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code; Brunch wrappers do not expose Pi branch creation/navigation as product behavior; spec readiness-grade mutations route through commands rather than session-local memory; reviewer-attributed writes target only `reconciliation_need`; Brunch-launched Pi runtimes do not load ambient `.pi/` resources or behavior-shaping settings outside the Brunch Pi Profile; Brunch product extensions load through the explicit static shell list rather than filesystem discovery or a runtime extension-metadata protocol. | D4-L, D6-L, D18-L, D21-L, D24-L, D29-L, D36-L, D39-L, D45-L; I2-L, I10-L, I11-L, I16-L, I19-L, I22-L, I24-L, I31-L. | | Middle | **Differential testing** | Dry-run validation at proposal time matches real-run validation at acceptance time (no drift between modes); free-form-generation vs constrained-generation legality rates (informs whether fallback path is needed per A14-L). | D27-L; A14-L. | | Middle | Probe transcript replay and property assertions | Probe runs preserve transcript evidence that can be replayed, rendered, and compared against current Brunch projections. Future brief-driven sessions, if revived, must produce the same probe-run artifact shape. For batch proposals/review sets: **structural-legality rate of LLM proposals tracked per-run in probe metadata as POC-phase fitness, not a merge gate**; first-attempt vs retry-with-feedback rates surfaced for human review. | A5-L, A6-L, A7-L, A14-L; I7-L; R20, R21, R22, R23. | -| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives the current structured-exchange permutations through Brunch JSON-RPC only, compares Pi JSONL plus `session.exchanges` projections against TUI-shaped structured-exchange expectations, rejects repeated deterministic prompts, and can persist a `.fixtures/runs/public-rpc-parity//` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. The landed FE-744 proof uses older public names; D49-L defines the target rename. | A5-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | +| Middle | Deterministic public-RPC parity proof | A scripted agent-as-user discovers Brunch methods, activates workspace/spec/session, drives the current structured-exchange permutations through Brunch JSON-RPC only, compares Pi JSONL plus `session.exchanges` projections against TUI-shaped structured-exchange expectations, rejects repeated deterministic prompts, and can persist a `.fixtures/runs/public-rpc-parity//` review bundle containing source `session.jsonl`, Brunch-semantic `transcript.md`, and `report.json`. The landed FE-744 proof has been reconciled to the canonical D49-L session method names. | A5-L; D5-L, D48-L, D49-L; I23-L, I32-L; R24, R27, R28. | | Middle | Capture-analysis transcript oracle | Future `capture_*` probes persist ANALYSIS as normal Brunch toolResults, assert no graph writes occur, render full analysis in Markdown/ASCII transcripts, and assert the TUI path hides or collapses the same result without losing persisted content/details. | D17-L, D18-L, D37-L, D47-L, D50-L; I23-L, I30-L, I33-L. | | Outer | Manual walkthrough with checklist | UX/presentation life: TUI chrome, spec/session picker, web shell feel, coherence visibility, elicitation usefulness. Adds: ambient-affordance rendering from establishment-offer structured-exchange facets; proposal/framing quality review; lens-recommendation appropriateness; review-cycle UX (approve / request-changes / reject); meta-rubric comparative-usefulness review (D31-L hypothesis test). | A17-L; R4, R14, R16, R20, R21. | | Outer | Adversarial / generative probe runs | Elicitation quality, human-gated `needs_human`, contradictory requirements, cross-session updates, long-horizon compaction, and reviewer-finding precision through small targeted probe scenarios (brief-shaped inputs are allowed, but the probe run and transcript artifacts are canonical). POC scope remains one or two known-bad scenarios per relevant invariant, not exhaustive coverage. | A5-L, A8-L, A9-L, A11-L, A14-L; I4-L, I6-L, I12-L, I13-L, I16-L. | @@ -605,7 +605,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I20-L | M5+ proposal-validation contract and differential tests proving only dry-run-valid proposals become reviewable review sets. | | I21-L | M3 RPC/WebSocket explicit-session projection tests; future write-lease tests when browser writes land. | | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | -| I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; elicitation-exchange projection pairs the prompt-side present with the terminal request result. Structured-exchange schema tests cover the landed target details model: checked `schema`/`v`, `tool_meta`, candidate rubric/graph-ref shapes, review-set pointer shape, request answered/cancelled/unavailable unions, `comment` vs runtime `message`, and capture no-graph-payload minimum. | +| I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; session exchange projection pairs the prompt-side present with the terminal request result. Structured-exchange schema tests cover the landed target details model: checked `schema`/`v`, `tool_meta`, candidate rubric/graph-ref shapes, review-set pointer shape, request answered/cancelled/unavailable unions, `comment` vs runtime `message`, and capture no-graph-payload minimum. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | | I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active `op_mode` / `strategy` / `lens` / `goal` (foreground role derived from `op_mode`), and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit`. | | I26-L | Structured-exchange schema tests prove the acknowledged Zod seam parses and exports JSON Schema; future M4 architectural tests should grep/import-audit schema libraries and Drizzle row-schema derivation boundaries. | @@ -613,7 +613,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/.pi/extensions/subagents/agents/*.md` frontmatter and `src/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | | I30-L | M5 post-exchange capture fixtures: compare committed graph facts, reconciliation needs, and preface-only interpretations against transcript evidence; known ambiguous exchanges must not silently become graph truth. | | I31-L | Spec-row command tests for grade updates plus prompt/tool-policy tests proving grade gates unlock later actions without disabling gathering/refinement. Card 1 covers the CommandExecutor grade-write path; prompt/tool-policy tests remain with M5. | -| I32-L | FE-744 public-RPC structured-exchange parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic permutation run over Brunch JSON-RPC only, no repeated deterministic prompts, and parity assertions over the resulting Pi JSONL, transcript display, and elicitation-exchange projections. | +| I32-L | FE-744 public-RPC structured-exchange parity proof: `rpc.discover` contract tests, pending/respond lifecycle tests, deterministic permutation run over Brunch JSON-RPC only, no repeated deterministic prompts, and parity assertions over the resulting Pi JSONL, transcript display, and session exchange projections. | | I33-L | Current schema tests cover minimum no-graph `capture_*` details and reject graph payload fields. Future capture-analysis runtime tests must still cover persisted result rendering, no graph-write side effects, Brunch-semantic transcript inclusion, and hidden/collapsed TUI rendering fallback. | | I36-L | M4 per-plane kind enum validation tests in CommandExecutor; kind-to-category derivation unit tests proving pure function parity with GRAPH_MODEL.md table. | | I37-L | M4 node-creation tests: decision/term rejected without detail; constraint accepted with or without detail; other kinds rejected with detail; unknown detail fields rejected. | @@ -623,7 +623,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` - **Prompt-resource manifests before eager prompt injection.** For goal, strategy, lens, and method guidance, prefer a deterministic per-turn manifest plus agent-driven `read` loading over a Brunch state machine that selects and concatenates large semantic prompt bodies. Inner-loop tests prove manifest legality and filtering; behavioral probes judge whether the agent loads and applies the right resource. - **Deterministic before generative.** Probe runs should prefer deterministic or tightly scripted paths before relying on LLM persona variance. Generative/adversarial probes come after the transcript substrate is trusted. Retired M1 scripted captures proved the early transport/projection substrate on then-current terms, but tuple-shaped FE-744 public-RPC probes are the current evidence path. -- **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, structured-exchange prompt/read/submit lifecycle, current structured-exchange permutations, JSONL/projection parity, and reviewable probe artifacts. The target method names live in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md); proof-era names in current code are rename debt, not design vocabulary. LLM elicitation quality and coherent ten-turn progress remain outer-loop generative fixture concerns after the transport/turn substrate is trustworthy. +- **Public RPC parity before LLM quality.** FE-744's product proof uses a deterministic dummy elicitor rather than a real LLM: the point is to prove Brunch's public RPC contract, assistant-first turn model, structured-exchange prompt/read/submit lifecycle, current structured-exchange permutations, JSONL/projection parity, and reviewable probe artifacts. The canonical method names live in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md); current code and probes should use those names only. LLM elicitation quality and coherent ten-turn progress remain outer-loop generative fixture concerns after the transport/turn substrate is trustworthy. - **Capture analysis before graph persistence.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before graph persistence or before comparing later graph mutations against transcript evidence. The landed schema layer defines only the checked minimum capture details and rejects graph payloads; richer analysis payloads and shared rendering components still require a separate design pass before runtime implementation. - **Projection handlers are oracles, not stores.** Read/subscription tests should prove handlers reconstruct truth from Brunch-supported linear Pi JSONL, `.brunch/workspace.json`, or SQLite graph/change log; they should not introduce a canonical view-store just for testing. - **Behavioral quality boundary.** Inner/middle loops prove structural validity, durable state, invariants, and expected graph/property coverage. “Good interview”, “good question”, and “coherent UX feel” remain outer-loop checklist/generative-fixture judgments until enough examples justify sharper metrics. diff --git a/memory/cards/live-graph-observer--mise-en-place.md b/memory/cards/live-graph-observer--mise-en-place.md new file mode 100644 index 00000000..2e632813 --- /dev/null +++ b/memory/cards/live-graph-observer--mise-en-place.md @@ -0,0 +1,115 @@ +# Live graph observer mise en place + +Frontier: live-graph-observer | n/a +Status: active — Card 2 is documented; browser-observable smoke is blocked locally +Mode: chain +Created: 2026-06-03 + +## Orientation + +- Containing seam: product launch/setup around the `live-graph-observer` frontier; these cards prepare the branch identity and local manual loop without touching graph/RPC/web core paths. +- Frontier item: `live-graph-observer` (FE-795). This is branch-local mise en place, not a separate Linear issue or Graphite branch. +- Current card state: Card 1 is done; Card 2 has a documented CDP-first browser loop, but local browser automation is blocked until a Chrome/Playwright backend works. +- Main open risk: feedback-loop tooling can sprawl into a dev-platform project. Keep the workbench/tooling concrete enough to launch and observe the POC only. +- Cross-cutting obligations: preserve `.brunch/` as cwd-scoped durable state; do not commit generated `.brunch/data.db` or sessions; do not add compatibility aliases unless explicitly requested. + +## Card 1 — done — CLI identity and local workbench + +### Objective + +The project installs and launches as `brunch-cli` from a reusable in-repo POC workbench cwd. + +### Acceptance Criteria + +✓ `package.json` — package name is `brunch-cli`, version is at least `0.1.0`, and the only bin command is `brunch-cli`. +✓ `bin/brunch-cli.js` — the executable bin shim launches the built CLI, with no `brunch-next` bin alias left behind. +✓ `.fixtures/workbenches/live-graph-observer/` — contains a small committed README or marker explaining how to launch `brunch-cli` there and let `.brunch/` + `data.db` scaffold locally. +✓ `npm run build` or focused package/bin test — proves the renamed bin target is included and executable after build. + +### Verification Approach + +- Inner: focused package/bin test or build assertion — proves package identity and bin path. +- Middle: manual command from `.fixtures/workbenches/live-graph-observer/` — `brunch-cli --mode print` or `npm run dev -- --mode print` scaffolds `.brunch/` in that directory. + +### Cross-cutting obligations + +- `.brunch/` remains cwd-scoped and ignored; generated DB/session artifacts are not committed. +- Identity is singular: no `brunch-next` compatibility alias unless the user asks. + +### Assumption dependency + +None — this is setup identity work, not a product architecture claim. + +### Expected touched paths (tentative) + +```pseudo +package.json ~ +package-lock.json ~ +bin/ +├── brunch.js - +└── brunch-cli.js + +src/brunch.test.ts ? +.fixtures/workbenches/live-graph-observer/ +└── README.md + +``` + +### Promotion checklist + +- [ ] Does this change a requirement? +- [ ] Does this create, retire, or invalidate an assumption? +- [ ] Does this slice depend on an unvalidated high-impact assumption? +- [ ] Does this make or reverse a non-trivial design decision? +- [ ] Does this establish a new seam-level invariant? +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? +- [ ] Does it cross more than two major seams? +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? +- [ ] Can you not name the containing seam or current rationale from the live docs? + +## Card 2 — blocked — Browser feedback loop decision + +### Objective + +The branch has one documented, runnable browser feedback loop for the web observer work. + +### Acceptance Criteria + +✓ Feedback-loop choice is explicit in the workbench README: recommended command(s), expected port/URL shape, and how to inspect browser console/network/accessibility state. +! Chrome/CDP command verification is blocked locally: the web host launches and prints a URL, but browser automation could not attach until a local Chrome/Playwright backend works. +✓ Browser automation/inspection tooling and `agentation` are treated as complementary: Chrome/CDP-style tooling observes the browser; `agentation` annotates the running browser so the agent can fetch annotations through its CLI. +n/a `agentation` is not enabled in this card, so no dependency/import change is recorded and no `src/web/*` edit is needed. +✓ No feedback-loop tool becomes product runtime behavior or a required POC dependency. + +### Verification Approach + +- Inner: doc/format verification for README/card changes; file-scoped lint/build for changed package/web files if a dev dependency or import is added. +- Middle: manual smoke in the workbench — launch host, open browser tooling, confirm the page is observable. +- Current observed state: `npm run build` passed and `brunch-cli --mode web` launched from the workbench; page-observable browser smoke is pending a working local browser backend. + +### Cross-cutting obligations + +- Keep feedback tooling out of canonical product state and out of `.brunch/` artifacts. +- Use Chrome/CDP-style tooling for browser inspection/automation and `agentation` for human/agent annotations when a running browser needs annotated UI feedback. + +### Assumption dependency + +None — if the tooling choice reveals a missing dev-server or MCP requirement, stop and rescope before adding a larger dev-platform seam. + +### Expected touched paths (tentative) + +```pseudo +.fixtures/workbenches/live-graph-observer/README.md ~ +package.json ? +package-lock.json ? +``` + +### Promotion checklist + +- [ ] Does this change a requirement? +- [ ] Does this create, retire, or invalidate an assumption? +- [ ] Does this slice depend on an unvalidated high-impact assumption? +- [ ] Does this make or reverse a non-trivial design decision? +- [ ] Does this establish a new seam-level invariant? +- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? +- [ ] Does it cross more than two major seams? +- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? +- [ ] Can you not name the containing seam or current rationale from the live docs? diff --git a/package-lock.json b/package-lock.json index e6469f72..52edaa0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "brunch-next", - "version": "0.0.0", + "name": "brunch-cli", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "brunch-next", - "version": "0.0.0", + "name": "brunch-cli", + "version": "0.1.0", "dependencies": { "@earendil-works/pi-ai": "^0.75.5", "@earendil-works/pi-coding-agent": "^0.75.5", @@ -20,7 +20,7 @@ "zod": "^4.4.3" }, "bin": { - "brunch-next": "bin/brunch.js" + "brunch-cli": "bin/brunch-cli.js" }, "devDependencies": { "@sinclair/typebox": "^0.34.49", @@ -42,6 +42,7 @@ "oxlint-tsgolint": "^0.23.0", "tsx": "^4.22.4", "typescript": "^5.7.0", + "typescript-language-server": "^5.3.0", "vite": "^8.0.16", "vitest": "^4.1.8" }, @@ -8047,6 +8048,19 @@ "node": ">=14.17" } }, + "node_modules/typescript-language-server": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz", + "integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "typescript-language-server": "lib/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 430a2177..cdb477a9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "brunch-next", - "version": "0.0.0", + "name": "brunch-cli", + "version": "0.1.0", "private": true, "description": "Brunch — opinionated specification-workspace product over pi-coding-agent.", "bin": { - "brunch-next": "./bin/brunch.js" + "brunch-cli": "./bin/brunch-cli.js" }, "files": [ "dist", @@ -65,6 +65,7 @@ "oxlint-tsgolint": "^0.23.0", "tsx": "^4.22.4", "typescript": "^5.7.0", + "typescript-language-server": "^5.3.0", "vite": "^8.0.16", "vitest": "^4.1.8" }, diff --git a/src/.pi/__tests__/graph-tools.test.ts b/src/.pi/__tests__/graph-tools.test.ts index f85cdd88..1ca22651 100644 --- a/src/.pi/__tests__/graph-tools.test.ts +++ b/src/.pi/__tests__/graph-tools.test.ts @@ -11,15 +11,17 @@ import { describe, beforeEach, it, expect } from 'vitest'; import { createDb } from '../../db/connection.js'; import type { BrunchDb } from '../../db/connection.js'; +import { specs } from '../../db/schema.js'; import { CommandExecutor } from '../../graph/command-executor.js'; import { getGraphOverview, getNodeNeighborhood } from '../../graph/snapshot.js'; +import { createProductUpdatePublisher } from '../../rpc/product-updates.js'; import { translateCommitGraph, formatCommitGraphResult, formatGraphOverview, formatNeighborhoodResult, } from '../extensions/graph/command-adapter.js'; -import type { GraphSnapshotReaders } from '../extensions/graph/index.js'; +import { registerBrunchGraph, type GraphSnapshotReaders } from '../extensions/graph/index.js'; // --------------------------------------------------------------------------- // Helpers @@ -29,10 +31,15 @@ function createTestDb(): BrunchDb { return createDb(':memory:'); } -function createSnapshots(db: BrunchDb): GraphSnapshotReaders { +function seedSpec(db: BrunchDb): number { + db.insert(specs).values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }).run(); + return db.select({ id: specs.id }).from(specs).get()!.id; +} + +function createSnapshots(db: BrunchDb, specId: number): GraphSnapshotReaders { return { - getGraphOverview: () => getGraphOverview(db), - getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, nodeId, options), + getGraphOverview: () => getGraphOverview(db, specId), + getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, specId, nodeId, options), }; } @@ -42,28 +49,32 @@ function createSnapshots(db: BrunchDb): GraphSnapshotReaders { describe('translateCommitGraph', () => { it('translates flat tool params into CommitGraphInput', () => { - const input = translateCommitGraph({ - nodes: [ - { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }, - { - ref: 'n2', - plane: 'intent', - kind: 'requirement', - title: 'Test req', - body: 'details', - }, - ], - edges: [ - { category: 'dependency', source: 'n2', target: 'n1' }, - { - category: 'support', - source: { existing: 42 }, - target: 'n1', - stance: 'for', - }, - ], - }); + const input = translateCommitGraph( + { + nodes: [ + { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }, + { + ref: 'n2', + plane: 'intent', + kind: 'requirement', + title: 'Test req', + body: 'details', + }, + ], + edges: [ + { category: 'dependency', source: 'n2', target: 'n1' }, + { + category: 'support', + source: { existing: 42 }, + target: 'n1', + stance: 'for', + }, + ], + }, + 7, + ); + expect(input.specId).toBe(7); expect(input.nodes).toHaveLength(2); expect(input.nodes[0]!.ref).toBe('n1'); expect(input.edges).toHaveLength(2); @@ -132,27 +143,32 @@ describe('graph tools end-to-end', () => { let db: BrunchDb; let executor: CommandExecutor; let snapshots: GraphSnapshotReaders; + let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); - snapshots = createSnapshots(db); + specId = seedSpec(db); + snapshots = createSnapshots(db, specId); }); it('commit_graph creates nodes and edges readable by read_graph', () => { // Commit a small graph - const input = translateCommitGraph({ - nodes: [ - { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Build auth' }, - { - ref: 'n2', - plane: 'intent', - kind: 'requirement', - title: 'JWT tokens', - }, - ], - edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], - }); + const input = translateCommitGraph( + { + nodes: [ + { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Build auth' }, + { + ref: 'n2', + plane: 'intent', + kind: 'requirement', + title: 'JWT tokens', + }, + ], + edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], + }, + specId, + ); const result = executor.commitGraph(input); expect(result.status).toBe('success'); @@ -167,11 +183,39 @@ describe('graph tools end-to-end', () => { expect(text).toContain('dependency'); }); - it('commit_graph returns diagnostics on invalid batch', () => { - const input = translateCommitGraph({ - nodes: [{ ref: 'n1', plane: 'intent', kind: 'not_a_kind' as never, title: 'Bad' }], + it('commit_graph publishes selected-spec graph update topics after successful commits', async () => { + const productUpdates = createProductUpdatePublisher(); + const observed: unknown[] = []; + const tools = new Map }>(); + productUpdates.subscribe((updates) => observed.push(...updates)); + registerBrunchGraph( + { + registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { + tools.set(tool.name, tool); + }, + } as never, + { specId, commandExecutor: executor, snapshots, productUpdates }, + ); + + await tools.get('commit_graph')!.execute('call-1', { + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Observable goal' }], edges: [], }); + + expect(observed).toEqual([ + { topic: 'graph.overview', specId, lsn: 1 }, + { topic: 'graph.nodeNeighborhood', specId, lsn: 1 }, + ]); + }); + + it('commit_graph returns diagnostics on invalid batch', () => { + const input = translateCommitGraph( + { + nodes: [{ ref: 'n1', plane: 'intent', kind: 'not_a_kind' as never, title: 'Bad' }], + edges: [], + }, + specId, + ); const result = executor.commitGraph(input); expect(result.status).toBe('structural_illegal'); @@ -183,13 +227,16 @@ describe('graph tools end-to-end', () => { }); it('commit_graph with edge validation failure rolls back nodes (I34-L)', () => { - const input = translateCommitGraph({ - nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], - edges: [ - // stance required for proof but missing - { category: 'proof', source: 'n1', target: 'n1' }, - ], - }); + const input = translateCommitGraph( + { + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], + edges: [ + // stance required for proof but missing + { category: 'proof', source: 'n1', target: 'n1' }, + ], + }, + specId, + ); const result = executor.commitGraph(input); expect(result.status).toBe('structural_illegal'); @@ -200,18 +247,21 @@ describe('graph tools end-to-end', () => { it('read_graph neighborhood returns node details', () => { // Create a node first - const input = translateCommitGraph({ - nodes: [ - { - ref: 'n1', - plane: 'intent', - kind: 'goal', - title: 'Main goal', - body: 'A detailed goal', - }, - ], - edges: [], - }); + const input = translateCommitGraph( + { + nodes: [ + { + ref: 'n1', + plane: 'intent', + kind: 'goal', + title: 'Main goal', + body: 'A detailed goal', + }, + ], + edges: [], + }, + specId, + ); const commitResult = executor.commitGraph(input); expect(commitResult.status).toBe('success'); diff --git a/src/.pi/__tests__/review-set-proposal.test.ts b/src/.pi/__tests__/review-set-proposal.test.ts index 4732ef75..98e06748 100644 --- a/src/.pi/__tests__/review-set-proposal.test.ts +++ b/src/.pi/__tests__/review-set-proposal.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { createDb } from '../../db/connection.js'; +import type { BrunchDb } from '../../db/connection.js'; +import { specs } from '../../db/schema.js'; import { CommandExecutor } from '../../graph/command-executor.js'; import { getGraphOverview } from '../../graph/snapshot.js'; import { @@ -9,6 +11,11 @@ import { type ReviewSetProposalDraft, } from '../extensions/graph/review-set-proposal.js'; +function seedSpec(db: BrunchDb): number { + db.insert(specs).values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }).run(); + return db.select({ id: specs.id }).from(specs).get()!.id; +} + function validProposal(overrides: Partial = {}): ReviewSetProposalDraft { return { schemaVersion: 1, @@ -65,7 +72,9 @@ describe('review-set proposal dry-run gate', () => { it('validates dry-run-valid review-set proposal payloads for structured exchanges', () => { const db = createDb(':memory:'); const executor = new CommandExecutor(db); + const specId = seedSpec(db); const result = validateReviewSetProposalPayload({ + specId, proposal: validProposal(), commandExecutor: executor, }); @@ -79,13 +88,15 @@ describe('review-set proposal dry-run gate', () => { validation: { status: 'success' }, }, }); - expect(getGraphOverview(db)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); + expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); }); it('rejects structurally invalid review-set proposal payloads', () => { const db = createDb(':memory:'); const executor = new CommandExecutor(db); + const specId = seedSpec(db); const result = validateReviewSetProposalPayload({ + specId, proposal: validProposal({ edgeDrafts: [ { @@ -102,12 +113,13 @@ describe('review-set proposal dry-run gate', () => { status: 'structural_illegal', diagnostics: [{ field: 'edges[0].stance', message: expect.stringContaining('required') }], }); - expect(getGraphOverview(db)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); + expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 0 }); }); it('rejects proposal schema drift before CommandExecutor dry-run', () => { const db = createDb(':memory:'); const executor = new CommandExecutor(db); + const specId = seedSpec(db); for (const proposal of [ { ...validProposal(), epistemicStatus: undefined }, @@ -125,6 +137,7 @@ describe('review-set proposal dry-run gate', () => { }, ]) { const result = validateReviewSetProposalPayload({ + specId, proposal: proposal as unknown as ReviewSetProposalDraft, commandExecutor: executor, }); @@ -135,15 +148,17 @@ describe('review-set proposal dry-run gate', () => { it('keeps dry-run validation in parity with commitGraph validation', () => { const db = createDb(':memory:'); const executor = new CommandExecutor(db); + const specId = seedSpec(db); const proposal = validProposal(); const entry = validateReviewSetProposalPayload({ + specId, proposal, commandExecutor: executor, }); expect(entry.status).toBe('success'); - const commitResult = executor.commitGraph(translateReviewSetProposalToCommitGraph(proposal)); + const commitResult = executor.commitGraph(translateReviewSetProposalToCommitGraph(proposal, specId)); expect(commitResult).toMatchObject({ status: 'success' }); - expect(getGraphOverview(db)).toMatchObject({ nodeCount: 3, edgeCount: 2 }); + expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 3, edgeCount: 2 }); }); }); diff --git a/src/.pi/extensions/graph/command-adapter.ts b/src/.pi/extensions/graph/command-adapter.ts index 37fa62f3..cd9b3e2c 100644 --- a/src/.pi/extensions/graph/command-adapter.ts +++ b/src/.pi/extensions/graph/command-adapter.ts @@ -25,8 +25,11 @@ import type { ToolCommitGraphParams } from './tool-schemas.js'; * Translate Pi tool params into a CommandExecutor CommitGraphInput. * * The translation is thin — structural validation happens in the CommandExecutor. + * `specId` is injected by the registrar from the selected session/spec context + * so the agent-facing tool schema never asks the LLM for a workspace-global + * graph target (D61-L). */ -export function translateCommitGraph(params: ToolCommitGraphParams): CommitGraphInput { +export function translateCommitGraph(params: ToolCommitGraphParams, specId: number): CommitGraphInput { const nodes: BatchNodeInput[] = params.nodes.map((n) => ({ ref: n.ref, plane: n.plane as BatchNodeInput['plane'], @@ -46,7 +49,7 @@ export function translateCommitGraph(params: ToolCommitGraphParams): CommitGraph rationale: e.rationale, })); - return { nodes, edges }; + return { specId, nodes, edges }; } function resolveEdgeRef(ref: string | { readonly existing: number }): BatchEdgeRef { diff --git a/src/.pi/extensions/graph/index.ts b/src/.pi/extensions/graph/index.ts index c36994e9..79d5bb32 100644 --- a/src/.pi/extensions/graph/index.ts +++ b/src/.pi/extensions/graph/index.ts @@ -14,6 +14,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import type { CommandExecutor } from '../../../graph/command-executor.js'; import type { GraphOverview, NeighborhoodResult } from '../../../graph/snapshot.js'; +import { graphMutationProductUpdates, type ProductUpdatePublisher } from '../../../rpc/product-updates.js'; import { translateCommitGraph, formatCommitGraphResult, @@ -32,9 +33,18 @@ export interface GraphSnapshotReaders { readonly getNodeNeighborhood: (nodeId: number, options?: { hops?: number }) => NeighborhoodResult; } +/** + * Selected-spec-bound dependencies for the Brunch graph extension. + * + * The shell pre-binds these to the workspace's active spec (D61-L) so the + * agent-facing `commit_graph` / `read_graph` tools never receive `specId` + * from the LLM and cannot reach into another spec's graph truth. + */ export interface BrunchGraphDeps { + readonly specId: number; readonly commandExecutor: CommandExecutor; readonly snapshots: GraphSnapshotReaders; + readonly productUpdates?: ProductUpdatePublisher; } // --------------------------------------------------------------------------- @@ -42,7 +52,7 @@ export interface BrunchGraphDeps { // --------------------------------------------------------------------------- export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): void { - const { commandExecutor, snapshots } = deps; + const { specId, commandExecutor, snapshots } = deps; // ── commit_graph ──────────────────────────────────────────────────── pi.registerTool({ @@ -64,9 +74,12 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo parameters: CommitGraphParams, async execute(_toolCallId, params) { - const input = translateCommitGraph(params); + const input = translateCommitGraph(params, specId); const result = commandExecutor.commitGraph(input); const text = formatCommitGraphResult(result); + if (result.status === 'success') { + deps.productUpdates?.publish(graphMutationProductUpdates({ specId, lsn: result.lsn })); + } return { content: [{ type: 'text' as const, text }], diff --git a/src/.pi/extensions/graph/review-set-proposal.ts b/src/.pi/extensions/graph/review-set-proposal.ts index edc83344..1473ace5 100644 --- a/src/.pi/extensions/graph/review-set-proposal.ts +++ b/src/.pi/extensions/graph/review-set-proposal.ts @@ -68,8 +68,12 @@ 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; -export function translateReviewSetProposalToCommitGraph(proposal: ReviewSetProposalDraft): CommitGraphInput { +export function translateReviewSetProposalToCommitGraph( + proposal: ReviewSetProposalDraft, + specId: number, +): CommitGraphInput { return { + specId, nodes: proposal.entityDrafts.map( (draft): BatchNodeInput => ({ ref: draft.draftId, @@ -97,6 +101,7 @@ export function translateReviewSetProposalToCommitGraph(proposal: ReviewSetPropo export function validateReviewSetProposalPayload(options: { readonly proposal: ReviewSetProposalDraft; readonly commandExecutor: CommandExecutor; + readonly specId: number; }): ReviewSetProposalValidationResult { const diagnostics = validateReviewSetProposalDraft(options.proposal); if (diagnostics.length > 0) { @@ -104,7 +109,7 @@ export function validateReviewSetProposalPayload(options: { } const validation = options.commandExecutor.dryRunCommitGraph( - translateReviewSetProposalToCommitGraph(options.proposal), + translateReviewSetProposalToCommitGraph(options.proposal, options.specId), ); if (validation.status !== 'success') { return validation; diff --git a/src/.pi/extensions/operational-mode.ts b/src/.pi/extensions/operational-mode.ts index cabeca73..51285380 100644 --- a/src/.pi/extensions/operational-mode.ts +++ b/src/.pi/extensions/operational-mode.ts @@ -22,106 +22,38 @@ import { Text } from '@earendil-works/pi-tui'; const ELICIT_BLOCKED_TOOLS = ['bash', 'edit', 'write'] as const; type ElicitBlockedToolName = (typeof ELICIT_BLOCKED_TOOLS)[number]; -export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = 'brunch.agent_runtime_state'; - -export type OperationalModeId = 'elicit'; -export type AgentRoleId = 'elicitor'; -export type AutoAxisSelection = 'auto'; -export type AgentStrategyId = - | 'step-wise-decision-tree' - | 'step-wise-disambiguate' - | 'propose-graph' - | 'project-graph'; -export type AgentStrategySelection = AutoAxisSelection | AgentStrategyId; -export type AgentLensId = 'intent' | 'design' | 'oracle'; -export type AgentLensSelection = AutoAxisSelection | AgentLensId; -export type AgentGoalId = 'grounding-advance' | 'elicit-expand' | 'commit-converge' | 'capture-posture'; -export type AgentGoalSelection = AutoAxisSelection | AgentGoalId; -export type ToolPolicyId = 'elicit-read-only'; -export type PromptPackId = 'brunch-base' | 'elicit' | 'elicitor'; -export type ModelPreference = 'default'; -export type ThinkingLevel = 'low' | 'medium' | 'high'; - -export interface BrunchAgentState { - schemaVersion: 1; - operationalMode: OperationalModeId; - agentStrategy: AgentStrategySelection; - agentLens: AgentLensSelection; - agentGoal: AgentGoalSelection; -} - -export interface OperationalModeDefinition { - id: OperationalModeId; - defaultRole: AgentRoleId; - allowedRoles: readonly AgentRoleId[]; - toolPolicyId: ToolPolicyId; - promptPackIds: readonly PromptPackId[]; -} - -export interface AgentRoleDefinition { - id: AgentRoleId; - operationalMode: OperationalModeId; - defaultStrategy: AgentStrategySelection; - allowedStrategies: readonly AgentStrategyId[]; - defaultLens: AgentLensSelection; - allowedLenses: readonly AgentLensId[]; - defaultGoal: AgentGoalSelection; - allowedGoals: readonly AgentGoalId[]; - promptPackIds: readonly PromptPackId[]; - modelPreference?: ModelPreference; - thinkingLevel?: ThinkingLevel; -} - -export interface ResolvedBrunchAgentState extends BrunchAgentState { - agentRole: AgentRoleId; - operationalModeDefinition: OperationalModeDefinition; - agentRoleDefinition: AgentRoleDefinition; -} - -export interface BrunchAgentStateEntryData { - schemaVersion: 1; - reason: 'init' | 'switch'; - state: BrunchAgentState; - previous?: BrunchAgentState; - source: 'system' | 'user' | 'agent' | 'extension'; -} - -export const DEFAULT_BRUNCH_AGENT_STATE: BrunchAgentState = { - schemaVersion: 1, - operationalMode: 'elicit', - agentStrategy: 'auto', - agentLens: 'auto', - agentGoal: 'grounding-advance', -}; - -export const OPERATIONAL_MODE_DEFINITIONS: Record = { - elicit: { - id: 'elicit', - defaultRole: 'elicitor', - allowedRoles: ['elicitor'], - toolPolicyId: 'elicit-read-only', - promptPackIds: ['brunch-base', 'elicit'], - }, -}; - -export const AGENT_ROLE_DEFINITIONS: Record = { - elicitor: { - id: 'elicitor', - operationalMode: 'elicit', - defaultStrategy: 'auto', - allowedStrategies: [ - 'step-wise-decision-tree', - 'step-wise-disambiguate', - 'propose-graph', - 'project-graph', - ], - defaultLens: 'auto', - allowedLenses: ['intent', 'design', 'oracle'], - defaultGoal: 'grounding-advance', - allowedGoals: ['grounding-advance', 'elicit-expand', 'commit-converge', 'capture-posture'], - promptPackIds: ['elicitor'], - }, -}; +export { + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + DEFAULT_BRUNCH_AGENT_STATE, + appendBrunchAgentRuntimeInit, + appendBrunchAgentRuntimeSwitch, + projectBrunchAgentState, + type AgentGoalId, + type AgentGoalSelection, + type AgentLensId, + type AgentLensSelection, + type AgentRoleId, + type AgentRoleDefinition, + type AgentStrategyId, + type AgentStrategySelection, + type AutoAxisSelection, + type BrunchAgentState, + type BrunchAgentStateEntryData, + type BrunchAgentStateEntrySessionManager, + type ModelPreference, + type OperationalModeDefinition, + type OperationalModeId, + type PromptPackId, + type ResolvedBrunchAgentState, + type ThinkingLevel, + type ToolPolicyId, +} from '../../session/runtime-state.js'; +import { + appendBrunchAgentRuntimeInit, + projectBrunchAgentState, + type ResolvedBrunchAgentState, + type BrunchAgentStateEntrySessionManager, +} from '../../session/runtime-state.js'; interface CustomEntryLike { type?: unknown; @@ -129,155 +61,6 @@ interface CustomEntryLike { data?: unknown; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isOneOf(value: unknown, allowed: readonly T[]): value is T { - return typeof value === 'string' && allowed.includes(value as T); -} - -function isAxisSelection( - value: unknown, - allowed: readonly T[], -): value is AutoAxisSelection | T { - return value === 'auto' || isOneOf(value, allowed); -} - -function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { - if (!isRecord(value)) return undefined; - const operationalModes = Object.keys(OPERATIONAL_MODE_DEFINITIONS) as OperationalModeId[]; - - if (value.schemaVersion !== 1) return undefined; - if (!isOneOf(value.operationalMode, operationalModes)) return undefined; - if ('agentRole' in value) return undefined; - - const mode = OPERATIONAL_MODE_DEFINITIONS[value.operationalMode]; - const role = AGENT_ROLE_DEFINITIONS[mode.defaultRole]; - if (!isAxisSelection(value.agentStrategy, role.allowedStrategies)) return undefined; - if (!isAxisSelection(value.agentLens, role.allowedLenses)) return undefined; - if (!isAxisSelection(value.agentGoal, role.allowedGoals)) return undefined; - - return { - schemaVersion: 1, - operationalMode: value.operationalMode, - agentStrategy: value.agentStrategy, - agentLens: value.agentLens, - agentGoal: value.agentGoal, - }; -} - -function parseBrunchAgentStateEntryData(value: unknown): BrunchAgentStateEntryData | undefined { - if (!isRecord(value)) return undefined; - if (value.schemaVersion !== 1) return undefined; - if (value.reason !== 'init' && value.reason !== 'switch') return undefined; - if ( - value.source !== 'system' && - value.source !== 'user' && - value.source !== 'agent' && - value.source !== 'extension' - ) { - return undefined; - } - const state = parseBrunchAgentState(value.state); - if (!state) return undefined; - const previous = value.previous === undefined ? undefined : parseBrunchAgentState(value.previous); - if (value.previous !== undefined && !previous) return undefined; - - return { - schemaVersion: 1, - reason: value.reason, - state, - ...(previous ? { previous } : {}), - source: value.source, - }; -} - -function resolveBrunchAgentState(state: BrunchAgentState): ResolvedBrunchAgentState { - const operationalModeDefinition = OPERATIONAL_MODE_DEFINITIONS[state.operationalMode]; - const agentRole = operationalModeDefinition.defaultRole; - return { - ...state, - agentRole, - operationalModeDefinition, - agentRoleDefinition: AGENT_ROLE_DEFINITIONS[agentRole], - }; -} - -function latestValidBrunchAgentStateEntryData( - entries: readonly CustomEntryLike[], -): BrunchAgentStateEntryData | undefined { - let latest: BrunchAgentStateEntryData | undefined; - - for (const entry of entries) { - if (entry.type !== 'custom' || entry.customType !== BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE) { - continue; - } - const data = parseBrunchAgentStateEntryData(entry.data); - if (data) latest = data; - } - - return latest; -} - -export function projectBrunchAgentState(entries: readonly CustomEntryLike[]): ResolvedBrunchAgentState { - return resolveBrunchAgentState( - latestValidBrunchAgentStateEntryData(entries)?.state ?? DEFAULT_BRUNCH_AGENT_STATE, - ); -} - -export interface BrunchAgentStateEntrySessionManager { - getEntries(): readonly CustomEntryLike[]; - appendCustomEntry(customType: string, data: BrunchAgentStateEntryData): string; -} - -function requireValidBrunchAgentState(state: BrunchAgentState): BrunchAgentState { - const valid = parseBrunchAgentState(state); - if (!valid) { - throw new Error('Invalid BrunchAgentState runtime selection.'); - } - return valid; -} - -export function appendBrunchAgentRuntimeInit( - sessionManager: BrunchAgentStateEntrySessionManager, - source: BrunchAgentStateEntryData['source'] = 'extension', -): string | undefined { - if (latestValidBrunchAgentStateEntryData(sessionManager.getEntries())) { - return undefined; - } - - return sessionManager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { - schemaVersion: 1, - reason: 'init', - state: DEFAULT_BRUNCH_AGENT_STATE, - source, - }); -} - -export function appendBrunchAgentRuntimeSwitch( - sessionManager: BrunchAgentStateEntrySessionManager, - state: BrunchAgentState, - source: BrunchAgentStateEntryData['source'] = 'user', -): string { - const validState = requireValidBrunchAgentState(state); - const previous = projectBrunchAgentState(sessionManager.getEntries()); - - return sessionManager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { - schemaVersion: 1, - reason: 'switch', - state: validState, - previous: { - schemaVersion: previous.schemaVersion, - operationalMode: previous.operationalMode, - agentStrategy: previous.agentStrategy, - agentLens: previous.agentLens, - agentGoal: previous.agentGoal, - }, - source, - }); -} - function shortenPath(path: string): string { const home = homedir(); if (path.startsWith(home)) return `~${path.slice(home.length)}`; @@ -493,7 +276,7 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { pi.on('session_start', async (_event, ctx) => { if (supportsBrunchAgentStateEntries(ctx?.sessionManager)) { - appendBrunchAgentRuntimeInit(ctx.sessionManager); + appendBrunchAgentRuntimeInit(ctx.sessionManager as BrunchAgentStateEntrySessionManager); } const state = projectBrunchAgentStateFromSessionManager(ctx?.sessionManager); applyBrunchToolPolicy(pi, state); diff --git a/src/README.md b/src/README.md index e7b9b5fa..8210c1dd 100644 --- a/src/README.md +++ b/src/README.md @@ -55,7 +55,7 @@ Rules: The session-domain files (workspace-session-coordinator, session-binding, session-projection-reader, brunch-session-envelope, session-transcript, -elicitation-exchange, structured-exchange, project-identity) now live in +exchange-projection, structured-exchange, project-identity) now live in `src/session/`; `brunch-pi-profile.ts` in `src/.pi/`; `web-host` in `src/rpc/`; the React client in `src/web/` (formerly `web-client/`); shared test helpers in `src/probes/`. The active workspace file is `.brunch/workspace.json` diff --git a/src/brunch-tui.test.ts b/src/brunch-tui.test.ts index 7233d276..dbc7236c 100644 --- a/src/brunch-tui.test.ts +++ b/src/brunch-tui.test.ts @@ -31,7 +31,9 @@ import { createBrunchAgentSessionRuntimeFactory, runBrunchTui, } from './brunch-tui.js'; +import { openWorkspaceGraphRuntime } from './graph/index.js'; import { userMessage } from './probes/test-helpers.js'; +import { createProductUpdatePublisher } from './rpc/product-updates.js'; import { createWorkspaceSessionCoordinator, verifyWorkspaceSessionStores, @@ -90,6 +92,80 @@ describe('Brunch TUI boot', () => { } }); + it('binds graph tools to the coordinator current spec when the runtime factory is reused after a switch', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-tui-graph-switch-')); + const agentDir = await mkdtemp(join(tmpdir(), 'brunch-agent-dir-')); + const coordinator = createWorkspaceSessionCoordinator({ cwd }); + const first = await coordinator.createSetupSession({ + specTitle: 'First spec', + createNewSpec: true, + }); + const productUpdates = createProductUpdatePublisher(); + const observedUpdates: Array = []; + const unsubscribe = productUpdates.subscribe((updates) => { + observedUpdates.push(updates); + }); + const createRuntime = createBrunchAgentSessionRuntimeFactory({ + workspace: first, + coordinator, + productUpdates, + }); + const second = await coordinator.createSetupSession({ + specTitle: 'Second spec', + createNewSpec: true, + }); + + const created = await createRuntime({ + cwd, + agentDir, + sessionManager: second.session.manager, + }); + + try { + const commitGraph = created.session.getToolDefinition('commit_graph') as + | { + execute: ( + id: string, + params: unknown, + signal?: AbortSignal, + onUpdate?: unknown, + ctx?: unknown, + ) => unknown; + } + | undefined; + expect(commitGraph).toBeDefined(); + + await commitGraph!.execute( + 'commit-after-switch', + { + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Second current goal' }], + edges: [], + }, + undefined, + undefined, + undefined, + ); + + const graph = await openWorkspaceGraphRuntime(cwd); + expect(graph.forSpec(first.spec.id).getGraphOverview().nodeCount).toBe(0); + expect( + graph + .forSpec(second.spec.id) + .getGraphOverview() + .nodes.map((node) => node.title), + ).toEqual(['Second current goal']); + expect(observedUpdates).toEqual([ + [ + { topic: 'graph.overview', specId: second.spec.id, lsn: expect.any(Number) }, + { topic: 'graph.nodeNeighborhood', specId: second.spec.id, lsn: expect.any(Number) }, + ], + ]); + } finally { + unsubscribe(); + created.session.dispose(); + } + }); + it('runs inspect, preflight, and activation before launching interactive mode', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); @@ -130,6 +206,128 @@ describe('Brunch TUI boot', () => { expect(events).toEqual(['inspect', 'preflight', 'activate:continue', 'launch:session-ready']); }); + it('starts a web sidecar on the active spec route with the shared update publisher before interactive mode', async () => { + const events: string[] = []; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + let sharedPublisher: + | { + publish(update: unknown): void; + subscribe(listener: (updates: readonly unknown[]) => void): () => void; + } + | undefined; + + await runBrunchTui({ + cwd: '/tmp/project', + coordinator: { + inspectWorkspace: async () => { + events.push('inspect'); + return { + cwd: '/tmp/project', + currentSpec: workspace.spec, + currentSessionFile: workspace.session.file, + needsNewSpec: false, + specs: [], + unavailableSessions: [], + }; + }, + activateWorkspace: async (decision) => { + events.push(`activate:${decision.action}`); + return workspace; + }, + bindCurrentSpecToReplacementSession: async () => workspace, + }, + runWorkspaceDialogPreflight: async () => { + events.push('preflight'); + return { + action: 'continue', + specId: workspace.spec.id, + sessionFile: workspace.session.file, + }; + }, + webSidecarRunner: async ({ cwd, productUpdates, routePath }) => { + events.push(`sidecar:${cwd}:${routePath}`); + sharedPublisher = productUpdates; + const unsubscribe = productUpdates.subscribe((updates) => { + events.push(`update:${updates[0]?.topic}`); + }); + return { + url: 'http://127.0.0.1:49152', + async close() { + unsubscribe(); + events.push('sidecar-close'); + }, + }; + }, + launchInteractive: async ({ productUpdates }) => { + events.push('launch'); + expect(productUpdates).toBe(sharedPublisher); + productUpdates!.publish({ topic: 'graph.overview', specId: 1, lsn: 11 }); + }, + }); + + expect(events).toEqual([ + 'inspect', + 'preflight', + 'activate:continue', + 'sidecar:/tmp/project:/spec/1', + 'launch', + 'update:graph.overview', + 'sidecar-close', + ]); + }); + + it('can disable browser auto-open while still advertising the active spec sidecar route', async () => { + const events: string[] = []; + const workspace = readyWorkspace('/tmp/project', 'session-ready'); + + 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 ({ routePath }) => { + events.push(`sidecar:${routePath}`); + return { + url: 'http://127.0.0.1:49152', + async close() { + events.push('sidecar-close'); + }, + }; + }, + openBrowser: async (url) => { + events.push(`open:${url}`); + }, + advertiseWebSidecar: (url) => { + events.push(`advertise:${url}`); + }, + launchInteractive: async () => { + events.push('launch'); + }, + }); + + expect(events).toEqual([ + 'sidecar:/spec/1', + 'advertise:http://127.0.0.1:49152/spec/1', + 'launch', + 'sidecar-close', + ]); + }); + it('does not launch interactive mode when startup preflight is cancelled', async () => { const events: string[] = []; const workspace = readyWorkspace('/tmp/project', 'session-ready'); diff --git a/src/brunch-tui.ts b/src/brunch-tui.ts index 34afc30d..c4d25c78 100644 --- a/src/brunch-tui.ts +++ b/src/brunch-tui.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import process from 'node:process'; import { @@ -13,10 +14,13 @@ import { applyBrunchOfflineDefault, createBrunchPiProfile } from './.pi/brunch-p import { runWorkspaceDialogPreflight } from './.pi/components/workspace-dialog.js'; import { chromeStateForWorkspace, createBrunchPiExtensionShell } from './.pi/pi-extension-shell.js'; import { openWorkspaceGraphRuntime } from './graph/index.js'; +import { createProductUpdatePublisher, type ProductUpdatePublisher } from './rpc/product-updates.js'; +import { startWebHost, type RunningWebHost } from './rpc/web-host.js'; import { createWorkspaceSessionCoordinator, type WorkspaceLaunchInventory, type WorkspaceSessionBoundaryCoordinator, + type WorkspaceSessionCoordinator, type WorkspaceSessionReadyState, type SpecSessionActivationCoordinator, type SpecSessionActivationDecision, @@ -45,9 +49,19 @@ export { runWorkspaceDialogPreflight } from './.pi/components/workspace-dialog.j export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator; +export interface BrunchWebSidecarRunnerOptions { + cwd: string; + coordinator: BrunchTuiCoordinator; + productUpdates: ProductUpdatePublisher; + routePath: string; +} + +export type BrunchWebSidecar = Pick; + export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState; coordinator: BrunchTuiCoordinator; + productUpdates?: ProductUpdatePublisher; } export interface BrunchTuiOptions { @@ -58,12 +72,17 @@ export interface BrunchTuiOptions { inventory: WorkspaceLaunchInventory, ) => Promise; launchInteractive?: (context: BrunchTuiLaunchContext) => Promise; + webSidecarRunner?: (options: BrunchWebSidecarRunnerOptions) => Promise; + autoOpen?: boolean; + openBrowser?: (url: string) => Promise; + advertiseWebSidecar?: (url: string) => void; } export async function runBrunchTui(options: BrunchTuiOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const coordinator = options.coordinator ?? createWorkspaceSessionCoordinator({ cwd }); + const productUpdates = createProductUpdatePublisher(); const inventory = await coordinator.inspectWorkspace(); const decision = await chooseSpecSessionActivationDecision(inventory, options); const workspaceState = await coordinator.activateWorkspace(decision); @@ -75,10 +94,29 @@ export async function runBrunchTui(options: BrunchTuiOptions = {}): Promise { + const currentWorkspace = await coordinator.bindCurrentSpecToReplacementSession(sessionManager); const graph = await openWorkspaceGraphRuntime(cwd); + // Bind graph snapshot readers to the coordinator's current spec (D61-L). + // The same runtime factory can be reused after /brunch switches sessions, + // so never close over the spec that happened to launch the factory. + const specId = currentWorkspace.spec.id; + const graphDeps = { + specId, + commandExecutor: graph.commandExecutor, + snapshots: graph.forSpec(specId), + ...(productUpdates ? { productUpdates } : {}), + }; const profile = createBrunchPiProfile({ cwd, agentDir: runtimeAgentDir, extensionFactories: [ createBrunchPiExtensionShell( - chromeStateForWorkspace(workspace), + chromeStateForWorkspace(currentWorkspace), async (replacementSessionManager) => { await coordinator.bindCurrentSpecToReplacementSession(replacementSessionManager); }, - { coordinator, graph }, + { coordinator, graph: graphDeps }, ), ], }); @@ -132,6 +181,36 @@ export function createBrunchAgentSessionRuntimeFactory({ }; } +async function startDefaultWebSidecar({ + cwd, + coordinator, + productUpdates, +}: BrunchWebSidecarRunnerOptions): Promise { + const host = await startWebHost({ + cwd, + coordinator: coordinator as WorkspaceSessionCoordinator, + productUpdates, + }); + return host; +} + +function webSidecarRoutePath(specId: number): string { + return `/spec/${specId}`; +} + +function advertiseWebSidecar(url: string): void { + process.stdout.write(`Brunch web sidecar listening on ${url}\n`); +} + +async function openBrowser(url: string): Promise { + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(command, [url], { + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + async function launchPiInteractive(context: BrunchTuiLaunchContext): Promise { const agentDir = getAgentDir(); const createRuntime = createBrunchAgentSessionRuntimeFactory(context); diff --git a/src/brunch.test.ts b/src/brunch.test.ts index 51faf787..c384a0c6 100644 --- a/src/brunch.test.ts +++ b/src/brunch.test.ts @@ -90,6 +90,22 @@ describe('Brunch CLI dispatch', () => { expect(launchedWith).toMatchObject({ cwd: '/tmp/brunch-project' }); }); + it('routes empty argv to the TUI launch path', async () => { + let launchedTui = false; + + const code = await runBrunchCli({ + argv: [], + cwd: '/tmp/brunch-project', + coordinator: coordinator(), + launchTui: async () => { + launchedTui = true; + }, + }); + + expect(code).toBe(0); + expect(launchedTui).toBe(true); + }); + it('routes --mode print through the coordinator snapshot and exits', async () => { let output = ''; @@ -125,7 +141,7 @@ describe('Brunch CLI dispatch', () => { argv: ['--mode=rpc'], cwd: '/tmp/brunch-project', coordinator: coordinator(manager.getSessionFile()!), - stdin: rpcRequest('session.elicitationExchanges', 2), + stdin: rpcRequest('session.exchanges', 2), stdout, }); @@ -140,6 +156,68 @@ describe('Brunch CLI dispatch', () => { }); }); + it('shares one product update publisher between RPC handlers and the stdio line server', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-cli-rpc-updates-')); + const workspace = await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ + specTitle: 'RPC updates', + }); + const stdout = new PassThrough(); + const chunks = collectStream(stdout); + + const code = await runBrunchCli({ + argv: ['--mode=rpc'], + cwd, + coordinator: createWorkspaceSessionCoordinator({ cwd }), + stdin: rpcRequest('session.triggerExchange', 7), + stdout, + }); + + const messages = chunks + .join('') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); + + expect(code).toBe(0); + expect(messages).toContainEqual({ + jsonrpc: '2.0', + method: 'brunch.updated', + params: { + topics: [ + 'workspace.snapshot', + 'session.pendingExchange', + 'session.exchanges', + 'session.runtimeState', + ], + updates: [ + { topic: 'workspace.snapshot', specId: workspace.spec.id, sessionId: workspace.session.id }, + { + topic: 'session.pendingExchange', + specId: workspace.spec.id, + sessionId: workspace.session.id, + }, + { + topic: 'session.exchanges', + specId: workspace.spec.id, + sessionId: workspace.session.id, + }, + { + topic: 'session.runtimeState', + specId: workspace.spec.id, + sessionId: workspace.session.id, + }, + ], + }, + }); + expect(messages).toContainEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 7, + result: expect.objectContaining({ status: 'pending' }), + }), + ); + }); + it('routes --mode rpc through the named JSON-RPC stdio adapter', async () => { const stdout = new PassThrough(); const chunks = collectStream(stdout); diff --git a/src/brunch.ts b/src/brunch.ts index daf9ceb4..e0a0b3dd 100644 --- a/src/brunch.ts +++ b/src/brunch.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { runBrunchTui } from './brunch-tui.js'; import { renderWorkspaceSnapshot, workspaceSnapshotFromState } from './print-snapshot.js'; import { createRpcHandlers, runJsonRpcLineServer } from './rpc/handlers.js'; +import { createProductUpdatePublisher } from './rpc/product-updates.js'; import { startWebHost } from './rpc/web-host.js'; import { createWorkspaceSessionCoordinator, @@ -23,6 +24,7 @@ export interface BrunchCliOptions { stdin?: Readable; stdout?: Writable | ((chunk: string) => void); webHostRunner?: (options: WebHostRunnerOptions) => Promise; + launchTui?: typeof runBrunchTui; } export async function runBrunchCli(options: BrunchCliOptions = {}): Promise { @@ -39,10 +41,12 @@ export async function runBrunchCli(options: BrunchCliOptions = {}): Promise arg.startsWith('--auto-open=')); + if (!autoOpenEquals) { + return true; + } + return autoOpenEquals.slice('--auto-open='.length) !== 'false'; +} + async function main(): Promise { process.exitCode = await runBrunchCli(); } diff --git a/src/db/schema.ts b/src/db/schema.ts index ce17cbdb..0083b225 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -71,6 +71,9 @@ export const specs = sqliteTable('specs', { export const nodes = sqliteTable('nodes', { id: integer().primaryKey({ autoIncrement: true }), + spec_id: integer() + .notNull() + .references(() => specs.id), plane: text({ enum: ['intent', 'oracle', 'design', 'plan'] }).notNull(), kind: text().notNull(), // validated at domain layer against plane-specific enum title: text().notNull(), @@ -84,6 +87,9 @@ export const nodes = sqliteTable('nodes', { export const edges = sqliteTable('edges', { id: integer().primaryKey({ autoIncrement: true }), + spec_id: integer() + .notNull() + .references(() => specs.id), category: text({ enum: EDGE_CATEGORIES }).notNull(), source_id: integer() .notNull() @@ -114,6 +120,9 @@ export const changeLog = sqliteTable('change_log', { export const reconciliationNeed = sqliteTable('reconciliation_need', { id: integer().primaryKey({ autoIncrement: true }), + spec_id: integer() + .notNull() + .references(() => specs.id), // target is {kind:'edge', edgeId} or {kind:'node_pair', aId, bId} target_kind: text({ enum: ['edge', 'node_pair'] }).notNull(), target_edge_id: integer().references(() => edges.id), diff --git a/src/graph/README.md b/src/graph/README.md index ec0672e3..bdf008ec 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -133,8 +133,10 @@ seam. The desired shape is documented here so future splits preserve topology. ## Known near-term schema pressure -- Add spec scoping before stable `graph.*` RPC / multi-spec UI projections. - The current table set has `specs`, but graph rows are not yet scoped to a spec. +- Keep spec scoping mandatory for stable `graph.*` RPC / multi-spec UI projections: + graph rows and graph-adjacent reconciliation needs are spec-owned, and + remaining graph read/write surfaces must preserve explicit selected-spec + authority. - Keep `coherence_state` deferred until its durable semantics are defined. - Begin consuming `db/row-schemas.ts` at persistence-facing validation seams; do not use row schemas as public RPC or agent-tool object contracts. diff --git a/src/graph/command-executor.test.ts b/src/graph/command-executor.test.ts index 8cf47b54..d87fa18e 100644 --- a/src/graph/command-executor.test.ts +++ b/src/graph/command-executor.test.ts @@ -5,10 +5,11 @@ * Scope card: CommandExecutor skeleton with single-node proof-of-life */ +import { eq } from 'drizzle-orm'; import { describe, expect, it, beforeEach } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; -import { graphClock, changeLog, edges, nodes, specs } from '../db/schema.js'; +import { graphClock, changeLog, edges, nodes, reconciliationNeed, specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; import type { CommitGraphInput } from './command-executor.js'; @@ -19,10 +20,15 @@ function createTestDb(): BrunchDb { describe('CommandExecutor', () => { let db: BrunchDb; let executor: CommandExecutor; + let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); + db.insert(specs) + .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) + .run(); + specId = db.select({ id: specs.id }).from(specs).get()!.id; }); // --- graph_clock initialization --- @@ -37,6 +43,7 @@ describe('CommandExecutor', () => { it('creates a valid intent node and returns success with nodeId and lsn', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'requirement', title: 'System must be offline-capable', @@ -50,11 +57,7 @@ describe('CommandExecutor', () => { }); it("defaults basis to 'explicit' when omitted", () => { - executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'Some goal', - }); + executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Some goal' }); const row = db.select().from(nodes).all()[0]; expect(row!.basis).toBe('explicit'); @@ -62,6 +65,7 @@ describe('CommandExecutor', () => { it('stores optional body and source fields', () => { executor.createNode({ + specId, plane: 'intent', kind: 'context', title: 'Target market', @@ -76,6 +80,7 @@ describe('CommandExecutor', () => { it('creates a decision node with required detail', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'decision', title: 'Use SQLite for persistence', @@ -96,6 +101,7 @@ describe('CommandExecutor', () => { it('creates a term node with required detail', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'term', title: 'Reconciliation Need', @@ -116,6 +122,7 @@ describe('CommandExecutor', () => { it('rejects invalid kind for plane', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'check', // oracle-plane kind, not intent title: 'Wrong plane', @@ -127,11 +134,7 @@ describe('CommandExecutor', () => { }); it('rejects decision without detail', () => { - const result = executor.createNode({ - plane: 'intent', - kind: 'decision', - title: 'Some decision', - }); + const result = executor.createNode({ specId, plane: 'intent', kind: 'decision', title: 'Some decision' }); expect(result.status).toBe('structural_illegal'); if (result.status !== 'structural_illegal') throw new Error('unreachable'); @@ -139,11 +142,7 @@ describe('CommandExecutor', () => { }); it('rejects term without detail', () => { - const result = executor.createNode({ - plane: 'intent', - kind: 'term', - title: 'Some term', - }); + const result = executor.createNode({ specId, plane: 'intent', kind: 'term', title: 'Some term' }); expect(result.status).toBe('structural_illegal'); if (result.status !== 'structural_illegal') throw new Error('unreachable'); @@ -152,6 +151,7 @@ describe('CommandExecutor', () => { it('rejects non-decision/term node with detail present', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'requirement', title: 'Some requirement', @@ -165,6 +165,7 @@ describe('CommandExecutor', () => { it('rejects decision with empty rejected array', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'decision', title: 'Bad decision', @@ -182,6 +183,7 @@ describe('CommandExecutor', () => { it('rejects decision detail with unknown fields', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'decision', title: 'Leaky decision', @@ -201,16 +203,8 @@ describe('CommandExecutor', () => { // --- LSN / graph_clock --- it('increments graph_clock atomically per command', () => { - executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'First', - }); - executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'Second', - }); + executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'First' }); + executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Second' }); const [clock] = db.select().from(graphClock).all(); expect(clock!.lsn).toBe(2); @@ -218,6 +212,7 @@ describe('CommandExecutor', () => { it('assigns matching created_at_lsn and updated_at_lsn on new nodes', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'assumption', title: 'Pi exposes enough seams', @@ -232,11 +227,7 @@ describe('CommandExecutor', () => { it('LSN is strictly monotonic across multiple creates', () => { const lsns: number[] = []; for (let i = 0; i < 10; i++) { - const result = executor.createNode({ - plane: 'intent', - kind: 'context', - title: `Context ${i}`, - }); + const result = executor.createNode({ specId, plane: 'intent', kind: 'context', title: `Context ${i}` }); if (result.status !== 'success') throw new Error('unreachable'); lsns.push(result.lsn); } @@ -249,11 +240,7 @@ describe('CommandExecutor', () => { // --- change_log --- it('appends exactly one change_log entry per successful command', () => { - executor.createNode({ - plane: 'intent', - kind: 'requirement', - title: 'Must persist', - }); + executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'Must persist' }); const logs = db.select().from(changeLog).all(); expect(logs).toHaveLength(1); @@ -262,6 +249,7 @@ describe('CommandExecutor', () => { it('change_log payload contains nodeId, plane, and kind', () => { const result = executor.createNode({ + specId, plane: 'intent', kind: 'invariant', title: 'LSN monotonicity', @@ -276,11 +264,7 @@ describe('CommandExecutor', () => { }); it("change_log.lsn matches the command's allocated LSN", () => { - const result = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'Test', - }); + const result = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Test' }); if (result.status !== 'success') throw new Error('unreachable'); const [log] = db.select().from(changeLog).all(); @@ -291,6 +275,7 @@ describe('CommandExecutor', () => { it('writes nothing on validation failure (no LSN bump, no change_log)', () => { executor.createNode({ + specId, plane: 'intent', kind: 'check', // invalid kind for intent plane title: 'Should fail', @@ -306,6 +291,7 @@ describe('CommandExecutor', () => { it('creates oracle-plane nodes', () => { const result = executor.createNode({ + specId, plane: 'oracle', kind: 'check', title: 'Verify LSN monotonicity', @@ -315,21 +301,13 @@ describe('CommandExecutor', () => { }); it('creates design-plane nodes', () => { - const result = executor.createNode({ - plane: 'design', - kind: 'module', - title: 'CommandExecutor', - }); + const result = executor.createNode({ specId, plane: 'design', kind: 'module', title: 'CommandExecutor' }); expect(result.status).toBe('success'); }); it('creates plan-plane nodes', () => { - const result = executor.createNode({ - plane: 'plan', - kind: 'slice', - title: 'M4 skeleton', - }); + const result = executor.createNode({ specId, plane: 'plan', kind: 'slice', title: 'M4 skeleton' }); expect(result.status).toBe('success'); }); @@ -351,7 +329,7 @@ describe('CommandExecutor', () => { expect(result.specId).toBeTypeOf('number'); expect(result.lsn).toBe(1); - const row = db.select().from(specs).all()[0]!; + const row = db.select().from(specs).where(eq(specs.id, result.specId)).get()!; expect(row.id).toBe(result.specId); expect(row.name).toBe('Brunch POC'); expect(row.slug).toBe('brunch-poc'); @@ -411,6 +389,7 @@ describe('CommandExecutor', () => { it('creates multiple nodes + edges in one transaction with one LSN', () => { const input: CommitGraphInput = { + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'requirement', title: 'Req A' }, { ref: 'n2', plane: 'intent', kind: 'constraint', title: 'Con B' }, @@ -433,6 +412,7 @@ describe('CommandExecutor', () => { it('resolves intra-batch refs to real NodeIds', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'a', plane: 'intent', kind: 'assumption', title: 'A1' }, { @@ -458,14 +438,11 @@ describe('CommandExecutor', () => { it('resolves existing-node refs to verified NodeIds', () => { // Pre-create a node - const pre = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'Existing goal', - }); + const pre = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Existing goal' }); if (pre.status !== 'success') throw new Error('unreachable'); const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'requirement', title: 'New req' }], edges: [ { @@ -486,6 +463,7 @@ describe('CommandExecutor', () => { it('returns nodes mapping and edges array in success result', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'x', plane: 'intent', kind: 'context', title: 'Ctx' }, { ref: 'y', plane: 'intent', kind: 'thesis', title: 'Thesis' }, @@ -502,6 +480,7 @@ describe('CommandExecutor', () => { it('appends one change_log entry for the entire batch', () => { executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'goal', title: 'G1' }, { ref: 'n2', plane: 'intent', kind: 'goal', title: 'G2' }, @@ -521,6 +500,7 @@ describe('CommandExecutor', () => { it('rejects edge with invalid category', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'goal', title: 'G' }, { ref: 'n2', plane: 'intent', kind: 'goal', title: 'G2' }, @@ -535,6 +515,7 @@ describe('CommandExecutor', () => { it('rejects proof edge without stance', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'criterion', title: 'Cr' }, { ref: 'n2', plane: 'intent', kind: 'invariant', title: 'Inv' }, @@ -549,6 +530,7 @@ describe('CommandExecutor', () => { it('rejects support edge without stance', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'context', title: 'Ctx' }, { ref: 'n2', plane: 'intent', kind: 'requirement', title: 'Req' }, @@ -561,6 +543,7 @@ describe('CommandExecutor', () => { it('rejects non-proof/non-support edge with stance', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'assumption', title: 'A' }, { ref: 'n2', plane: 'intent', kind: 'requirement', title: 'R' }, @@ -575,6 +558,7 @@ describe('CommandExecutor', () => { it('rejects edge referencing non-existent existing node', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'G' }], edges: [{ category: 'dependency', source: { existing: 9999 }, target: 'n1' }], }); @@ -586,6 +570,7 @@ describe('CommandExecutor', () => { it('rejects edge with unresolvable intra-batch ref', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'G' }], edges: [{ category: 'dependency', source: 'n1', target: 'missing_ref' }], }); @@ -597,6 +582,7 @@ describe('CommandExecutor', () => { it('rejects self-loop edge', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'G' }], edges: [{ category: 'association', source: 'n1', target: 'n1' }], }); @@ -610,6 +596,7 @@ describe('CommandExecutor', () => { it('rejects batch node with invalid kind-for-plane', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'check', title: 'Wrong' }], edges: [], }); @@ -621,6 +608,7 @@ describe('CommandExecutor', () => { it('rejects batch decision without detail', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'decision', title: 'D' }], edges: [], }); @@ -632,6 +620,7 @@ describe('CommandExecutor', () => { it('if any node fails validation, entire batch rejected — nothing written', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Valid' }, { ref: 'n2', plane: 'intent', kind: 'check', title: 'Invalid kind' }, @@ -647,6 +636,7 @@ describe('CommandExecutor', () => { it('if any edge fails validation, no nodes written', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Valid goal' }, { ref: 'n2', plane: 'intent', kind: 'context', title: 'Valid ctx' }, @@ -665,6 +655,7 @@ describe('CommandExecutor', () => { it('diagnostics include which entry failed', () => { const result = executor.commitGraph({ + specId, nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'OK' }], edges: [{ category: 'dependency', source: 'n1', target: { existing: 9999 } }], }); @@ -676,19 +667,12 @@ describe('CommandExecutor', () => { // --- edge cases --- it('edge-only batch between existing nodes', () => { - const a = executor.createNode({ - plane: 'intent', - kind: 'requirement', - title: 'R1', - }); - const b = executor.createNode({ - plane: 'intent', - kind: 'assumption', - title: 'A1', - }); + const a = executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'R1' }); + const b = executor.createNode({ specId, plane: 'intent', kind: 'assumption', title: 'A1' }); if (a.status !== 'success' || b.status !== 'success') throw new Error('unreachable'); const result = executor.commitGraph({ + specId, nodes: [], edges: [ { @@ -707,6 +691,7 @@ describe('CommandExecutor', () => { it('node-only batch (no edges)', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'context', title: 'C1' }, { ref: 'n2', plane: 'intent', kind: 'context', title: 'C2' }, @@ -721,21 +706,40 @@ describe('CommandExecutor', () => { }); it('empty batch → structural_illegal', () => { - const result = executor.commitGraph({ nodes: [], edges: [] }); + const result = executor.commitGraph({ specId, nodes: [], edges: [] }); expect(result.status).toBe('structural_illegal'); }); + it('dry-run rejects nonexistent spec before review-set proposals can be surfaced', () => { + const missingSpecId = specId + 10_000; + const input: CommitGraphInput = { + specId: missingSpecId, + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Missing spec goal' }], + edges: [], + }; + + const dryRun = executor.dryRunCommitGraph(input); + const commit = executor.commitGraph(input); + + expect(dryRun).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'specId' }], + }); + expect(commit).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'specId' }], + }); + expect(db.select().from(nodes).all()).toHaveLength(0); + }); + // --- mixed refs --- it('edges can mix intra-batch source with existing target', () => { - const pre = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'Existing', - }); + const pre = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Existing' }); if (pre.status !== 'success') throw new Error('unreachable'); const result = executor.commitGraph({ + specId, nodes: [{ ref: 'new', plane: 'intent', kind: 'requirement', title: 'New' }], edges: [ { @@ -757,6 +761,7 @@ describe('CommandExecutor', () => { it('uses one LSN for the entire batch (not per-entity)', () => { const result = executor.commitGraph({ + specId, nodes: [ { ref: 'n1', plane: 'intent', kind: 'goal', title: 'G1' }, { ref: 'n2', plane: 'intent', kind: 'goal', title: 'G2' }, @@ -783,6 +788,7 @@ describe('CommandExecutor', () => { it('creates a recon need targeting an edge and returns success with id and lsn', () => { // Seed a node and edge first const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -794,6 +800,7 @@ describe('CommandExecutor', () => { const edgeId = batch.edges[0]!; const result = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId }, needKind: 'edge_revalidation', reason: 'upstream assumption changed', @@ -807,6 +814,7 @@ describe('CommandExecutor', () => { it('creates a recon need targeting a node pair', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'r2', plane: 'intent', kind: 'requirement', title: 'R2' }, @@ -819,6 +827,7 @@ describe('CommandExecutor', () => { const bId = batch.nodes['r2']!; const result = executor.createReconciliationNeed({ + specId, target: { kind: 'node_pair', aId, bId }, needKind: 'possible_duplicate', }); @@ -830,6 +839,7 @@ describe('CommandExecutor', () => { it('rejects edge target with non-existent edgeId', () => { const result = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId: 999 }, needKind: 'edge_revalidation', }); @@ -840,15 +850,12 @@ describe('CommandExecutor', () => { }); it('rejects node_pair target with non-existent nodeId', () => { - const n = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'G1', - }); + const n = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'G1' }); expect(n.status).toBe('success'); if (n.status !== 'success') throw new Error('unreachable'); const result = executor.createReconciliationNeed({ + specId, target: { kind: 'node_pair', aId: n.nodeId, bId: 999 }, needKind: 'possible_relation', }); @@ -859,22 +866,15 @@ describe('CommandExecutor', () => { }); it('allocates a new LSN for each recon need', () => { - const n = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'G1', - }); + const n = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'G1' }); expect(n.status).toBe('success'); if (n.status !== 'success') throw new Error('unreachable'); - const n2 = executor.createNode({ - plane: 'intent', - kind: 'goal', - title: 'G2', - }); + const n2 = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'G2' }); expect(n2.status).toBe('success'); if (n2.status !== 'success') throw new Error('unreachable'); const r1 = executor.createReconciliationNeed({ + specId, target: { kind: 'node_pair', aId: n.nodeId, bId: n2.nodeId }, needKind: 'possible_relation', }); @@ -882,6 +882,7 @@ describe('CommandExecutor', () => { if (r1.status !== 'success') throw new Error('unreachable'); const r2 = executor.createReconciliationNeed({ + specId, target: { kind: 'node_pair', aId: n.nodeId, bId: n2.nodeId }, needKind: 'semantic_conflict', }); @@ -897,6 +898,7 @@ describe('CommandExecutor', () => { describe('resolveReconciliationNeed', () => { it('resolves an open need and records resolvedAtLsn', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -907,25 +909,64 @@ describe('CommandExecutor', () => { if (batch.status !== 'success') throw new Error('unreachable'); const create = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId: batch.edges[0]! }, needKind: 'edge_revalidation', }); expect(create.status).toBe('success'); if (create.status !== 'success') throw new Error('unreachable'); - const resolve = executor.resolveReconciliationNeed(create.id); + const resolve = executor.resolveReconciliationNeed({ specId, id: create.id }); expect(resolve.status).toBe('success'); if (resolve.status !== 'success') throw new Error('unreachable'); expect(resolve.lsn).toBeGreaterThan(create.lsn); }); it('rejects non-existent need id', () => { - const result = executor.resolveReconciliationNeed(999); + const result = executor.resolveReconciliationNeed({ specId, id: 999 }); expect(result.status).toBe('structural_illegal'); }); + it('rejects a need id that belongs to another spec without resolving it', () => { + const otherSpec = executor.createSpec({ name: 'Other Spec', slug: 'other-spec' }); + expect(otherSpec.status).toBe('success'); + if (otherSpec.status !== 'success') throw new Error('unreachable'); + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, + { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, + ], + edges: [{ category: 'dependency', source: 'r1', target: 'a1' }], + }); + expect(batch.status).toBe('success'); + if (batch.status !== 'success') throw new Error('unreachable'); + const create = executor.createReconciliationNeed({ + specId, + target: { kind: 'edge', edgeId: batch.edges[0]! }, + needKind: 'edge_revalidation', + }); + expect(create.status).toBe('success'); + if (create.status !== 'success') throw new Error('unreachable'); + + const wrongSpecResolve = executor.resolveReconciliationNeed({ + specId: otherSpec.specId, + id: create.id, + }); + + expect(wrongSpecResolve.status).toBe('structural_illegal'); + expect( + db + .select({ status: reconciliationNeed.status, resolvedAtLsn: reconciliationNeed.resolved_at_lsn }) + .from(reconciliationNeed) + .where(eq(reconciliationNeed.id, create.id)) + .get(), + ).toEqual({ status: 'open', resolvedAtLsn: null }); + }); + it('rejects already-resolved need', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -936,16 +977,17 @@ describe('CommandExecutor', () => { if (batch.status !== 'success') throw new Error('unreachable'); const create = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId: batch.edges[0]! }, needKind: 'edge_revalidation', }); expect(create.status).toBe('success'); if (create.status !== 'success') throw new Error('unreachable'); - const resolve1 = executor.resolveReconciliationNeed(create.id); + const resolve1 = executor.resolveReconciliationNeed({ specId, id: create.id }); expect(resolve1.status).toBe('success'); - const resolve2 = executor.resolveReconciliationNeed(create.id); + const resolve2 = executor.resolveReconciliationNeed({ specId, id: create.id }); expect(resolve2.status).toBe('structural_illegal'); if (resolve2.status !== 'structural_illegal') throw new Error('unreachable'); expect(resolve2.diagnostics[0]!.message).toContain('already resolved'); diff --git a/src/graph/command-executor.ts b/src/graph/command-executor.ts index 2062b155..214c00b6 100644 --- a/src/graph/command-executor.ts +++ b/src/graph/command-executor.ts @@ -17,7 +17,7 @@ * even though pre-M6 policy classification is minimal. */ -import { eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import type { BrunchDb } from '../db/connection.js'; import * as schema from '../db/schema.js'; @@ -164,6 +164,7 @@ export interface UpdateReadinessGradeInput { /** Input for creating a single graph node. */ export interface CreateNodeInput { + readonly specId: number; readonly plane: NodePlane; readonly kind: string; readonly title: string; @@ -195,11 +196,18 @@ export type ReconNeedTarget = ReconNeedTargetEdge | ReconNeedTargetNodePair; /** Input for creating a reconciliation need. */ export interface CreateReconNeedInput { + readonly specId: number; readonly target: ReconNeedTarget; readonly needKind: string; readonly reason?: string | undefined; } +/** Input for resolving a reconciliation need. */ +export interface ResolveReconNeedInput { + readonly specId: number; + readonly id: number; +} + // --------------------------------------------------------------------------- // Batch input types (commitGraph) // --------------------------------------------------------------------------- @@ -231,6 +239,7 @@ export interface BatchEdgeInput { /** Input for the commitGraph atomic batch mutation. */ export interface CommitGraphInput { + readonly specId: number; readonly nodes: readonly BatchNodeInput[]; readonly edges: readonly BatchEdgeInput[]; } @@ -400,6 +409,8 @@ function validateAndResolveBatchEdge( index: number, refMap: ReadonlyMap, existingNodeIds: ReadonlySet, + crossSpecExisting: ReadonlySet, + specId: number, ): EdgeValidationResult { const diagnostics: Diagnostic[] = []; const p = `edges[${index}]`; @@ -446,7 +457,12 @@ function validateAndResolveBatchEdge( } } else { resolvedSourceId = input.source.existing; - if (!existingNodeIds.has(resolvedSourceId)) { + if (crossSpecExisting.has(resolvedSourceId)) { + diagnostics.push({ + field: `${p}.source`, + message: `existing node ${resolvedSourceId} belongs to a different spec (command spec ${specId})`, + }); + } else if (!existingNodeIds.has(resolvedSourceId)) { diagnostics.push({ field: `${p}.source`, message: `existing node ${resolvedSourceId} not found`, @@ -466,7 +482,12 @@ function validateAndResolveBatchEdge( } } else { resolvedTargetId = input.target.existing; - if (!existingNodeIds.has(resolvedTargetId)) { + if (crossSpecExisting.has(resolvedTargetId)) { + diagnostics.push({ + field: `${p}.target`, + message: `existing node ${resolvedTargetId} belongs to a different spec (command spec ${specId})`, + }); + } else if (!existingNodeIds.has(resolvedTargetId)) { diagnostics.push({ field: `${p}.target`, message: `existing node ${resolvedTargetId} not found`, @@ -627,7 +648,7 @@ export class CommandExecutor { * Create a single graph node. * * Validates structurally, then executes inside one transaction: - * allocate LSN → insert node → append change_log → return result. + * verify spec exists → allocate LSN → insert node → append change_log → return result. * * On validation failure, nothing is written. */ @@ -638,7 +659,20 @@ export class CommandExecutor { } return this.db.transaction((tx) => { - // 1. Allocate LSN (atomic increment) + // 1. Verify spec exists + const specRow = tx + .select({ id: schema.specs.id }) + .from(schema.specs) + .where(eq(schema.specs.id, input.specId)) + .get(); + if (!specRow) { + return { + status: 'structural_illegal' as const, + diagnostics: [{ field: 'specId', message: `spec ${input.specId} does not exist` }], + }; + } + + // 2. Allocate LSN (atomic increment) const clock = tx .update(schema.graphClock) .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) @@ -647,10 +681,11 @@ export class CommandExecutor { .get(); const lsn = clock!.lsn; - // 2. Insert node + // 3. Insert node const node = tx .insert(schema.nodes) .values({ + spec_id: input.specId, plane: input.plane, kind: input.kind, title: input.title, @@ -665,13 +700,14 @@ export class CommandExecutor { .get(); const nodeId = node!.id; - // 3. Append change_log + // 4. Append change_log tx.insert(schema.changeLog) .values({ lsn, operation: 'create_node', payload: JSON.stringify({ nodeId, + specId: input.specId, plane: input.plane, kind: input.kind, }), @@ -698,7 +734,8 @@ export class CommandExecutor { * * One transaction, one LSN. Intra-batch refs (strings) resolve to * just-inserted NodeIds; existing refs ({ existing: id }) are verified - * against the database. All-or-nothing: if any entry fails structural + * against the database AND must belong to the command's spec + * (D61-L spec ownership). All-or-nothing: if any entry fails structural * validation, the entire batch is rejected (I34-L). */ commitGraph(input: CommitGraphInput): CommitGraphResult { @@ -710,7 +747,19 @@ export class CommandExecutor { // --- Transaction: insert nodes, resolve refs, validate + insert edges --- try { return this.db.transaction((tx) => { - // 1. Allocate ONE LSN + // 1. Verify spec exists + const specRow = tx + .select({ id: schema.specs.id }) + .from(schema.specs) + .where(eq(schema.specs.id, input.specId)) + .get(); + if (!specRow) { + throw new BatchValidationError([ + { field: 'specId', message: `spec ${input.specId} does not exist` }, + ]); + } + + // 2. Allocate ONE LSN const clock = tx .update(schema.graphClock) .set({ lsn: sql`${schema.graphClock.lsn} + 1` }) @@ -719,12 +768,13 @@ export class CommandExecutor { .get(); const lsn = clock!.lsn; - // 2. Insert all nodes, build ref → id map + // 3. Insert all nodes, build ref → id map const refMap = new Map(); for (const bn of input.nodes) { const row = tx .insert(schema.nodes) .values({ + spec_id: input.specId, plane: bn.plane, kind: bn.kind, title: bn.title, @@ -740,7 +790,7 @@ export class CommandExecutor { refMap.set(bn.ref, row!.id); } - // 3. Collect and verify existing-node references + // 4. Collect and verify existing-node references — must be same spec const existingRefs = new Set(); for (const edge of input.edges) { if (typeof edge.source !== 'string') existingRefs.add(edge.source.existing); @@ -748,21 +798,35 @@ export class CommandExecutor { } const verifiedExisting = new Set(); + const crossSpecExisting = new Set(); if (existingRefs.size > 0) { const rows = tx - .select({ id: schema.nodes.id }) + .select({ id: schema.nodes.id, spec_id: schema.nodes.spec_id }) .from(schema.nodes) .where(inArray(schema.nodes.id, [...existingRefs])) .all(); - for (const row of rows) verifiedExisting.add(row.id); + for (const row of rows) { + if (row.spec_id === input.specId) { + verifiedExisting.add(row.id); + } else { + crossSpecExisting.add(row.id); + } + } } - // 4. Validate and resolve all edges + // 5. Validate and resolve all edges const edgeDiagnostics: Diagnostic[] = []; const resolvedEdges: ResolvedEdge[] = []; for (let i = 0; i < input.edges.length; i++) { - const result = validateAndResolveBatchEdge(input.edges[i]!, i, refMap, verifiedExisting); + const result = validateAndResolveBatchEdge( + input.edges[i]!, + i, + refMap, + verifiedExisting, + crossSpecExisting, + input.specId, + ); edgeDiagnostics.push(...result.diagnostics); if (result.resolved) resolvedEdges.push(result.resolved); } @@ -771,12 +835,13 @@ export class CommandExecutor { throw new BatchValidationError(edgeDiagnostics); } - // 5. Insert all edges + // 6. Insert all edges const edgeIds: number[] = []; for (const re of resolvedEdges) { const row = tx .insert(schema.edges) .values({ + spec_id: input.specId, category: re.category, source_id: re.sourceId, target_id: re.targetId, @@ -791,12 +856,13 @@ export class CommandExecutor { edgeIds.push(row!.id); } - // 6. Append one change_log entry for the entire batch + // 7. Append one change_log entry for the entire batch tx.insert(schema.changeLog) .values({ lsn, operation: 'commit_graph', payload: JSON.stringify({ + specId: input.specId, nodes: Object.fromEntries(refMap), edges: edgeIds, }), @@ -825,6 +891,16 @@ export class CommandExecutor { return diagnostics; } + const specRow = this.db + .select({ id: schema.specs.id }) + .from(schema.specs) + .where(eq(schema.specs.id, input.specId)) + .get(); + if (!specRow) { + diagnostics.push({ field: 'specId', message: `spec ${input.specId} does not exist` }); + return diagnostics; + } + const refMap = new Map(); for (let i = 0; i < input.nodes.length; i++) { const bn = input.nodes[i]!; @@ -836,7 +912,8 @@ export class CommandExecutor { } refMap.set(bn.ref, -(i + 1)); - for (const diagnostic of validateCreateNode(bn)) { + // Node validation reuses createNode rules; specId comes from the batch. + for (const diagnostic of validateCreateNode({ ...bn, specId: input.specId })) { diagnostics.push({ field: `nodes[${i}].${diagnostic.field}`, message: diagnostic.message, @@ -852,18 +929,32 @@ export class CommandExecutor { } const verifiedExisting = new Set(); + const crossSpecExisting = new Set(); if (existingRefs.size > 0) { const rows = this.db - .select({ id: schema.nodes.id }) + .select({ id: schema.nodes.id, spec_id: schema.nodes.spec_id }) .from(schema.nodes) .where(inArray(schema.nodes.id, [...existingRefs])) .all(); - for (const row of rows) verifiedExisting.add(row.id); + for (const row of rows) { + if (row.spec_id === input.specId) { + verifiedExisting.add(row.id); + } else { + crossSpecExisting.add(row.id); + } + } } for (let i = 0; i < input.edges.length; i++) { diagnostics.push( - ...validateAndResolveBatchEdge(input.edges[i]!, i, refMap, verifiedExisting).diagnostics, + ...validateAndResolveBatchEdge( + input.edges[i]!, + i, + refMap, + verifiedExisting, + crossSpecExisting, + input.specId, + ).diagnostics, ); } return diagnostics; @@ -876,13 +967,23 @@ export class CommandExecutor { * inside one transaction with LSN allocation and change_log append. */ createReconciliationNeed(input: CreateReconNeedInput): CreateReconNeedResult { - // Validate target references exist + // Validate spec + target references exist and share the same spec. return this.db.transaction((tx) => { const diagnostics: Diagnostic[] = []; + const specRow = tx + .select({ id: schema.specs.id }) + .from(schema.specs) + .where(eq(schema.specs.id, input.specId)) + .get(); + if (!specRow) { + diagnostics.push({ field: 'specId', message: `spec ${input.specId} does not exist` }); + return { status: 'structural_illegal' as const, diagnostics }; + } + if (input.target.kind === 'edge') { const row = tx - .select({ id: schema.edges.id }) + .select({ id: schema.edges.id, spec_id: schema.edges.spec_id }) .from(schema.edges) .where(eq(schema.edges.id, input.target.edgeId)) .get(); @@ -891,30 +992,30 @@ export class CommandExecutor { field: 'target.edgeId', message: `edge ${input.target.edgeId} does not exist`, }); - } - } else { - const aRow = tx - .select({ id: schema.nodes.id }) - .from(schema.nodes) - .where(eq(schema.nodes.id, input.target.aId)) - .get(); - if (!aRow) { - diagnostics.push({ - field: 'target.aId', - message: `node ${input.target.aId} does not exist`, - }); - } - const bRow = tx - .select({ id: schema.nodes.id }) - .from(schema.nodes) - .where(eq(schema.nodes.id, input.target.bId)) - .get(); - if (!bRow) { + } else if (row.spec_id !== input.specId) { diagnostics.push({ - field: 'target.bId', - message: `node ${input.target.bId} does not exist`, + field: 'target.edgeId', + message: `edge ${input.target.edgeId} belongs to a different spec (command spec ${input.specId})`, }); } + } else { + const checkNode = (id: number, field: 'target.aId' | 'target.bId'): void => { + const row = tx + .select({ id: schema.nodes.id, spec_id: schema.nodes.spec_id }) + .from(schema.nodes) + .where(eq(schema.nodes.id, id)) + .get(); + if (!row) { + diagnostics.push({ field, message: `node ${id} does not exist` }); + } else if (row.spec_id !== input.specId) { + diagnostics.push({ + field, + message: `node ${id} belongs to a different spec (command spec ${input.specId})`, + }); + } + }; + checkNode(input.target.aId, 'target.aId'); + checkNode(input.target.bId, 'target.bId'); } if (diagnostics.length > 0) { @@ -934,6 +1035,7 @@ export class CommandExecutor { const row = tx .insert(schema.reconciliationNeed) .values({ + spec_id: input.specId, target_kind: input.target.kind, target_edge_id: input.target.kind === 'edge' ? input.target.edgeId : null, target_a_id: input.target.kind === 'node_pair' ? input.target.aId : null, @@ -952,6 +1054,7 @@ export class CommandExecutor { operation: 'create_reconciliation_need', payload: JSON.stringify({ id: row!.id, + specId: input.specId, target: input.target, kind: input.needKind, }), @@ -968,12 +1071,17 @@ export class CommandExecutor { * Sets status to "resolved" and records the resolvedAtLsn. * Rejects if the need does not exist or is already resolved. */ - resolveReconciliationNeed(id: number): ResolveReconNeedResult { + resolveReconciliationNeed(input: ResolveReconNeedInput): ResolveReconNeedResult { return this.db.transaction((tx) => { const existing = tx .select() .from(schema.reconciliationNeed) - .where(eq(schema.reconciliationNeed.id, id)) + .where( + and( + eq(schema.reconciliationNeed.id, input.id), + eq(schema.reconciliationNeed.spec_id, input.specId), + ), + ) .get(); if (!existing) { @@ -982,7 +1090,7 @@ export class CommandExecutor { diagnostics: [ { field: 'id', - message: `reconciliation need ${id} does not exist`, + message: `reconciliation need ${input.id} does not exist for spec ${input.specId}`, }, ], }; @@ -994,7 +1102,7 @@ export class CommandExecutor { diagnostics: [ { field: 'id', - message: `reconciliation need ${id} is already resolved`, + message: `reconciliation need ${input.id} is already resolved`, }, ], }; @@ -1012,7 +1120,12 @@ export class CommandExecutor { // Update status tx.update(schema.reconciliationNeed) .set({ status: 'resolved', resolved_at_lsn: lsn }) - .where(eq(schema.reconciliationNeed.id, id)) + .where( + and( + eq(schema.reconciliationNeed.id, input.id), + eq(schema.reconciliationNeed.spec_id, input.specId), + ), + ) .run(); // Append change_log @@ -1020,7 +1133,7 @@ export class CommandExecutor { .values({ lsn, operation: 'resolve_reconciliation_need', - payload: JSON.stringify({ id }), + payload: JSON.stringify({ id: input.id, specId: input.specId }), }) .run(); diff --git a/src/graph/index.ts b/src/graph/index.ts index 6498b8fb..af1f8be7 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -92,6 +92,7 @@ export type { ReconNeedResolveSuccess, ReconNeedSuccess, ReconNeedTarget, + ResolveReconNeedInput, ResolveReconNeedResult, SpecRecord, StructuralIllegal, diff --git a/src/graph/schema/edges.ts b/src/graph/schema/edges.ts index b88d8f14..66b846e5 100644 --- a/src/graph/schema/edges.ts +++ b/src/graph/schema/edges.ts @@ -69,6 +69,7 @@ export type EdgeBasis = (typeof NODE_BASES)[number]; */ export interface GraphEdge { readonly id: EdgeId; + readonly specId: number; readonly category: EdgeCategory; readonly sourceId: NodeId; readonly targetId: NodeId; diff --git a/src/graph/schema/nodes.ts b/src/graph/schema/nodes.ts index 12de3197..2ec99c36 100644 --- a/src/graph/schema/nodes.ts +++ b/src/graph/schema/nodes.ts @@ -125,6 +125,7 @@ export type NodeDetail = DecisionDetail | TermDetail; */ export interface GraphNode { readonly id: NodeId; + readonly specId: number; readonly plane: NodePlane; readonly kind: NodeKind; readonly title: string; diff --git a/src/graph/schema/reconciliation-need.ts b/src/graph/schema/reconciliation-need.ts index 495a0319..9482eae3 100644 --- a/src/graph/schema/reconciliation-need.ts +++ b/src/graph/schema/reconciliation-need.ts @@ -54,6 +54,7 @@ export type ReconciliationNeedTarget = export interface ReconciliationNeed { readonly id: string; + readonly specId: number; readonly kind: ReconciliationNeedKind; readonly target: ReconciliationNeedTarget; readonly rationale?: string; diff --git a/src/graph/snapshot.test.ts b/src/graph/snapshot.test.ts index 83e7fec1..f31829ed 100644 --- a/src/graph/snapshot.test.ts +++ b/src/graph/snapshot.test.ts @@ -10,6 +10,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; +import { specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; import { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './snapshot.js'; @@ -20,14 +21,19 @@ function createTestDb(): BrunchDb { describe('getGraphOverview', () => { let db: BrunchDb; let executor: CommandExecutor; + let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); + db.insert(specs) + .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) + .run(); + specId = db.select({ id: specs.id }).from(specs).get()!.id; }); it('returns empty arrays and zero counts on an empty graph', () => { - const overview = getGraphOverview(db); + const overview = getGraphOverview(db, specId); expect(overview.nodes).toEqual([]); expect(overview.edges).toEqual([]); expect(overview.nodeCount).toBe(0); @@ -36,14 +42,15 @@ describe('getGraphOverview', () => { }); it('returns current LSN from graph_clock', () => { - executor.createNode({ plane: 'intent', kind: 'goal', title: 'G1' }); - executor.createNode({ plane: 'intent', kind: 'thesis', title: 'T1' }); - const overview = getGraphOverview(db); + executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'G1' }); + executor.createNode({ specId, plane: 'intent', kind: 'thesis', title: 'T1' }); + const overview = getGraphOverview(db, specId); expect(overview.lsn).toBe(2); }); it('returns typed domain objects with parsed detail JSON', () => { executor.createNode({ + specId, plane: 'intent', kind: 'decision', title: 'Use SQLite', @@ -55,7 +62,7 @@ describe('getGraphOverview', () => { }, }); - const overview = getGraphOverview(db); + const overview = getGraphOverview(db, specId); expect(overview.nodes).toHaveLength(1); const node = overview.nodes[0]!; expect(node.id).toBeTypeOf('number'); @@ -75,6 +82,7 @@ describe('getGraphOverview', () => { it('returns nodes and edges with correct counts', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -83,7 +91,7 @@ describe('getGraphOverview', () => { }); expect(batch.status).toBe('success'); - const overview = getGraphOverview(db); + const overview = getGraphOverview(db, specId); expect(overview.nodeCount).toBe(2); expect(overview.edgeCount).toBe(1); expect(overview.nodes).toHaveLength(2); @@ -93,16 +101,13 @@ describe('getGraphOverview', () => { it('excludes superseded predecessors from overview', () => { // Create R_v0, then R_v1 that supersedes R_v0 - const r0 = executor.createNode({ - plane: 'intent', - kind: 'requirement', - title: 'R_offline_v0', - }); + const r0 = executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'R_offline_v0' }); expect(r0.status).toBe('success'); if (r0.status !== 'success') throw new Error('unreachable'); const r0Id = r0.nodeId; const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', @@ -121,7 +126,7 @@ describe('getGraphOverview', () => { }); expect(batch.status).toBe('success'); - const overview = getGraphOverview(db); + const overview = getGraphOverview(db, specId); // R_offline_v0 should be excluded (it is a superseded predecessor) const titles = overview.nodes.map((n) => n.title); expect(titles).toContain('R_offline_v1'); @@ -134,19 +139,25 @@ describe('getGraphOverview', () => { describe('getNodeNeighborhood', () => { let db: BrunchDb; let executor: CommandExecutor; + let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); + db.insert(specs) + .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) + .run(); + specId = db.select({ id: specs.id }).from(specs).get()!.id; }); it('returns error for non-existent nodeId', () => { - const result = getNodeNeighborhood(db, 999); + const result = getNodeNeighborhood(db, specId, 999); expect(result.status).toBe('not_found'); }); it('returns anchor node and directly connected nodes/edges at 1 hop (default)', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -161,7 +172,7 @@ describe('getNodeNeighborhood', () => { if (batch.status !== 'success') throw new Error('unreachable'); const r1Id = batch.nodes['r1']!; - const result = getNodeNeighborhood(db, r1Id); + const result = getNodeNeighborhood(db, specId, r1Id); expect(result.status).toBe('success'); if (result.status !== 'success') throw new Error('unreachable'); @@ -176,6 +187,7 @@ describe('getNodeNeighborhood', () => { it('reaches 2-hop neighbors', () => { // G1 -> R1 -> A1 (chain of depth 2 from G1) const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'g1', plane: 'intent', kind: 'goal', title: 'G1' }, { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, @@ -192,13 +204,13 @@ describe('getNodeNeighborhood', () => { const g1Id = batch.nodes['g1']!; // 1 hop: only R1 - const hop1 = getNodeNeighborhood(db, g1Id, { hops: 1 }); + const hop1 = getNodeNeighborhood(db, specId, g1Id, { hops: 1 }); expect(hop1.status).toBe('success'); if (hop1.status !== 'success') throw new Error('unreachable'); expect(hop1.neighbors.map((n) => n.title)).toEqual(['R1']); // 2 hops: R1 and A1 - const hop2 = getNodeNeighborhood(db, g1Id, { hops: 2 }); + const hop2 = getNodeNeighborhood(db, specId, g1Id, { hops: 2 }); expect(hop2.status).toBe('success'); if (hop2.status !== 'success') throw new Error('unreachable'); const titles = hop2.neighbors.map((n) => n.title).sort(); @@ -207,16 +219,13 @@ describe('getNodeNeighborhood', () => { it('excludes superseded predecessors from neighborhood (unless anchor)', () => { // R_v0 superseded by R_v1, with A1 depending on R_v1 - const r0 = executor.createNode({ - plane: 'intent', - kind: 'requirement', - title: 'R_v0', - }); + const r0 = executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'R_v0' }); expect(r0.status).toBe('success'); if (r0.status !== 'success') throw new Error('unreachable'); const r0Id = r0.nodeId; const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R_v1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -232,7 +241,7 @@ describe('getNodeNeighborhood', () => { const r1Id = batch.nodes['r1']!; // Neighborhood of R_v1: should include A1 but exclude R_v0 - const result = getNodeNeighborhood(db, r1Id); + const result = getNodeNeighborhood(db, specId, r1Id); expect(result.status).toBe('success'); if (result.status !== 'success') throw new Error('unreachable'); @@ -241,7 +250,7 @@ describe('getNodeNeighborhood', () => { expect(neighborTitles).not.toContain('R_v0'); // But if R_v0 is the anchor, it should still be returned - const r0Result = getNodeNeighborhood(db, r0Id); + const r0Result = getNodeNeighborhood(db, specId, r0Id); expect(r0Result.status).toBe('success'); if (r0Result.status !== 'success') throw new Error('unreachable'); expect(r0Result.anchor.title).toBe('R_v0'); @@ -249,6 +258,7 @@ describe('getNodeNeighborhood', () => { it('returns typed GraphNode and GraphEdge domain objects', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 't1', @@ -265,7 +275,7 @@ describe('getNodeNeighborhood', () => { if (batch.status !== 'success') throw new Error('unreachable'); const t1Id = batch.nodes['t1']!; - const result = getNodeNeighborhood(db, t1Id); + const result = getNodeNeighborhood(db, specId, t1Id); expect(result.status).toBe('success'); if (result.status !== 'success') throw new Error('unreachable'); @@ -286,19 +296,25 @@ describe('getNodeNeighborhood', () => { describe('getOpenReconciliationNeeds', () => { let db: BrunchDb; let executor: CommandExecutor; + let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); + db.insert(specs) + .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) + .run(); + specId = db.select({ id: specs.id }).from(specs).get()!.id; }); it('returns empty array when no needs exist', () => { - const needs = getOpenReconciliationNeeds(db); + const needs = getOpenReconciliationNeeds(db, specId); expect(needs).toEqual([]); }); it('returns open needs as typed domain objects', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -309,6 +325,7 @@ describe('getOpenReconciliationNeeds', () => { if (batch.status !== 'success') throw new Error('unreachable'); const create = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId: batch.edges[0]! }, needKind: 'edge_revalidation', reason: 'upstream changed', @@ -316,7 +333,7 @@ describe('getOpenReconciliationNeeds', () => { expect(create.status).toBe('success'); if (create.status !== 'success') throw new Error('unreachable'); - const needs = getOpenReconciliationNeeds(db); + const needs = getOpenReconciliationNeeds(db, specId); expect(needs).toHaveLength(1); expect(needs[0]!.kind).toBe('edge_revalidation'); expect(needs[0]!.target).toEqual({ kind: 'edge', edgeId: batch.edges[0]! }); @@ -326,6 +343,7 @@ describe('getOpenReconciliationNeeds', () => { it('excludes resolved needs', () => { const batch = executor.commitGraph({ + specId, nodes: [ { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, @@ -336,15 +354,16 @@ describe('getOpenReconciliationNeeds', () => { if (batch.status !== 'success') throw new Error('unreachable'); const create = executor.createReconciliationNeed({ + specId, target: { kind: 'edge', edgeId: batch.edges[0]! }, needKind: 'edge_revalidation', }); expect(create.status).toBe('success'); if (create.status !== 'success') throw new Error('unreachable'); - executor.resolveReconciliationNeed(create.id); + executor.resolveReconciliationNeed({ specId, id: create.id }); - const needs = getOpenReconciliationNeeds(db); + const needs = getOpenReconciliationNeeds(db, specId); expect(needs).toEqual([]); }); }); diff --git a/src/graph/snapshot.ts b/src/graph/snapshot.ts index fa641352..0ea1b6d7 100644 --- a/src/graph/snapshot.ts +++ b/src/graph/snapshot.ts @@ -9,7 +9,7 @@ * edge) are excluded per CATEGORY_POLICY projectionEffect. */ -import { eq, or, inArray } from 'drizzle-orm'; +import { and, eq, or, inArray } from 'drizzle-orm'; import type { BrunchDb } from '../db/connection.js'; import * as schema from '../db/schema.js'; @@ -58,6 +58,7 @@ export interface NeighborhoodOptions { function rowToNode(row: typeof schema.nodes.$inferSelect): GraphNode { return { id: row.id, + specId: row.spec_id, plane: row.plane as GraphNode['plane'], kind: row.kind as GraphNode['kind'], title: row.title, @@ -73,6 +74,7 @@ function rowToNode(row: typeof schema.nodes.$inferSelect): GraphNode { function rowToEdge(row: typeof schema.edges.$inferSelect): GraphEdge { const base = { id: row.id, + specId: row.spec_id, category: row.category as GraphEdge['category'], sourceId: row.source_id, targetId: row.target_id, @@ -97,12 +99,12 @@ function rowToEdge(row: typeof schema.edges.$inferSelect): GraphEdge { // Supersession helpers // --------------------------------------------------------------------------- -/** Return the set of node ids that are superseded predecessors. */ -function getSupersededIds(db: BrunchDb): Set { +/** Return the set of node ids that are superseded predecessors within a spec. */ +function getSupersededIds(db: BrunchDb, specId: number): Set { const rows = db .select({ targetId: schema.edges.target_id }) .from(schema.edges) - .where(eq(schema.edges.category, 'supersession')) + .where(and(eq(schema.edges.category, 'supersession'), eq(schema.edges.spec_id, specId))) .all(); return new Set(rows.map((r) => r.targetId)); } @@ -112,17 +114,17 @@ function getSupersededIds(db: BrunchDb): Set { // --------------------------------------------------------------------------- /** - * Cursory full-graph overview. + * Cursory selected-spec graph overview (D61-L). * - * Returns all accepted nodes and edges with current LSN. - * Superseded predecessors are excluded from the node list - * per CATEGORY_POLICY.supersession.projectionEffect. + * Returns all accepted nodes and edges for the given spec with current LSN. + * Superseded predecessors are excluded from the node list per + * CATEGORY_POLICY.supersession.projectionEffect. */ -export function getGraphOverview(db: BrunchDb): GraphOverview { - const supersededIds = getSupersededIds(db); +export function getGraphOverview(db: BrunchDb, specId: number): GraphOverview { + const supersededIds = getSupersededIds(db, specId); - const allNodeRows = db.select().from(schema.nodes).all(); - const allEdgeRows = db.select().from(schema.edges).all(); + const allNodeRows = db.select().from(schema.nodes).where(eq(schema.nodes.spec_id, specId)).all(); + const allEdgeRows = db.select().from(schema.edges).where(eq(schema.edges.spec_id, specId)).all(); const nodes = allNodeRows.filter((r) => !supersededIds.has(r.id)).map(rowToNode); @@ -145,31 +147,38 @@ export function getGraphOverview(db: BrunchDb): GraphOverview { // --------------------------------------------------------------------------- /** - * Neighborhood snapshot around a given node. + * Neighborhood snapshot around a given node, scoped to a single spec (D61-L). * - * Returns the anchor node, all reachable neighbors within `hops` - * distance (default 1), and the edges connecting them. - * Superseded predecessors are excluded from neighbors - * (unless the predecessor is the anchor itself). + * Returns `not_found` if the anchor does not exist or belongs to a different + * spec. Returns the anchor node, all reachable same-spec neighbors within + * `hops` distance (default 1), and the edges connecting them. Superseded + * predecessors are excluded from neighbors (unless the predecessor is the + * anchor itself). */ export function getNodeNeighborhood( db: BrunchDb, + specId: number, nodeId: number, options?: NeighborhoodOptions, ): NeighborhoodResult { const hops = options?.hops ?? 1; - // Verify anchor exists - const anchorRow = db.select().from(schema.nodes).where(eq(schema.nodes.id, nodeId)).get(); + // Verify anchor exists in the requested spec + const anchorRow = db + .select() + .from(schema.nodes) + .where(and(eq(schema.nodes.id, nodeId), eq(schema.nodes.spec_id, specId))) + .get(); if (!anchorRow) { return { status: 'not_found' }; } - const supersededIds = getSupersededIds(db); + const supersededIds = getSupersededIds(db, specId); const anchor = rowToNode(anchorRow); - // BFS traversal: collect reachable node ids within hop distance + // BFS traversal: collect reachable node ids within hop distance. + // Edges are spec-scoped, so endpoints discovered here are also spec-scoped. const visited = new Set([nodeId]); let frontier = new Set([nodeId]); const collectedEdgeIds = new Set(); @@ -177,12 +186,17 @@ export function getNodeNeighborhood( for (let hop = 0; hop < hops; hop++) { if (frontier.size === 0) break; - // Find all edges touching frontier nodes + // Find all edges touching frontier nodes (within this spec) const frontierArr = [...frontier]; const edgeRows = db .select() .from(schema.edges) - .where(or(inArray(schema.edges.source_id, frontierArr), inArray(schema.edges.target_id, frontierArr))) + .where( + and( + eq(schema.edges.spec_id, specId), + or(inArray(schema.edges.source_id, frontierArr), inArray(schema.edges.target_id, frontierArr)), + ), + ) .all(); const nextFrontier = new Set(); @@ -200,11 +214,15 @@ export function getNodeNeighborhood( frontier = nextFrontier; } - // Fetch neighbor nodes (exclude anchor) + // Fetch neighbor nodes (exclude anchor) — restrict to same spec defensively const neighborIds = [...visited].filter((id) => id !== nodeId); const neighborNodes: GraphNode[] = []; if (neighborIds.length > 0) { - const rows = db.select().from(schema.nodes).where(inArray(schema.nodes.id, neighborIds)).all(); + const rows = db + .select() + .from(schema.nodes) + .where(and(inArray(schema.nodes.id, neighborIds), eq(schema.nodes.spec_id, specId))) + .all(); neighborNodes.push(...rows.map(rowToNode)); } @@ -236,6 +254,7 @@ function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): Rec return { id: String(row.id), + specId: row.spec_id, kind: row.kind as ReconciliationNeed['kind'], target, ...(row.reason != null ? { rationale: row.reason } : {}), @@ -245,13 +264,13 @@ function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): Rec } /** - * Return all open (unresolved) reconciliation needs. + * Return all open (unresolved) reconciliation needs for a single spec. */ -export function getOpenReconciliationNeeds(db: BrunchDb): ReconciliationNeed[] { +export function getOpenReconciliationNeeds(db: BrunchDb, specId: number): ReconciliationNeed[] { const rows = db .select() .from(schema.reconciliationNeed) - .where(eq(schema.reconciliationNeed.status, 'open')) + .where(and(eq(schema.reconciliationNeed.status, 'open'), eq(schema.reconciliationNeed.spec_id, specId))) .all(); return rows.map(rowToReconNeed); } diff --git a/src/graph/spec-ownership.test.ts b/src/graph/spec-ownership.test.ts new file mode 100644 index 00000000..a4b4413e --- /dev/null +++ b/src/graph/spec-ownership.test.ts @@ -0,0 +1,222 @@ +/** + * Spec ownership isolation across the storage / command / reader / tool seam. + * + * SPEC: D61-L (each spec owns its own intent graph; no workspace-global graph), + * D52-L (graph/ owns the readers), D4-L/D20-L (CommandExecutor authority). + * + * This is the card 1 tracer for live-graph-observer--graph-rpc-spine: every + * graph projection and graph mutation targets exactly one spec. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { BrunchDb } from '../db/connection.js'; +import { createDb } from '../db/connection.js'; +import { CommandExecutor } from './command-executor.js'; +import { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './snapshot.js'; + +function freshDbWithTwoSpecs(): { + db: BrunchDb; + executor: CommandExecutor; + specA: number; + specB: number; +} { + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const a = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); + const b = executor.createSpec({ name: 'Spec B', slug: 'spec-b' }); + if (a.status !== 'success' || b.status !== 'success') { + throw new Error('failed to seed specs'); + } + return { db, executor, specA: a.specId, specB: b.specId }; +} + +describe('graph items are owned by spec', () => { + let db: BrunchDb; + let executor: CommandExecutor; + let specA: number; + let specB: number; + + beforeEach(() => { + ({ db, executor, specA, specB } = freshDbWithTwoSpecs()); + }); + + it('graph ownership isolation: each spec sees only its own nodes and edges', () => { + const commitA = executor.commitGraph({ + specId: specA, + nodes: [ + { ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }, + { ref: 'n2', plane: 'intent', kind: 'requirement', title: 'A requirement' }, + ], + edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], + }); + expect(commitA.status).toBe('success'); + + const commitB = executor.commitGraph({ + specId: specB, + nodes: [{ ref: 'm1', plane: 'intent', kind: 'goal', title: 'B goal' }], + edges: [], + }); + expect(commitB.status).toBe('success'); + + const overviewA = getGraphOverview(db, specA); + const overviewB = getGraphOverview(db, specB); + + expect(overviewA.nodeCount).toBe(2); + expect(overviewA.edgeCount).toBe(1); + expect(overviewA.nodes.every((n) => n.title.startsWith('A '))).toBe(true); + + expect(overviewB.nodeCount).toBe(1); + expect(overviewB.edgeCount).toBe(0); + expect(overviewB.nodes[0]!.title).toBe('B goal'); + }); + + it('existing-ref guard: commitGraph rejects an existing ref from another spec', () => { + const seed = executor.commitGraph({ + specId: specA, + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], + edges: [], + }); + if (seed.status !== 'success') throw new Error('seed failed'); + const aNodeId = seed.nodes['n1']!; + + const attempt = executor.commitGraph({ + specId: specB, + nodes: [{ ref: 'm1', plane: 'intent', kind: 'requirement', title: 'B req' }], + edges: [{ category: 'dependency', source: 'm1', target: { existing: aNodeId } }], + }); + + expect(attempt.status).toBe('structural_illegal'); + if (attempt.status === 'structural_illegal') { + const messages = attempt.diagnostics.map((d) => d.message).join(' | '); + expect(messages).toMatch(/spec/i); + } + + // Nothing was written for spec B + const overviewB = getGraphOverview(db, specB); + expect(overviewB.nodeCount).toBe(0); + }); + + it('endpoint guard: an edge cannot connect nodes from different specs', () => { + const seedA = executor.commitGraph({ + specId: specA, + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], + edges: [], + }); + const seedB = executor.commitGraph({ + specId: specB, + nodes: [{ ref: 'm1', plane: 'intent', kind: 'goal', title: 'B goal' }], + edges: [], + }); + if (seedA.status !== 'success' || seedB.status !== 'success') { + throw new Error('seed failed'); + } + const aNodeId = seedA.nodes['n1']!; + const bNodeId = seedB.nodes['m1']!; + + // Attempt edge across specs (both endpoints existing) + const attempt = executor.commitGraph({ + specId: specA, + nodes: [], + edges: [ + { + category: 'dependency', + source: { existing: aNodeId }, + target: { existing: bNodeId }, + }, + ], + }); + + expect(attempt.status).toBe('structural_illegal'); + }); + + it('reader guard: getNodeNeighborhood is not_found for a node owned by another spec', () => { + const seedA = executor.commitGraph({ + specId: specA, + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], + edges: [], + }); + if (seedA.status !== 'success') throw new Error('seed failed'); + const aNodeId = seedA.nodes['n1']!; + + const wrongSpec = getNodeNeighborhood(db, specB, aNodeId); + expect(wrongSpec.status).toBe('not_found'); + + const rightSpec = getNodeNeighborhood(db, specA, aNodeId); + expect(rightSpec.status).toBe('success'); + }); + + it('reconciliation needs are spec-scoped and reject cross-spec targets', () => { + const seedA = executor.commitGraph({ + specId: specA, + nodes: [ + { ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }, + { ref: 'n2', plane: 'intent', kind: 'requirement', title: 'A req' }, + ], + edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], + }); + const seedB = executor.commitGraph({ + specId: specB, + nodes: [{ ref: 'm1', plane: 'intent', kind: 'goal', title: 'B goal' }], + edges: [], + }); + if (seedA.status !== 'success' || seedB.status !== 'success') { + throw new Error('seed failed'); + } + const aEdgeId = seedA.edges[0]!; + const bNodeId = seedB.nodes['m1']!; + const aNodeId = seedA.nodes['n1']!; + + // Valid same-spec need + const ok = executor.createReconciliationNeed({ + specId: specA, + target: { kind: 'edge', edgeId: aEdgeId }, + needKind: 'staleness', + }); + expect(ok.status).toBe('success'); + + // Cross-spec node pair rejected + const crossPair = executor.createReconciliationNeed({ + specId: specA, + target: { kind: 'node_pair', aId: aNodeId, bId: bNodeId }, + needKind: 'contradiction', + }); + expect(crossPair.status).toBe('structural_illegal'); + + // Resolve scoped to spec + if (ok.status !== 'success') throw new Error('unreachable'); + const wrongSpecResolve = executor.resolveReconciliationNeed({ specId: specB, id: ok.id }); + expect(wrongSpecResolve.status).toBe('structural_illegal'); + + // Listing scoped to spec and wrong-spec resolve leaves the need open + const needsA = getOpenReconciliationNeeds(db, specA); + const needsB = getOpenReconciliationNeeds(db, specB); + expect(needsA).toHaveLength(1); + expect(needsB).toHaveLength(0); + + const rightSpecResolve = executor.resolveReconciliationNeed({ specId: specA, id: ok.id }); + expect(rightSpecResolve.status).toBe('success'); + expect(getOpenReconciliationNeeds(db, specA)).toHaveLength(0); + }); +}); + +describe('tool guard: agent-facing graph tool schemas do not expose specId', () => { + it('CommitGraphParams has no top-level specId field', async () => { + const mod = await import('../.pi/extensions/graph/tool-schemas.js'); + // Sinclair TypeBox object schemas store fields under `properties` + const schema = mod.CommitGraphParams as unknown as { + properties: Record; + }; + expect(Object.keys(schema.properties)).not.toContain('specId'); + expect(Object.keys(schema.properties)).not.toContain('spec_id'); + }); + + it('ReadGraphParams has no top-level specId field', async () => { + const mod = await import('../.pi/extensions/graph/tool-schemas.js'); + const schema = mod.ReadGraphParams as unknown as { + properties: Record; + }; + expect(Object.keys(schema.properties)).not.toContain('specId'); + expect(Object.keys(schema.properties)).not.toContain('spec_id'); + }); +}); diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index 5693a0b8..2a178bfd 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -9,21 +9,31 @@ import type { GraphOverview, NeighborhoodOptions, NeighborhoodResult } from './s const BRUNCH_DIR = '.brunch'; const DATA_DB_FILE = 'data.db'; +/** + * Spec-scoped snapshot readers. Returned by `WorkspaceGraphRuntime.forSpec` + * so callers (Pi extensions, RPC handlers, probes) interact with a single + * spec's graph without ever needing to thread `specId` through every call. + */ +export interface SpecScopedReaders { + readonly getGraphOverview: () => GraphOverview; + readonly getNodeNeighborhood: (nodeId: number, options?: NeighborhoodOptions) => NeighborhoodResult; +} + export interface WorkspaceGraphRuntime { readonly commandExecutor: CommandExecutor; - readonly snapshots: { - readonly getGraphOverview: () => GraphOverview; - readonly getNodeNeighborhood: (nodeId: number, options?: NeighborhoodOptions) => NeighborhoodResult; - }; + /** Bind snapshot readers to a single spec (D61-L). */ + readonly forSpec: (specId: number) => SpecScopedReaders; } export async function openWorkspaceGraphRuntime(cwd: string): Promise { const db = await openWorkspaceDb(cwd); return { commandExecutor: new CommandExecutor(db), - snapshots: { - getGraphOverview: () => getGraphOverview(db), - getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, nodeId, options), + forSpec(specId: number): SpecScopedReaders { + return { + getGraphOverview: () => getGraphOverview(db, specId), + getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, specId, nodeId, options), + }; }, }; } diff --git a/src/package-identity.test.ts b/src/package-identity.test.ts new file mode 100644 index 00000000..73a4b365 --- /dev/null +++ b/src/package-identity.test.ts @@ -0,0 +1,57 @@ +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +interface PackageJson { + name: string; + version: string; + bin: Record; +} + +function readPackageJson(): PackageJson { + const raw = readFileSync(join(repoRoot, 'package.json'), 'utf8'); + return JSON.parse(raw) as PackageJson; +} + +function parseMajorMinorPatch(version: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) throw new Error(`unparseable version: ${version}`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +describe('package identity', () => { + it('publishes as brunch-cli', () => { + const pkg = readPackageJson(); + expect(pkg.name).toBe('brunch-cli'); + }); + + it('declares a version of at least 0.1.0', () => { + const pkg = readPackageJson(); + const [major, minor] = parseMajorMinorPatch(pkg.version); + const atLeast010 = major > 0 || (major === 0 && minor >= 1); + expect(atLeast010, `version ${pkg.version} must be >= 0.1.0`).toBe(true); + }); + + it('exposes exactly one bin command, brunch-cli, with no brunch-next alias', () => { + const pkg = readPackageJson(); + expect(Object.keys(pkg.bin)).toEqual(['brunch-cli']); + expect(pkg.bin['brunch-cli']).toBe('./bin/brunch-cli.js'); + }); + + it('ships an executable bin shim at the declared path', () => { + const pkg = readPackageJson(); + const declaredPath = pkg.bin['brunch-cli']; + if (declaredPath === undefined) { + throw new Error('brunch-cli bin entry must be declared'); + } + const binPath = join(repoRoot, declaredPath); + const stat = statSync(binPath); + expect(stat.isFile()).toBe(true); + const ownerExecutable = (stat.mode & 0o100) !== 0; + expect(ownerExecutable, `${binPath} must be executable`).toBe(true); + }); +}); diff --git a/src/probes/propose-graph-commit-proof.test.ts b/src/probes/propose-graph-commit-proof.test.ts index 05afc999..8d3810ff 100644 --- a/src/probes/propose-graph-commit-proof.test.ts +++ b/src/probes/propose-graph-commit-proof.test.ts @@ -27,6 +27,7 @@ const successfulOverview: GraphOverview = { nodes: [ { id: 1, + specId: 1, plane: 'intent', kind: 'goal', title: 'Clarify launch readiness', @@ -36,6 +37,7 @@ const successfulOverview: GraphOverview = { }, { id: 2, + specId: 1, plane: 'intent', kind: 'requirement', title: 'Expose rollback criteria', @@ -47,6 +49,7 @@ const successfulOverview: GraphOverview = { edges: [ { id: 1, + specId: 1, category: 'dependency', sourceId: 2, targetId: 1, diff --git a/src/probes/propose-graph-commit-proof.ts b/src/probes/propose-graph-commit-proof.ts index 08f8b3f0..dd611873 100644 --- a/src/probes/propose-graph-commit-proof.ts +++ b/src/probes/propose-graph-commit-proof.ts @@ -129,6 +129,7 @@ export async function runProposeGraphCommitProof( const session = created.session; const friction = created.diagnostics.map((diagnostic) => `${diagnostic.type}: ${diagnostic.message}`); const graph = await openWorkspaceGraphRuntime(cwd); + const specSnapshots = graph.forSpec(workspace.spec.id); try { await session.sendUserMessage(prompt); @@ -142,7 +143,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: graph.snapshots.getGraphOverview(), + overview: specSnapshots.getGraphOverview(), prompt, ...(session.model?.id !== undefined ? { model: session.model.id } : {}), friction, @@ -159,7 +160,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: graph.snapshots.getGraphOverview(), + overview: specSnapshots.getGraphOverview(), prompt, ...(session.model?.id !== undefined ? { model: session.model.id } : {}), friction, diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index ebed1308..6b0f2d0c 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -38,7 +38,6 @@ describe('public Brunch RPC structured-exchange parity proof', () => { ]); expect(new Set(report.exchangeIds).size).toBe(3); expect(report.artifacts).toBeUndefined(); - expect(report.transcriptDisplayRows).toBeGreaterThanOrEqual(6); }); it('writes a reviewable artifact bundle when given a fixture root', async () => { diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 3df83afb..c7b6b338 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -38,15 +38,6 @@ interface RpcExchangeProjection { exchanges: RpcExchange[]; } -interface TranscriptDisplayRow { - role: string; - text: string; -} - -interface TranscriptDisplayProjection { - rows: TranscriptDisplayRow[]; -} - interface WorkspaceSelectionResult { requiresSelection: boolean; } @@ -83,7 +74,6 @@ export interface PublicRpcParityProofReport { sessionId: string; toolCoverage: string[]; exchangeIds: string[]; - transcriptDisplayRows: number; artifacts?: PublicRpcParityProofArtifacts; } @@ -187,11 +177,10 @@ export async function runPublicRpcParityProof( for (const method of [ 'workspace.selectionState', 'workspace.activate', - 'session.startElicitation', + 'session.triggerExchange', 'session.pendingExchange', - 'elicitation.respond', - 'session.elicitationExchanges', - 'session.transcriptDisplay', + 'session.submitExchangeResponse', + 'session.exchanges', ]) { if (!discovery.methods.some((entry) => entry.method === method)) { throw new Error(`rpc.discover did not include ${method}`); @@ -228,7 +217,7 @@ export async function runPublicRpcParityProof( await handlers.handle({ jsonrpc: '2.0', id: 10 + turn * 3, - method: 'session.startElicitation', + method: 'session.triggerExchange', }), ); const pending = success( @@ -239,7 +228,7 @@ export async function runPublicRpcParityProof( }), ); if (pending.exchange.exchangeId !== started.exchange.exchangeId) { - friction.push(`Turn ${turn + 1}: pendingExchange differed from startElicitation.`); + friction.push(`Turn ${turn + 1}: pendingExchange differed from triggerExchange.`); } if (started.exchange.mode !== 'text') { const richOption = started.exchange.options.find( @@ -257,7 +246,7 @@ export async function runPublicRpcParityProof( await handlers.handle({ jsonrpc: '2.0', id: 12 + turn * 3, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId: started.exchange.exchangeId, answer: response.answer, @@ -270,14 +259,7 @@ export async function runPublicRpcParityProof( await handlers.handle({ jsonrpc: '2.0', id: 50, - method: 'session.elicitationExchanges', - }), - ); - const display = success( - await handlers.handle({ - jsonrpc: '2.0', - id: 51, - method: 'session.transcriptDisplay', + method: 'session.exchanges', }), ); if (exchanges.exchanges.length !== PUBLIC_RPC_PARITY_PERMUTATION_COUNT) { @@ -380,7 +362,6 @@ export async function runPublicRpcParityProof( sessionId: workspace.session.id, toolCoverage, exchangeIds, - transcriptDisplayRows: display.rows.length, }; if (options.fixtureRoot !== undefined) { diff --git a/src/rpc/README.md b/src/rpc/README.md index 14b00a11..ce8f57d9 100644 --- a/src/rpc/README.md +++ b/src/rpc/README.md @@ -1,10 +1,21 @@ # Brunch public RPC -This directory owns Brunch's public JSON-RPC boundary. This README is the findable naming contract for RPC methods that product clients and designers should reason about. `memory/SPEC.md` records the architectural decision; this file names the concrete surface. +This directory owns Brunch's public JSON-RPC boundary. This README is the findable naming contract for RPC methods that product clients and designers should reason about. `memory/SPEC.md` records the architectural decision; this file names the concrete surface implemented by `rpc/`. ## Boundary -Brunch exposes one product RPC surface over stdio, WebSocket, and in-process handlers. Browser clients, CLI probes, TUI adapters, and future relays speak Brunch method names; they do not coordinate raw Pi RPC plus Brunch product RPC themselves. +Brunch exposes two handler surfaces over stdio, WebSocket, and in-process handlers: + +```pseudo +rpc handler surfaces: +├── full RPC host +│ ├── read methods +│ └── write methods +└── TUI-started web sidecar + └── read methods only +``` + +The full CLI/RPC host includes mutation-capable workspace/session methods. The TUI-started web sidecar is a read attachment: it exposes projection/read methods plus `rpc.discover`, and rejects write methods as `Method not found`. Browser clients, CLI probes, TUI adapters, and future relays speak Brunch method names; they do not coordinate raw Pi RPC plus Brunch product RPC themselves. RPC handlers project from canonical stores: @@ -35,85 +46,206 @@ canonical stores: RPC handlers must not become a generic records API, REST read model, or canonical view store. Reads are named projections over the store that owns the fact. Mutations route through the owning product seam: session transcript operations through `session.*`, graph mutations through the agent/tool or `CommandExecutor` path that owns them. -## Product method vocabulary +## Method registry + +Method discovery and dispatch come from the same registry. A method not present in a surface registry is not discoverable and is rejected as `Method not found` on that surface. -Use these names in product design, SPEC text, and new public handlers: +```pseudo +rpc/ +├── handlers.ts +│ ├── createRpcHandlers(...) -> full registry +│ ├── createReadOnlyRpcHandlers(...) -> read-only registry +│ └── rpc.discover -> discovery over active registry +└── methods/ + ├── registry.ts -> method definition + discovery shape + ├── workspace.ts -> workspace.* handlers + ├── session.ts -> session.* handlers + ├── graph.ts -> graph.* handlers + └── schemas.ts -> shared protocol schemas +``` + +## Current method surface + +```pseudo +full RPC host: + reads: + rpc.discover + workspace.snapshot + workspace.selectionState + session.pendingExchange + session.exchanges + session.runtimeState + graph.overview + graph.nodeNeighborhood + writes: + workspace.activate + session.triggerExchange + session.submitExchangeResponse + +TUI-started web sidecar: + reads: + rpc.discover + workspace.snapshot + workspace.selectionState + session.pendingExchange + session.exchanges + session.runtimeState + graph.overview + graph.nodeNeighborhood + rejected as method-not-found: + workspace.activate + session.triggerExchange + session.submitExchangeResponse +``` + +## Method overview ```pseudo rpc.discover - Returns supported Brunch methods, schemas, and examples. + access: read + params: none + result: supported methods with descriptions, schemas, and examples + source: active method registry workspace.snapshot - Returns cwd-scoped workspace product state: + access: read + params: none + result: cwd-scoped workspace product state project posture current/default spec/session activation/chrome state + source: WorkspaceSessionCoordinator + .brunch/workspace.json + DB-backed spec inventory workspace.selectionState - Returns boot/picker inventory and whether explicit spec/session activation is required. + access: read + params: none + result: spec/session picker inventory and requiresSelection flag + source: WorkspaceSessionCoordinator inspection workspace.activate - Applies an explicit workspace -> spec -> session decision. - -session.promptExchange - Starts, resumes, or advances the assistant-first session loop until one of: - pending structured exchange - idle/completed state - needs_human blocker - policy/authority blocker + access: write + params: {decision} + continue | openSession | newSession | newSpec | cancel + result: workspace snapshot or cancelled activation state + effects: creates/opens selected spec/session and publishes selected-session invalidations session.pendingExchange - Reads the current unresolved structured exchange without advancing the agent loop. + access: read + params: {sessionId, specId?} or omitted selected session + result: current unresolved structured exchange, or idle + source: linear Pi JSONL transcript projection -session.submitExchangeResponse - Submits the terminal response for one pending structured exchange. - The payload is generic over request_* variants: - request_answer - request_choice - request_choices - request_review - future request_* tools +session.exchanges + access: read + params: {sessionId, specId?} or omitted selected session + result: structured exchange history + source: linear Pi JSONL transcript projection + +session.runtimeState + access: read + params: {sessionId, specId} + result: transcript-backed runtime posture, mention slots, world watermarks (latest graph LSN and git head, no raw detail bags), lifecycle slots + source: linear Pi JSONL transcript projection + +session.triggerExchange + access: write + params: none + result: pending exchange + effects: starts/resumes/advances the assistant-first exchange loop and publishes selected-session invalidations -session.submitMessage - Submits ordinary non-exchange user text or an explicit interruption. - It is not a structured exchange answer. +session.submitExchangeResponse + access: write + params: + exchangeId + answer: {text} | {optionId} | {optionIds} + note? + result: accepted terminal response + effects: appends request_* toolResult response and publishes selected-session invalidations + +graph.overview + access: read + params: {specId} + result: selected-spec graph overview + nodes + edges + nodeCount + edgeCount + lsn + source: SQLite graph reader for the explicit spec + +graph.nodeNeighborhood + access: read + params: {specId, nodeId, hops?} + result: success(anchor, neighbors, edges) | not_found + source: SQLite graph reader for the explicit spec +``` -session.exchanges - Projects structured exchange history from transcript truth. +## Product update notifications -future graph projection methods - graph.overview - graph.nodeNeighborhood - graph.changesSince / graph.recentChanges +`brunch.updated` is a JSON-RPC notification, not a request/response method. It carries process-local invalidation hints only; clients refetch canonical projections through named RPC methods. -future graph-adjacent coherence projection method - graph.coherenceSummary +```pseudo +brunch.updated: + params: + topics: + - workspace.snapshot + - workspace.selectionState + - session.pendingExchange + - session.exchanges + - session.runtimeState + - graph.overview + - graph.nodeNeighborhood + updates: + - {topic, specId?, sessionId?, nodeId?, lsn?} ``` -## Names to avoid +WebSocket and stdio transports both carry these notifications independently from request responses. The notification payload is owned by `rpc/`; graph and session mutation adapters receive only a narrow product-update publisher. -These names are proof-era, stale, or too narrow for the stable product contract: +## RPC methods to web Query hooks + +Current web code only uses the read sidecar. Write hooks are named here as the expected TanStack Query mutation shape for a future write-capable web/client surface; they are not accepted by the TUI-started sidecar today. ```pseudo -session.startElicitation - too mode/lifecycle specific; use session.promptExchange +query key families: + workspace.snapshot -> ['workspace.snapshot'] + workspace.selectionState -> ['workspace.selectionState'] # target, not yet implemented in web queryKeys + session.pendingExchange -> ['session.pendingExchange', specId, sessionId] # target + session.exchanges -> ['session.exchanges', specId, sessionId] # target + session.runtimeState -> ['session.runtimeState', specId, sessionId] + graph.overview -> ['graph.overview', specId] + graph.nodeNeighborhood -> ['graph.nodeNeighborhood', specId, nodeId, hops] +``` -elicitation.respond - too mode-specific and too narrow; use session.submitExchangeResponse +| RPC method | Web Query/Mutation mapping | Current web status | Invalidation source | +| --- | --- | --- | --- | +| `rpc.discover` | `rpcDiscoveryQueryOptions(rpc)` | not implemented; optional debug/adaptive UI only | none | +| `workspace.snapshot` | `workspaceSnapshotQueryOptions(rpc)` | implemented; root/spec loaders prime it | exact `workspace.snapshot` | +| `workspace.selectionState` | `workspaceSelectionStateQueryOptions(rpc)` | target; picker route not built | `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` | +| `session.runtimeState` | `sessionRuntimeStateQueryOptions(rpc, target)` | implemented query option; not yet route-rendered | `session.runtimeState` | +| `session.triggerExchange` | `triggerExchangeMutationOptions(rpc)` | target full-host mutation; sidecar rejects | invalidates pending/exchanges/runtime state | +| `session.submitExchangeResponse` | `submitExchangeResponseMutationOptions(rpc)` | target full-host mutation; sidecar rejects | invalidates pending/exchanges/runtime state; graph updates arrive after agent commit | +| `graph.overview` | `graphOverviewQueryOptions(rpc, specId)` | implemented; spec route loader primes it | exact `graph.overview(specId)` when `specId` is present | +| `graph.nodeNeighborhood` | `graphNodeNeighborhoodQueryOptions(rpc, specId, nodeId, hops?)` | implemented query option; graph panel selection not yet wired | exact/prefix neighborhood invalidation when `nodeId` is present; broad topic fallback otherwise | + +Route/use pattern: -session.elicitationExchanges - too mode-specific; use session.exchanges +```pseudo +route loader + -> queryClient.ensureQueryData(queryOptionsFrom(web/queries/*)) -session.transcriptDisplay - render/debug concern, not a core web-product state API +route/component + -> useSuspenseQuery(same query options) for required projections + -> useQuery(enabled: target != null) for optional panels -command.* - not a web UI primitive for propose-graph; command execution is an internal authority seam +root subscription + -> useBrunchUpdateSubscription(queryClient, rpcClient) + -> brunch.updated invalidates method-shaped Query keys ``` -Existing code may still expose some proof-era names from the deterministic public-RPC parity slice. Treat those as rename debt, not as product vocabulary for new work. - ## Structured exchange lifecycle A structured exchange is transcript-native. Its durable semantic content lives in Pi JSONL `toolResult` tuples, not in UI local state. @@ -152,18 +284,18 @@ if session.pendingExchange returns pending: do not also treat freeform text as ambient chat if no exchange is pending: - session.promptExchange may ask the agent for the next exchange - session.submitMessage may append ordinary user text or an explicit interruption + session.triggerExchange may ask the agent for the next exchange + future session.submitMessage may append ordinary user text or an explicit interruption ``` -`session.submitMessage` must not silently answer a pending exchange. If interruption is allowed, it should be explicit in the payload and transcript-visible. +`session.submitMessage` is reserved for a future real method. It is not exposed in current discovery. When implemented, it must not silently answer a pending exchange; interruptions should be explicit in the payload and transcript-visible. ## `propose-graph` flow In `propose-graph`, the browser does not submit graph nodes or edges and does not call `commitGraph` directly. ```pseudo -session.promptExchange +session.triggerExchange -> agent presents a concept proposal as a structured exchange session.pendingExchange @@ -181,3 +313,28 @@ agent continues after acceptance ``` The user reviews the concept-level proposal. The graph becomes product truth only after the internal `commitGraph` path succeeds. + +## Names absent from current public RPC + +These names are not compatibility aliases and must not be reintroduced in product code: + +```pseudo +session.startElicitation -> retired proof-era name for session.triggerExchange +elicitation.respond -> retired non-session family for session.submitExchangeResponse +session.elicitationExchanges -> retired proof-era name for session.exchanges +session.transcriptDisplay -> removed render/debug projection; not a web product API +command.* -> internal authority seam, not a browser RPC primitive +``` + +Reserved future names: + +```pseudo +session.submitMessage + ordinary non-exchange user text or explicit interruption; absent until real behavior is scoped + +graph.changesSince / graph.recentChanges + future graph update projection + +graph.coherenceSummary + future graph-adjacent coherence projection after durable semantics are modeled +``` diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 3317c002..4e2498e7 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -4,10 +4,13 @@ import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { SessionManager } from '@earendil-works/pi-coding-agent'; +import type { TSchema } from 'typebox'; import { Value } from 'typebox/value'; import { describe, expect, it } from 'vitest'; +import { openWorkspaceGraphRuntime } from '../graph/workspace-store.js'; import { assistantMessage, userMessage } from '../probes/test-helpers.js'; +import { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, type BrunchAgentState } from '../session/runtime-state.js'; import { createSessionBindingData } from '../session/session-binding.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; import type { @@ -20,6 +23,7 @@ import type { SpecSessionActivationDecision, } from '../session/workspace-session-coordinator.js'; import { createRpcHandlers, runJsonRpcLineServer } from './handlers.js'; +import { createProductUpdatePublisher } from './product-updates.js'; function coordinator( state: WorkspaceSessionState = readyState('/tmp/brunch-project/.brunch/sessions/session-1.jsonl'), @@ -153,6 +157,49 @@ function appendBinding(manager: SessionManager): void { ); } +async function createGraphRpcFixture(): Promise<{ + cwd: string; + specAId: number; + specBId: number; + specANodeId: number; + specBNodeId: number; + finalLsn: number; +}> { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-graph-')); + const graph = await openWorkspaceGraphRuntime(cwd); + const specA = graph.commandExecutor.createSpec({ name: 'Spec A', slug: 'spec-a' }); + const specB = graph.commandExecutor.createSpec({ name: 'Spec B', slug: 'spec-b' }); + if (specA.status !== 'success' || specB.status !== 'success') { + throw new Error('failed to create graph RPC fixture specs'); + } + + const commitA = graph.commandExecutor.commitGraph({ + specId: specA.specId, + nodes: [ + { ref: 'requirement', plane: 'intent', kind: 'requirement', title: 'Spec A requirement' }, + { ref: 'constraint', plane: 'intent', kind: 'constraint', title: 'Spec A constraint' }, + ], + edges: [{ category: 'dependency', source: 'requirement', target: 'constraint' }], + }); + const commitB = graph.commandExecutor.commitGraph({ + specId: specB.specId, + nodes: [{ ref: 'goal', plane: 'intent', kind: 'goal', title: 'Spec B goal' }], + edges: [], + }); + if (commitA.status !== 'success' || commitB.status !== 'success') { + throw new Error('failed to create graph RPC fixture graph'); + } + + return { + cwd, + specAId: specA.specId, + specBId: specB.specId, + specANodeId: commitA.nodes.requirement!, + specBNodeId: commitB.nodes.goal!, + finalLsn: commitB.lsn, + }; +} + function presentQuestionEntry() { return { id: 'present-question-1', @@ -246,12 +293,14 @@ describe('JSON-RPC handlers', () => { } ).methods; expect(methods.map((entry) => entry.method).sort()).toEqual([ - 'elicitation.respond', + 'graph.nodeNeighborhood', + 'graph.overview', 'rpc.discover', - 'session.elicitationExchanges', + 'session.exchanges', 'session.pendingExchange', - 'session.startElicitation', - 'session.transcriptDisplay', + 'session.runtimeState', + 'session.submitExchangeResponse', + 'session.triggerExchange', 'workspace.activate', 'workspace.selectionState', 'workspace.snapshot', @@ -326,6 +375,104 @@ describe('JSON-RPC handlers', () => { } }); + it('discovers selected-spec graph read methods with schemas and examples', async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }); + + const response = await handlers.handle({ + jsonrpc: '2.0', + id: 34, + method: 'rpc.discover', + }); + if (!('result' in response)) throw new Error('expected success response'); + + const methods = ( + response.result as { + methods: Array<{ + method: string; + description: string; + paramsSchema: unknown; + resultSchema: unknown; + examples: unknown[]; + }>; + } + ).methods; + const overview = methods.find((entry) => entry.method === 'graph.overview'); + const neighborhood = methods.find((entry) => entry.method === 'graph.nodeNeighborhood'); + + expect(overview).toBeDefined(); + expect(neighborhood).toBeDefined(); + expect(JSON.stringify(overview?.paramsSchema)).toContain('specId'); + expect(JSON.stringify(overview?.resultSchema)).toContain('nodeCount'); + expect(JSON.stringify(neighborhood?.paramsSchema)).toContain('nodeId'); + expect(JSON.stringify(neighborhood?.resultSchema)).toContain('not_found'); + expect(overview?.examples).toContainEqual({ + jsonrpc: '2.0', + id: expect.any(Number), + method: 'graph.overview', + params: { specId: expect.any(Number) }, + }); + expect(neighborhood?.examples).toContainEqual({ + jsonrpc: '2.0', + id: expect.any(Number), + method: 'graph.nodeNeighborhood', + params: { specId: expect.any(Number), nodeId: expect.any(Number), hops: expect.any(Number) }, + }); + }); + + it('discovers exact session projection wire shapes', async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }); + + const response = await handlers.handle({ + jsonrpc: '2.0', + id: 35, + method: 'rpc.discover', + }); + if (!('result' in response)) throw new Error('expected success response'); + + const methods = ( + response.result as { + methods: Array<{ + method: string; + paramsSchema: TSchema & { properties?: Record }; + examples: Array<{ params?: unknown }>; + }>; + } + ).methods; + const exchanges = methods.find((entry) => entry.method === 'session.exchanges'); + const pending = methods.find((entry) => entry.method === 'session.pendingExchange'); + const runtimeState = methods.find((entry) => entry.method === 'session.runtimeState'); + + for (const entry of [exchanges, pending]) { + if (!entry) throw new Error('expected discovered selected-session projection method'); + expect(entry.paramsSchema.properties?.specId).toMatchObject({ + type: 'integer', + minimum: 1, + }); + expect(Value.Check(entry.paramsSchema, { sessionId: 'session-1' })).toBe(true); + expect(Value.Check(entry.paramsSchema, { sessionId: 'session-1', specId: 1 })).toBe(true); + for (const example of entry.examples.filter((example) => example.params !== undefined)) { + expect(Value.Check(entry.paramsSchema, example.params)).toBe(true); + } + } + + if (!runtimeState) throw new Error('expected discovered session.runtimeState method'); + expect(runtimeState.paramsSchema.properties?.specId).toMatchObject({ + type: 'integer', + minimum: 1, + }); + expect(Value.Check(runtimeState.paramsSchema, { sessionId: 'session-1' })).toBe(false); + expect(Value.Check(runtimeState.paramsSchema, { sessionId: 'session-1', specId: 1 })).toBe(true); + for (const example of runtimeState.examples.filter((example) => example.params !== undefined)) { + expect(Value.Check(runtimeState.paramsSchema, example.params)).toBe(true); + } + }); + it('serves discovery examples that are valid JSON-RPC requests for advertised methods', async () => { const handlers = createRpcHandlers({ coordinator: coordinator(), @@ -397,6 +544,33 @@ describe('JSON-RPC handlers', () => { }); }); + it('publishes workspace mutation invalidation through the shared product update bus', async () => { + const productUpdates = createProductUpdatePublisher(); + const observed: unknown[] = []; + productUpdates.subscribe((updates) => observed.push(...updates)); + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + productUpdates, + }); + + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 35, + method: 'workspace.activate', + params: { decision: { action: 'continue', specId: 1, sessionFile: 'session-1.jsonl' } }, + }), + ).resolves.toMatchObject({ jsonrpc: '2.0', id: 35, result: { status: 'ready' } }); + + expect(observed).toEqual([ + { topic: 'workspace.snapshot', 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' }, + ]); + }); + it('activates valid spec/session decisions and returns serializable product snapshots', async () => { const decisions: SpecSessionActivationDecision[] = []; const handlers = createRpcHandlers({ @@ -504,7 +678,7 @@ describe('JSON-RPC handlers', () => { }); }); - it('serves session elicitation exchanges from the coordinator-selected session', async () => { + it('serves session exchanges from the coordinator-selected session', async () => { const sessionFile = await createSessionFile(); const handlers = createRpcHandlers({ coordinator: coordinator(readyState(sessionFile)), @@ -515,7 +689,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 3, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -541,7 +715,7 @@ describe('JSON-RPC handlers', () => { const start = await handlers.handle({ jsonrpc: '2.0', id: 40, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); expect(start).toMatchObject({ @@ -575,7 +749,7 @@ describe('JSON-RPC handlers', () => { const exchanges = await handlers.handle({ jsonrpc: '2.0', id: 41, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }); expect(exchanges).toMatchObject({ jsonrpc: '2.0', @@ -583,31 +757,6 @@ describe('JSON-RPC handlers', () => { result: { status: 'open_prompt', openPrompt: expect.any(Object) }, }); - const display = await handlers.handle({ - jsonrpc: '2.0', - id: 42, - method: 'session.transcriptDisplay', - }); - expect(display).toMatchObject({ - jsonrpc: '2.0', - id: 42, - result: { - rows: [ - { - role: 'prompt', - text: expect.stringContaining('new product or feature'), - }, - ], - }, - }); - const displayText = ( - display as { - result: { rows: Array<{ text: string }> }; - } - ).result.rows[0]!.text; - expect(displayText).toContain('Start a new spec workspace from a blank slate.'); - expect(displayText).toContain('This keeps the parity run focused on initial grounding.'); - const sessionText = await readFile(workspace.session.file, 'utf8'); expect(sessionText).toContain('brunch.structured_exchange.present'); expect(sessionText).toContain('present_options'); @@ -615,7 +764,7 @@ describe('JSON-RPC handlers', () => { expect(sessionText).toContain('"lens":"intent"'); }); - it('reads the selected pending elicitation exchange from transcript truth', async () => { + it('reads the selected pending structured exchange from transcript truth', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-pending-')); const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); await coordinatorInstance.createSetupSession({ @@ -629,7 +778,7 @@ describe('JSON-RPC handlers', () => { const start = await handlers.handle({ jsonrpc: '2.0', id: 46, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const pending = await handlers.handle({ jsonrpc: '2.0', @@ -676,7 +825,7 @@ describe('JSON-RPC handlers', () => { await startHandlers.handle({ jsonrpc: '2.0', id: 48, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const handlers = createRpcHandlers({ @@ -757,7 +906,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 150, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: 'session-1', specId: 1 }, }), ).resolves.toMatchObject({ @@ -773,27 +922,6 @@ describe('JSON-RPC handlers', () => { ], }, }); - - await expect( - handlers.handle({ - jsonrpc: '2.0', - id: 151, - method: 'session.transcriptDisplay', - params: { sessionId: 'session-1', specId: 1 }, - }), - ).resolves.toMatchObject({ - jsonrpc: '2.0', - id: 151, - result: { - rows: [ - { role: 'prompt', text: expect.stringContaining('Domain?') }, - { - role: 'user', - text: expect.stringContaining('Developer tooling'), - }, - ], - }, - }); }); it('reports idle pending state when the selected session has no open prompt', async () => { @@ -980,7 +1108,7 @@ describe('JSON-RPC handlers', () => { const start = await handlers.handle({ jsonrpc: '2.0', id: 53, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const exchangeId = ( start as { @@ -991,7 +1119,7 @@ describe('JSON-RPC handlers', () => { const response = await handlers.handle({ jsonrpc: '2.0', id: 54, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId, answer: { optionId: 'new-from-scratch' }, @@ -1029,7 +1157,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 56, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -1045,29 +1173,6 @@ describe('JSON-RPC handlers', () => { }, }); - await expect( - handlers.handle({ - jsonrpc: '2.0', - id: 57, - method: 'session.transcriptDisplay', - }), - ).resolves.toMatchObject({ - jsonrpc: '2.0', - id: 57, - result: { - rows: [ - { - role: 'prompt', - text: expect.stringContaining('new product or feature'), - }, - { - role: 'user', - text: expect.stringContaining('Yes — this is new from scratch'), - }, - ], - }, - }); - const sessionText = await readFile(workspace.session.file, 'utf8'); expect(sessionText).toContain('brunch.structured_exchange.request'); expect(sessionText).toContain('request_choice'); @@ -1088,7 +1193,7 @@ describe('JSON-RPC handlers', () => { const first = await handlers.handle({ jsonrpc: '2.0', id: 250, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const firstExchangeId = ( first as { @@ -1098,7 +1203,7 @@ describe('JSON-RPC handlers', () => { await handlers.handle({ jsonrpc: '2.0', id: 251, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId: firstExchangeId, answer: { optionId: 'new-from-scratch' }, @@ -1108,7 +1213,7 @@ describe('JSON-RPC handlers', () => { const textStart = await handlers.handle({ jsonrpc: '2.0', id: 252, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); expect(textStart).toMatchObject({ result: { @@ -1127,7 +1232,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 253, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId: textExchangeId, answer: { text: 'A local product specification workspace.' }, @@ -1143,7 +1248,7 @@ describe('JSON-RPC handlers', () => { const multiStart = await handlers.handle({ jsonrpc: '2.0', id: 254, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); expect(multiStart).toMatchObject({ result: { @@ -1157,7 +1262,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 255, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId: 'deterministic-grounding-multi-3', answer: { optionIds: ['transcript', 'other'] }, @@ -1191,7 +1296,7 @@ describe('JSON-RPC handlers', () => { await handlers.handle({ jsonrpc: '2.0', id: 58, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const before = await readFile(workspace.session.file, 'utf8'); @@ -1199,7 +1304,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 59, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId: 'not-current', answer: { optionId: 'new-from-scratch' }, @@ -1210,7 +1315,7 @@ describe('JSON-RPC handlers', () => { id: 59, error: { code: -32006, - message: 'Pending elicitation exchange does not match request', + message: 'Pending structured exchange does not match request', }, }); await expect(readFile(workspace.session.file, 'utf8')).resolves.toBe(before); @@ -1229,7 +1334,7 @@ describe('JSON-RPC handlers', () => { const start = await handlers.handle({ jsonrpc: '2.0', id: 60, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const exchangeId = ( start as { @@ -1242,7 +1347,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 61, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId, answer: { optionId: 'missing-option' } }, }), ).resolves.toMatchObject({ @@ -1266,7 +1371,7 @@ describe('JSON-RPC handlers', () => { const start = await handlers.handle({ jsonrpc: '2.0', id: 62, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const exchangeId = ( start as { @@ -1276,7 +1381,7 @@ describe('JSON-RPC handlers', () => { await handlers.handle({ jsonrpc: '2.0', id: 63, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId, answer: { optionId: 'existing-codebase' } }, }); const before = await readFile(workspace.session.file, 'utf8'); @@ -1285,13 +1390,13 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 64, - method: 'elicitation.respond', + method: 'session.submitExchangeResponse', params: { exchangeId, answer: { optionId: 'existing-codebase' } }, }), ).resolves.toMatchObject({ jsonrpc: '2.0', id: 64, - error: { code: -32008, message: 'No pending elicitation exchange' }, + error: { code: -32008, message: 'No pending structured exchange' }, }); await expect(readFile(workspace.session.file, 'utf8')).resolves.toBe(before); }); @@ -1310,14 +1415,14 @@ describe('JSON-RPC handlers', () => { const first = await handlers.handle({ jsonrpc: '2.0', id: 43, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const before = await readFile(workspace.session.file, 'utf8'); const second = await handlers.handle({ jsonrpc: '2.0', id: 44, - method: 'session.startElicitation', + method: 'session.triggerExchange', }); const after = await readFile(workspace.session.file, 'utf8'); @@ -1348,7 +1453,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 45, - method: 'session.startElicitation', + method: 'session.triggerExchange', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -1368,7 +1473,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 8, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -1380,7 +1485,7 @@ describe('JSON-RPC handlers', () => { }); }); - it('serves session elicitation exchanges by durable session id without opening the selected workspace session', async () => { + it('serves session exchanges by durable session id without opening the selected workspace session', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-explicit-session-')); const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); const first = await coordinatorInstance.createSetupSession({ @@ -1406,7 +1511,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 9, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: first.session.id }, }), ).resolves.toMatchObject({ @@ -1436,26 +1541,90 @@ describe('JSON-RPC handlers', () => { }, cwd, }); + }); + + it('serves runtime state by explicit spec and session id without opening selected session', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-runtime-state-')); + const latestState: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'propose-graph', + agentLens: 'design', + agentGoal: 'capture-posture', + }; + await writeExplicitSessionFixture(cwd, [ + { type: 'session', id: 'session-1', cwd }, + sessionBindingEntry('session-1', 1), + { + id: 'runtime-state-1', + type: 'custom', + parentId: 'binding-session-1-1', + customType: BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + data: { + schemaVersion: 1, + reason: 'switch', + state: latestState, + source: 'user', + }, + }, + ]); + const handlers = createRpcHandlers({ + coordinator: { + ...coordinator(), + async openDefaultWorkspace() { + throw new Error('explicit reads must not open selected session'); + }, + }, + cwd, + }); await expect( handlers.handle({ jsonrpc: '2.0', - id: 13, - method: 'session.transcriptDisplay', - params: { sessionId: workspace.session.id, specId: workspace.spec.id }, + id: 14, + method: 'session.runtimeState', + params: { sessionId: 'session-1', specId: 1 }, }), ).resolves.toMatchObject({ jsonrpc: '2.0', - id: 13, + id: 14, result: { - rows: [ - { role: 'assistant', text: 'Display question' }, - { role: 'user', text: 'Display answer' }, - ], + status: 'ready', + specId: 1, + sessionId: 'session-1', + agent: { + operationalMode: 'elicit', + role: 'elicitor', + strategy: 'propose-graph', + lens: 'design', + goal: 'capture-posture', + }, + mentions: { graphNodes: [], files: [] }, + world: { graph: { latestLsn: null }, git: { head: null } }, }, }); }); + it('requires an explicit spec id for runtime state reads', async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }); + + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 15, + method: 'session.runtimeState', + params: { sessionId: 'session-1' }, + }), + ).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 15, + error: { code: -32602, message: 'Invalid params' }, + }); + }); + it('validates explicit session projection against a requested spec id', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-explicit-spec-')); const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); @@ -1471,7 +1640,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 10, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: workspace.session.id, specId: 9999 }, }), ).resolves.toMatchObject({ @@ -1500,7 +1669,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 16, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: 'session-1' }, }), ).resolves.toMatchObject({ @@ -1529,7 +1698,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 17, - method: 'session.transcriptDisplay', + method: 'session.exchanges', params: { sessionId: 'session-1' }, }), ).resolves.toMatchObject({ @@ -1552,7 +1721,7 @@ describe('JSON-RPC handlers', () => { headerlessHandlers.handle({ jsonrpc: '2.0', id: 19, - method: 'session.transcriptDisplay', + method: 'session.exchanges', params: { sessionId: 'session-1' }, }), ).resolves.toMatchObject({ @@ -1580,7 +1749,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 18, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: 'session-header' }, }), ).resolves.toMatchObject({ @@ -1605,7 +1774,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 11, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: 'session-does-not-exist' }, }), ).resolves.toMatchObject({ @@ -1638,7 +1807,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 12, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { sessionId: workspace.session.id }, }), ).resolves.toMatchObject({ @@ -1651,7 +1820,7 @@ describe('JSON-RPC handlers', () => { }); }); - it('rejects raw file params on session elicitation exchange RPC', async () => { + it('rejects raw file params on session session exchange RPC', async () => { const handlers = createRpcHandlers({ coordinator: coordinator(), cwd: '/tmp/brunch-project', @@ -1661,7 +1830,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 4, - method: 'session.elicitationExchanges', + method: 'session.exchanges', params: { file: '/tmp/not-a-product-param.jsonl' }, }), ).resolves.toMatchObject({ @@ -1681,7 +1850,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: 5, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -1722,6 +1891,101 @@ describe('JSON-RPC handlers', () => { }); }); + it('serves selected-spec graph overview and node neighborhoods through public RPC', async () => { + const fixture = await createGraphRpcFixture(); + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: fixture.cwd, + }); + + const overviewA = await handlers.handle({ + jsonrpc: '2.0', + id: 50, + method: 'graph.overview', + params: { specId: fixture.specAId }, + }); + expect(overviewA).toMatchObject({ + jsonrpc: '2.0', + id: 50, + result: { + nodeCount: 2, + edgeCount: 1, + lsn: fixture.finalLsn, + }, + }); + if (!('result' in overviewA)) throw new Error('expected graph overview'); + expect(JSON.stringify(overviewA.result)).toContain('Spec A requirement'); + expect(JSON.stringify(overviewA.result)).not.toContain('Spec B goal'); + + const overviewB = await handlers.handle({ + jsonrpc: '2.0', + id: 51, + method: 'graph.overview', + params: { specId: fixture.specBId }, + }); + expect(overviewB).toMatchObject({ + jsonrpc: '2.0', + id: 51, + result: { nodeCount: 1, edgeCount: 0, lsn: fixture.finalLsn }, + }); + + const crossSpecNeighborhood = await handlers.handle({ + jsonrpc: '2.0', + id: 52, + method: 'graph.nodeNeighborhood', + params: { specId: fixture.specAId, nodeId: fixture.specBNodeId }, + }); + expect(crossSpecNeighborhood).toEqual({ + jsonrpc: '2.0', + id: 52, + result: { status: 'not_found' }, + }); + + const neighborhood = await handlers.handle({ + jsonrpc: '2.0', + id: 53, + method: 'graph.nodeNeighborhood', + params: { specId: fixture.specAId, nodeId: fixture.specANodeId, hops: 1 }, + }); + expect(neighborhood).toMatchObject({ + jsonrpc: '2.0', + id: 53, + result: { + status: 'success', + anchor: { id: fixture.specANodeId, specId: fixture.specAId }, + neighbors: [{ title: 'Spec A constraint', specId: fixture.specAId }], + edges: [{ category: 'dependency', specId: fixture.specAId }], + }, + }); + }); + + it('requires explicit params for selected-spec graph RPC reads', async () => { + const handlers = createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }); + + await expect( + handlers.handle({ jsonrpc: '2.0', id: 54, method: 'graph.overview' }), + ).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 54, + error: { code: -32602, message: 'Invalid params' }, + }); + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 55, + method: 'graph.nodeNeighborhood', + params: { specId: 1 }, + }), + ).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 55, + error: { code: -32602, message: 'Invalid params' }, + }); + }); + it('returns parse errors over newline-delimited JSON-RPC streams', async () => { const input = new PassThrough(); const output = new PassThrough(); @@ -1797,4 +2061,62 @@ describe('JSON-RPC handlers', () => { result: { status: 'ready' }, }); }); + + it('writes product update notifications over stdio independently from responses', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const productUpdates = createProductUpdatePublisher(); + const chunks: string[] = []; + output.on('data', (chunk) => chunks.push(String(chunk))); + + const done = runJsonRpcLineServer({ + input, + output, + handlers: createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }), + productUpdates, + }); + + productUpdates.publish({ topic: 'graph.overview', specId: 1, lsn: 4 }); + input.end(); + await done; + + expect(JSON.parse(chunks.join(''))).toEqual({ + jsonrpc: '2.0', + method: 'brunch.updated', + params: { + topics: ['graph.overview'], + updates: [{ topic: 'graph.overview', specId: 1, lsn: 4 }], + }, + }); + }); + + it('parses stdio input as LF-framed JSON-RPC without splitting U+2028 inside strings', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const chunks: string[] = []; + output.on('data', (chunk) => chunks.push(String(chunk))); + + const done = runJsonRpcLineServer({ + input, + output, + handlers: createRpcHandlers({ + coordinator: coordinator(), + cwd: '/tmp/brunch-project', + }), + }); + + input.end( + `${JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'unknown.method', params: { text: 'a
b' } })}\n`, + ); + await done; + + expect(JSON.parse(chunks.join(''))).toEqual({ + jsonrpc: '2.0', + id: 99, + error: { code: -32601, message: 'Method not found' }, + }); + }); }); diff --git a/src/rpc/handlers.ts b/src/rpc/handlers.ts index 197ab365..1082e016 100644 --- a/src/rpc/handlers.ts +++ b/src/rpc/handlers.ts @@ -1,42 +1,30 @@ -import { createInterface } from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; -import { Type, type Static } from 'typebox'; -import { Value } from 'typebox/value'; +import { Type } from 'typebox'; -import type { StructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/model.js'; -import { isStructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/recovery.js'; -import { workspaceSnapshotFromState } from '../print-snapshot.js'; -import { - readBrunchSessionEnvelope, - NonLinearTranscriptError, - type BrunchSessionEnvelope, -} from '../session/brunch-session-envelope.js'; -import { - projectLinearElicitationExchangeProjection, - projectLinearTranscriptDisplayProjection, -} from '../session/elicitation-exchange.js'; -import { - resolveExplicitSessionProjectionTarget, - type ExplicitSessionProjectionParams, - type SessionProjectionTarget, -} from '../session/session-projection-reader.js'; +import { openWorkspaceGraphRuntime, type WorkspaceGraphRuntime } from '../graph/workspace-store.js'; import type { DefaultWorkspaceCoordinator, - WorkspaceActivationState, - WorkspaceLaunchInventory, - WorkspaceSessionState, SpecSessionActivationCoordinator, - SpecSessionActivationDecision, } from '../session/workspace-session-coordinator.js'; +import { graphRpcMethods } from './methods/graph.js'; +import { + discoverRpcMethods, + registryByMethod, + type RpcMethodContext, + type RpcMethodDefinition, + type RpcMethodRegistry, +} from './methods/registry.js'; +import { NoParamsSchema } from './methods/schemas.js'; +import { sessionRpcMethods } from './methods/session.js'; +import { workspaceRpcMethods } from './methods/workspace.js'; +import { createProductUpdateNotification, type ProductUpdatePublisher } from './product-updates.js'; import { createJsonRpcFailure, createJsonRpcSuccess, isJsonRpcRequest, jsonRpcRequestId, dispatchJsonRpcMessage, - type JsonRpcId, - type JsonRpcRequest, type JsonRpcResponse, } from './protocol.js'; @@ -44,10 +32,43 @@ export interface RpcHandlers { handle(request: unknown): Promise; } +export function createReadOnlyRpcHandlers(options: { + coordinator: DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator; + cwd: string; + productUpdates?: ProductUpdatePublisher; +}): RpcHandlers { + return createRpcHandlersForRegistry(options, READ_ONLY_RPC_METHOD_REGISTRY); +} + export function createRpcHandlers(options: { coordinator: DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator; cwd: string; + productUpdates?: ProductUpdatePublisher; }): RpcHandlers { + return createRpcHandlersForRegistry(options, FULL_RPC_METHOD_REGISTRY); +} + +function createRpcHandlersForRegistry( + options: { + coordinator: DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator; + cwd: string; + productUpdates?: ProductUpdatePublisher; + }, + registryDefinitions: RpcMethodRegistry, +): RpcHandlers { + let graphRuntime: Promise | null = null; + + const getGraphRuntime = () => { + graphRuntime ??= openWorkspaceGraphRuntime(options.cwd); + return graphRuntime; + }; + const context: RpcMethodContext = { + ...options, + getGraphRuntime, + discoveryRegistry: registryDefinitions, + }; + const registry = registryByMethod(registryDefinitions); + return { async handle(request) { if (!isJsonRpcRequest(request)) { @@ -55,352 +76,20 @@ export function createRpcHandlers(options: { } const requestId = jsonRpcRequestId(request); - - if (request.method === 'rpc.discover') { - if (request.params !== undefined) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - return createJsonRpcSuccess(requestId, discoverPublicRpcMethods()); + const definition = registry.get(request.method); + if (definition === undefined) { + return createJsonRpcFailure(requestId, -32601, 'Method not found'); } - if (request.method === 'workspace.snapshot') { - if (request.params !== undefined) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - const state = await options.coordinator.openDefaultWorkspace(); - return createJsonRpcSuccess(requestId, workspaceSnapshotFromState(state)); - } - - if (request.method === 'workspace.selectionState') { - if (request.params !== undefined) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - const [state, inventory] = await Promise.all([ - options.coordinator.openDefaultWorkspace(), - options.coordinator.inspectWorkspace(), - ]); - return createJsonRpcSuccess(requestId, workspaceSelectionStateFromInventory(state, inventory)); - } - - if (request.method === 'workspace.activate') { - const decision = parseWorkspaceActivationParams(request.params); - if (!decision.ok) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - const state = await options.coordinator.activateWorkspace(decision.value); - return createJsonRpcSuccess(requestId, workspaceActivationSnapshotFromState(state)); - } - - if (request.method === 'session.startElicitation') { - if (request.params !== undefined) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - return handleStartElicitation(requestId, options); - } - - if (request.method === 'session.pendingExchange') { - return handleSessionProjection(requestId, request.params, options, projectPendingElicitationExchange); - } - - if (request.method === 'elicitation.respond') { - return handleRespondToElicitation(requestId, request.params, options); - } - - if (request.method === 'session.elicitationExchanges') { - return handleSessionProjection( - requestId, - request.params, - options, - projectLinearElicitationExchangeProjection, - ); - } - - if (request.method === 'session.transcriptDisplay') { - return handleSessionProjection( - requestId, - request.params, - options, - projectLinearTranscriptDisplayProjection, - ); - } - - return createJsonRpcFailure(requestId, -32601, 'Method not found'); + return definition.handle(context, request); }, }; } -function workspaceSelectionStateFromInventory( - state: WorkspaceSessionState, - inventory: WorkspaceLaunchInventory, -): WorkspaceLaunchInventory & { - status: WorkspaceSessionState['status']; - requiresSelection: boolean; -} { - return { - ...inventory, - status: state.status, - requiresSelection: state.status !== 'ready', - }; -} - -function workspaceActivationSnapshotFromState(state: WorkspaceActivationState): - | ReturnType - | { - status: 'cancelled'; - cwd: string; - spec: WorkspaceActivationState['chrome']['spec']; - chrome: { - phase: 'select_spec' | 'elicitation'; - chatMode: 'select-spec' | 'responding-to-elicitation'; - }; - } { - if (state.status === 'cancelled') { - return { - status: 'cancelled', - cwd: state.cwd, - spec: state.chrome.spec, - chrome: { - phase: state.chrome.phase, - chatMode: state.chrome.chatMode, - }, - }; - } - return workspaceSnapshotFromState(state); -} - -const NonBlankStringSchema = Type.String({ minLength: 1, pattern: '\\S' }); -const PositiveIntegerSchema = Type.Integer({ minimum: 1 }); - -export const SpecSessionActivationDecisionSchema = Type.Union([ - Type.Object( - { - action: Type.Literal('continue'), - specId: PositiveIntegerSchema, - sessionFile: NonBlankStringSchema, - }, - { additionalProperties: false }, - ), - Type.Object( - { - action: Type.Literal('openSession'), - specId: PositiveIntegerSchema, - sessionFile: NonBlankStringSchema, - }, - { additionalProperties: false }, - ), - Type.Object( - { - action: Type.Literal('newSession'), - specId: PositiveIntegerSchema, - }, - { additionalProperties: false }, - ), - Type.Object( - { - action: Type.Literal('newSpec'), - title: NonBlankStringSchema, - }, - { additionalProperties: false }, - ), - Type.Object( - { - action: Type.Literal('cancel'), - }, - { additionalProperties: false }, - ), -]); - -const WorkspaceActivationParamsSchema = Type.Object( - { - decision: SpecSessionActivationDecisionSchema, - }, - { additionalProperties: false }, -); - -type WorkspaceActivationParams = Static; - -const NoParamsSchema = Type.Void({ description: 'Omit JSON-RPC params.' }); - -const WorkspaceSnapshotResultSchema = Type.Object( - { - status: Type.String(), - cwd: Type.String(), - spec: Type.Union([ - Type.Null(), - Type.Object( - { id: Type.String(), title: Type.String() }, - { - additionalProperties: true, - }, - ), - ]), - chrome: Type.Object({}, { additionalProperties: true }), - }, - { additionalProperties: true }, -); - -const WorkspaceSelectionStateResultSchema = Type.Object( - { - status: Type.String(), - requiresSelection: Type.Boolean(), - cwd: Type.String(), - specs: Type.Array(Type.Object({}, { additionalProperties: true })), - unavailableSessions: Type.Array(Type.Object({}, { additionalProperties: true })), - }, - { additionalProperties: true }, -); - -const WorkspaceActivationResultSchema = Type.Union([ - WorkspaceSnapshotResultSchema, - Type.Object( - { - status: Type.Literal('cancelled'), - cwd: Type.String(), - spec: Type.Union([ - Type.Null(), - Type.Object( - { id: Type.String(), title: Type.String() }, - { - additionalProperties: true, - }, - ), - ]), - chrome: Type.Object( - { - phase: Type.Union([Type.Literal('select_spec'), Type.Literal('elicitation')]), - chatMode: Type.Union([Type.Literal('select-spec'), Type.Literal('responding-to-elicitation')]), - }, - { additionalProperties: false }, - ), - }, - { additionalProperties: false }, - ), -]); - -const SessionProjectionParamsSchema = Type.Object( - { - sessionId: NonBlankStringSchema, - specId: Type.Optional(NonBlankStringSchema), - }, - { additionalProperties: false }, -); - -const ElicitationExchangesResultSchema = Type.Object( - { - status: Type.String(), - exchanges: Type.Array(Type.Object({}, { additionalProperties: true })), - }, - { additionalProperties: true }, -); - -const TranscriptDisplayResultSchema = Type.Object( - { - rows: Type.Array(Type.Object({}, { additionalProperties: true })), - }, - { additionalProperties: true }, -); - -const PendingElicitationExchangeSchema = Type.Object( - { - exchangeId: NonBlankStringSchema, - lens: Type.Literal('intent'), - mode: Type.Union([Type.Literal('text'), Type.Literal('single-select'), Type.Literal('multi-select')]), - prompt: NonBlankStringSchema, - details: Type.Optional(NonBlankStringSchema), - options: Type.Array( - Type.Object( - { - id: NonBlankStringSchema, - label: NonBlankStringSchema, - content: NonBlankStringSchema, - rationale: Type.Optional(NonBlankStringSchema), - }, - { additionalProperties: false }, - ), - ), - note: Type.Object( - { allowed: Type.Boolean() }, - { - additionalProperties: false, - }, - ), - }, - { additionalProperties: false }, -); - -const StartElicitationResultSchema = Type.Object( - { - status: Type.Literal('pending'), - exchange: PendingElicitationExchangeSchema, - }, - { additionalProperties: false }, -); - -const PendingExchangeResultSchema = Type.Union([ - StartElicitationResultSchema, - Type.Object( - { - status: Type.Literal('idle'), - exchange: Type.Null(), - }, - { additionalProperties: false }, - ), -]); - -const ElicitationRespondParamsSchema = Type.Object( - { - exchangeId: NonBlankStringSchema, - answer: Type.Union([ - Type.Object( - { text: NonBlankStringSchema }, - { - additionalProperties: false, - }, - ), - Type.Object( - { optionId: NonBlankStringSchema }, - { - additionalProperties: false, - }, - ), - Type.Object( - { optionIds: Type.Array(NonBlankStringSchema, { minItems: 1 }) }, - { additionalProperties: false }, - ), - ]), - note: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -); - -const ElicitationRespondResultSchema = Type.Object( - { - status: Type.Literal('accepted'), - exchangeId: NonBlankStringSchema, - answer: Type.Object({}, { additionalProperties: true }), - note: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -); - -type ElicitationRespondParams = Static; -type ElicitationRespondResult = Static; - -type RpcMethodDiscovery = { - method: string; - description: string; - paramsSchema: unknown; - resultSchema: unknown; - examples: JsonRpcRequest[]; -}; - -function discoverPublicRpcMethods(): { methods: RpcMethodDiscovery[] } { - return { methods: PUBLIC_RPC_METHOD_DISCOVERY }; -} - -const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ +const FULL_RPC_METHOD_REGISTRY: readonly RpcMethodDefinition[] = [ { method: 'rpc.discover', + access: 'read', description: 'List the public Brunch JSON-RPC methods supported by this host with schemas and example calls.', paramsSchema: NoParamsSchema, @@ -409,804 +98,64 @@ const PUBLIC_RPC_METHOD_DISCOVERY: RpcMethodDiscovery[] = [ { additionalProperties: false }, ), examples: [{ jsonrpc: '2.0', id: 1, method: 'rpc.discover' }], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + return createJsonRpcSuccess(requestId, discoverRpcMethods(context.discoveryRegistry)); + }, }, - { - method: 'workspace.snapshot', - description: - 'Return the current Brunch workspace/spec/session snapshot for the invocation cwd without changing activation state.', - paramsSchema: NoParamsSchema, - resultSchema: WorkspaceSnapshotResultSchema, - examples: [{ jsonrpc: '2.0', id: 2, method: 'workspace.snapshot' }], - }, - { - method: 'workspace.selectionState', - description: - 'Return the product-shaped workspace inventory and whether the client must choose or create a spec/session before an agent loop can run.', - paramsSchema: NoParamsSchema, - resultSchema: WorkspaceSelectionStateResultSchema, - examples: [{ jsonrpc: '2.0', id: 3, method: 'workspace.selectionState' }], - }, - { - method: 'workspace.activate', - description: - 'Apply an explicit workspace→spec→session activation decision such as continuing, opening a session, creating a session, creating a spec, or cancelling.', - paramsSchema: WorkspaceActivationParamsSchema, - resultSchema: WorkspaceActivationResultSchema, - examples: [ - { - jsonrpc: '2.0', - id: 4, - method: 'workspace.activate', - params: { decision: { action: 'newSpec', title: 'POC spec' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'workspace.activate', - params: { - decision: { - action: 'openSession', - specId: 1, - sessionFile: '.brunch/sessions/session-1.jsonl', - }, - }, - }, - ], - }, - { - method: 'session.elicitationExchanges', - description: - 'Project structured elicitation exchanges from the selected or explicitly named linear Brunch session transcript.', - paramsSchema: SessionProjectionParamsSchema, - resultSchema: ElicitationExchangesResultSchema, - examples: [ - { - jsonrpc: '2.0', - id: 6, - method: 'session.elicitationExchanges', - params: { sessionId: 'session-1', specId: 1 }, - }, - ], - }, - { - method: 'session.transcriptDisplay', - description: - 'Project transcript display rows from the selected or explicitly named linear Brunch session transcript.', - paramsSchema: SessionProjectionParamsSchema, - resultSchema: TranscriptDisplayResultSchema, - examples: [ - { - jsonrpc: '2.0', - id: 7, - method: 'session.transcriptDisplay', - params: { sessionId: 'session-1', specId: 1 }, - }, - ], - }, - { - method: 'session.startElicitation', - description: - "Start or resume the selected session's deterministic structured-exchange permutation loop and return the current pending exchange.", - paramsSchema: NoParamsSchema, - resultSchema: StartElicitationResultSchema, - examples: [{ jsonrpc: '2.0', id: 8, method: 'session.startElicitation' }], - }, - { - method: 'session.pendingExchange', - description: - 'Read the current transcript-backed pending elicitation exchange from the selected or explicitly named linear Brunch session.', - paramsSchema: SessionProjectionParamsSchema, - resultSchema: PendingExchangeResultSchema, - examples: [ - { jsonrpc: '2.0', id: 9, method: 'session.pendingExchange' }, - { - jsonrpc: '2.0', - id: 10, - method: 'session.pendingExchange', - params: { sessionId: 'session-1', specId: 1 }, - }, - ], - }, - { - method: 'elicitation.respond', - description: - "Submit a text, single-choice, or multi-choice answer for the selected session's current deterministic tuple-shaped pending structured exchange.", - paramsSchema: ElicitationRespondParamsSchema, - resultSchema: ElicitationRespondResultSchema, - examples: [ - { - jsonrpc: '2.0', - id: 11, - method: 'elicitation.respond', - params: { - exchangeId: 'deterministic-grounding-choice', - answer: { optionId: 'new-from-scratch' }, - note: 'This is a greenfield product.', - }, - }, - ], - }, + ...workspaceRpcMethods, + ...graphRpcMethods, + ...sessionRpcMethods, ]; -type WorkspaceActivationParamsParseResult = - | { - ok: true; - value: SpecSessionActivationDecision; - } - | { ok: false }; - -function parseWorkspaceActivationParams(value: unknown): WorkspaceActivationParamsParseResult { - if (!Value.Check(WorkspaceActivationParamsSchema, value)) { - return { ok: false }; - } - const params: WorkspaceActivationParams = Value.Parse(WorkspaceActivationParamsSchema, value); - return { ok: true, value: params.decision }; -} - -async function handleSessionProjection( - requestId: JsonRpcId, - rawParams: unknown, - options: { - coordinator: DefaultWorkspaceCoordinator; - cwd: string; - }, - loadProjection: (envelope: BrunchSessionEnvelope) => T, -): Promise { - const params = parseSessionProjectionParams(rawParams); - if (!params.ok) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - - const target = params.value - ? await resolveExplicitSessionProjectionTarget(options.cwd, params.value) - : await selectedSessionFile(await options.coordinator.openDefaultWorkspace()); - if (!target.ok) { - return createJsonRpcFailure(requestId, target.code, target.message); - } - - try { - return createJsonRpcSuccess(requestId, loadProjection(target.envelope)); - } catch (error) { - if (error instanceof NonLinearTranscriptError) { - return createJsonRpcFailure(requestId, -32002, target.nonLinearMessage); - } - throw error; - } -} - -async function handleStartElicitation( - requestId: JsonRpcId, - options: { - coordinator: DefaultWorkspaceCoordinator; - cwd: string; - }, -): Promise { - const state = await options.coordinator.openDefaultWorkspace(); - if (state.status !== 'ready') { - return createJsonRpcFailure(requestId, -32001, 'No selected Brunch session'); - } - - const existingTarget = await selectedSessionFile(state); - if (!existingTarget.ok) { - return createJsonRpcFailure(requestId, existingTarget.code, existingTarget.message); - } - - const existing = pendingExchangeFromEnvelope(existingTarget.envelope); - if (existing) { - return createJsonRpcSuccess(requestId, { - status: 'pending', - exchange: existing, - }); - } - - const exchange = nextDeterministicElicitationExchange( - projectLinearElicitationExchangeProjection(existingTarget.envelope).exchanges.length, - ); - const manager = state.session.manager; - manager.appendMessage(presentToolResultMessage(exchange)); - flushSessionEntries(manager, state.session.file); - - const reloadedTarget = await selectedSessionFile(state); - if (!reloadedTarget.ok) { - return createJsonRpcFailure(requestId, reloadedTarget.code, reloadedTarget.message); - } - const reloaded = pendingExchangeFromEnvelope(reloadedTarget.envelope); +const READ_ONLY_RPC_METHOD_REGISTRY = FULL_RPC_METHOD_REGISTRY.filter( + (definition) => definition.access === 'read', +); - return createJsonRpcSuccess(requestId, { - status: 'pending', - exchange: reloaded ?? exchange, +export async function runJsonRpcLineServer(options: { + input: Readable; + output: Writable; + handlers: RpcHandlers; + productUpdates?: ProductUpdatePublisher; +}): Promise { + const unsubscribe = options.productUpdates?.subscribe((updates) => { + options.output.write(`${JSON.stringify(createProductUpdateNotification(updates))}\n`); }); -} - -async function handleRespondToElicitation( - requestId: JsonRpcId, - rawParams: unknown, - options: { - coordinator: DefaultWorkspaceCoordinator; - cwd: string; - }, -): Promise { - if (!Value.Check(ElicitationRespondParamsSchema, rawParams)) { - return createJsonRpcFailure(requestId, -32602, 'Invalid params'); - } - const params: ElicitationRespondParams = Value.Parse(ElicitationRespondParamsSchema, rawParams); - - const state = await options.coordinator.openDefaultWorkspace(); - if (state.status !== 'ready') { - return createJsonRpcFailure(requestId, -32001, 'No selected Brunch session'); - } - - const target = await selectedSessionFile(state); - if (!target.ok) { - return createJsonRpcFailure(requestId, target.code, target.message); - } - - let pending: PendingElicitationExchange | null; + let buffered = ''; try { - pending = pendingExchangeFromEnvelope(target.envelope); - } catch (error) { - if (error instanceof NonLinearTranscriptError) { - return createJsonRpcFailure(requestId, -32002, target.nonLinearMessage); - } - throw error; - } - - if (!pending) { - return createJsonRpcFailure(requestId, -32008, 'No pending elicitation exchange'); - } - - if (params.exchangeId !== pending.exchangeId) { - return createJsonRpcFailure(requestId, -32006, 'Pending elicitation exchange does not match request'); - } - - const accepted = acceptedResponseFromParams(pending, params); - if (!accepted.ok) { - return createJsonRpcFailure(requestId, -32007, accepted.message); - } - - const result: ElicitationRespondResult = { - status: 'accepted', - exchangeId: pending.exchangeId, - answer: accepted.answer, - ...(params.note === undefined ? {} : { note: params.note }), - }; - - state.session.manager.appendMessage(accepted.toolResultMessage); - flushSessionEntries(state.session.manager, state.session.file); - - return createJsonRpcSuccess(requestId, result); -} - -interface AcceptedToolTextContent { - type: 'text'; - text: string; -} - -interface AcceptedToolResultMessage { - role: 'toolResult'; - toolCallId: string; - toolName: string; - content: AcceptedToolTextContent[]; - details: Record; - isError: false; - timestamp: 0; -} - -type AcceptedResponse = - | { - ok: true; - answer: Record; - toolResultMessage: AcceptedToolResultMessage; - } - | { - ok: false; - message: string; - }; - -function acceptedResponseFromParams( - pending: PendingElicitationExchange, - params: ElicitationRespondParams, -): AcceptedResponse { - if ('text' in params.answer) { - if (pending.mode !== 'text') return invalidResponseMode(); - const details = requestDetailsBase(pending, 'request_answer'); - return { - ok: true, - answer: { text: params.answer.text }, - toolResultMessage: { - ...toolResultMessageBase(pending, 'request_answer'), - content: [{ type: 'text', text: `### Response\n\n${params.answer.text}` }], - details: { ...details, answer: params.answer.text }, - }, - }; - } - - if ('optionId' in params.answer) { - if (pending.mode !== 'single-select') return invalidResponseMode(); - const optionId = params.answer.optionId; - const choice = pending.options.find((option) => option.id === optionId); - if (!choice) return { ok: false, message: 'Invalid elicitation option' }; - const details = requestDetailsBase(pending, 'request_choice'); - if (params.note !== undefined && params.note.trim().length > 0) { - details.comment = params.note.trim(); - } - return { - ok: true, - answer: { optionId: choice.id, label: choice.label }, - toolResultMessage: { - ...toolResultMessageBase(pending, 'request_choice'), - content: [{ type: 'text', text: choiceResponseMarkdown([choice], params.note) }], - details: { ...details, choice }, - }, - }; - } - - if (pending.mode !== 'multi-select') return invalidResponseMode(); - const selected = params.answer.optionIds.map((id) => pending.options.find((option) => option.id === id)); - if (selected.some((choice) => choice === undefined)) { - return { ok: false, message: 'Invalid elicitation option' }; - } - const choices = selected as PendingChoice[]; - if ( - choices.some((choice) => choice.id === 'other' || choice.id === 'none') && - (params.note === undefined || params.note.trim().length === 0) - ) { - return { - ok: false, - message: 'Elicitation response requires a comment for Other or None selections', - }; - } - const details = requestDetailsBase(pending, 'request_choices'); - if (params.note !== undefined && params.note.trim().length > 0) { - details.comment = params.note.trim(); - } - return { - ok: true, - answer: { optionIds: choices.map((choice) => choice.id), choices }, - toolResultMessage: { - ...toolResultMessageBase(pending, 'request_choices'), - content: [{ type: 'text', text: choiceResponseMarkdown(choices, params.note) }], - details: { ...details, choices }, - }, - }; -} - -function invalidResponseMode(): AcceptedResponse { - return { - ok: false, - message: 'Elicitation response mode does not match pending exchange', - }; -} - -function requestDetailsBase( - pending: PendingElicitationExchange, - requestTool: 'request_answer' | 'request_choice' | 'request_choices', -): Record { - return { - schema: 'brunch.structured_exchange.request', - schemaVersion: 1, - exchangeId: pending.exchangeId, - requestTool, - status: 'answered', - respondsTo: { - exchangeId: pending.exchangeId, - presentTool: pending.mode === 'text' ? 'present_question' : 'present_options', - }, - createdAtToolCallId: `${pending.exchangeId}:${requestTool}`, - }; -} - -function toolResultMessageBase( - pending: PendingElicitationExchange, - requestTool: 'request_answer' | 'request_choice' | 'request_choices', -) { - return { - role: 'toolResult' as const, - toolCallId: `${pending.exchangeId}:${requestTool}`, - toolName: requestTool, - isError: false as const, - timestamp: 0 as const, - }; -} - -function choiceResponseMarkdown(choices: Array<{ label: string }>, comment: string | undefined): string { - const lines = ['### Response', '', ...choices.map((choice) => `- ${choice.label}`)]; - if (comment !== undefined && comment.trim().length > 0) { - lines.push('', 'Comment:', '', `> ${comment.trim()}`); - } - return lines.join('\n'); -} - -interface PendingChoice { - id: string; - label: string; - content: string; - rationale?: string; -} - -type PendingElicitationExchange = Static; - -function nextDeterministicElicitationExchange(completedCount: number): PendingElicitationExchange { - const turnNumber = completedCount + 1; - const script: PendingElicitationExchange[] = [ - { - exchangeId: `deterministic-grounding-choice-${turnNumber}`, - lens: 'intent', - mode: 'single-select', - prompt: 'Is this a new product or feature from scratch?', - details: 'Choose the best starting context so later elicitation can ask useful follow-ups.', - options: [ - { - id: 'new-from-scratch', - label: 'Yes — this is new from scratch', - content: 'Start a new spec workspace from a blank slate.', - rationale: 'This keeps the parity run focused on initial grounding.', - }, - { - id: 'existing-codebase', - label: 'No — this builds on existing code', - content: 'Ground the spec in existing implementation constraints.', - rationale: 'Existing code changes what the elicitor should inspect next.', - }, - { - id: 'relates-to-existing-spec', - label: 'It relates to an existing spec', - content: 'Connect this work to a prior specification thread.', - rationale: 'Continuity matters when prior graph intent exists.', - }, - ], - note: { allowed: true }, - }, - { - exchangeId: `deterministic-grounding-text-${turnNumber}`, - lens: 'intent', - mode: 'text', - prompt: 'What are we specifying?', - details: - "This covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof.", - options: [], - note: { allowed: true }, - }, - { - exchangeId: `deterministic-grounding-multi-${turnNumber}`, - lens: 'intent', - mode: 'multi-select', - prompt: 'Which proof qualities matter for this parity run?', - details: - 'Select all qualities the deterministic structured-exchange permutation proof should preserve.', - options: [ - { - id: 'transcript', - label: 'Transcript fidelity', - content: 'Pi JSONL keeps every present/request tuple recoverable.', - rationale: 'The transcript is the durable source of truth.', - }, - { - id: 'projection', - label: 'Projection fidelity', - content: 'Brunch projections preserve semantic option artifacts.', - rationale: 'Public clients depend on projected structured exchange data.', - }, - { - id: 'other', - label: 'Other', - content: 'Another proof quality should be captured in the note.', - rationale: 'Other requires a comment so the transcript stays explicit.', - }, - { - id: 'none', - label: 'None', - content: 'No additional proof qualities matter for this run.', - rationale: 'None requires a comment to avoid silent dismissal.', - }, - ], - note: { allowed: true }, - }, - ]; - return script[completedCount % script.length]!; -} - -function presentToolResultMessage(exchange: PendingElicitationExchange) { - const presentTool = exchange.mode === 'text' ? 'present_question' : 'present_options'; - const requestTool = - exchange.mode === 'text' - ? 'request_answer' - : exchange.mode === 'multi-select' - ? 'request_choices' - : 'request_choice'; - const toolCallId = `${exchange.exchangeId}:${presentTool}`; - return { - role: 'toolResult' as const, - toolCallId, - toolName: presentTool, - content: [{ type: 'text' as const, text: presentMarkdown(exchange) }], - details: { - schema: 'brunch.structured_exchange.present', - schemaVersion: 1, - exchangeId: exchange.exchangeId, - presentTool, - kind: exchange.mode === 'text' ? 'question' : 'options', - status: 'presented', - expectedRequest: { tool: requestTool, required: true }, - createdAtToolCallId: toolCallId, - prompt: exchange.prompt, - details: exchange.details, - lens: exchange.lens, - options: exchange.options, - }, - isError: false as const, - timestamp: 0 as const, - }; -} - -function presentMarkdown(exchange: PendingElicitationExchange): string { - if (exchange.mode === 'text') { - return [`## ${exchange.prompt}`, exchange.details].filter(Boolean).join('\n\n'); - } - const lines = [`## ${exchange.prompt}`]; - if (exchange.details) lines.push('', exchange.details); - exchange.options.forEach((option, index) => { - lines.push('', `### ${index + 1}. ${option.content}`); - if (option.rationale) { - lines.push('', `**Rationale:** ${option.rationale}`); - } - lines.push('', ``); - }); - return lines.join('\n'); -} - -function pendingExchangeFromEnvelope(envelope: BrunchSessionEnvelope): PendingElicitationExchange | null { - const projection = projectLinearElicitationExchangeProjection(envelope); - if (!projection.openPrompt) { - return null; - } - - for (const entryId of projection.openPrompt.promptEntryIds) { - const entry = envelope.entries.find( - (candidate) => - candidate.type === 'custom_message' && - candidate.id === entryId && - candidate.customType === 'brunch.elicitation_prompt' && - Value.Check(PendingElicitationExchangeSchema, candidate.details), - ); - if (entry?.type === 'custom_message') { - return Value.Parse(PendingElicitationExchangeSchema, entry.details); - } - } - - for (const entryId of projection.openPrompt.promptEntryIds) { - const entry = envelope.entries.find( - (candidate) => candidate.type === 'message' && candidate.id === entryId, - ); - const details = structuredExchangePresentDetails(entry); - if (!details) continue; - const text = textContent((entry as { message: { content?: unknown } }).message.content); - return pendingExchangeFromStructuredPresent(details, text); - } - - return null; -} - -function pendingExchangeFromStructuredPresent( - details: StructuredExchangePresentDetails, - markdown: string, -): PendingElicitationExchange { - const richDetails = details as StructuredExchangePresentDetails & { - prompt?: unknown; - details?: unknown; - options?: unknown; - }; - const prompt = - typeof richDetails.prompt === 'string' - ? richDetails.prompt - : (firstNonEmptyMarkdownLine(markdown) ?? markdown); - const detailsText = typeof richDetails.details === 'string' ? richDetails.details : markdown; - return { - exchangeId: details.exchangeId, - lens: 'intent', - mode: - details.expectedRequest?.tool === 'request_choices' - ? 'multi-select' - : details.presentTool === 'present_question' - ? 'text' - : 'single-select', - prompt, - ...(detailsText.length > 0 ? { details: detailsText } : {}), - options: parsePendingOptions(richDetails.options, markdown), - note: { allowed: true }, - }; -} - -function parsePendingOptions(value: unknown, markdown: string = ''): PendingChoice[] { - if (!Array.isArray(value)) return parseMarkdownPendingOptions(markdown); - const options = value.flatMap((option) => { - if (typeof option !== 'object' || option === null) return []; - const id = (option as { id?: unknown }).id; - const label = (option as { label?: unknown }).label; - const content = (option as { content?: unknown }).content; - const rationale = (option as { rationale?: unknown }).rationale; - if (typeof id !== 'string') return []; - const optionContent = - typeof content === 'string' ? content : typeof label === 'string' ? label : undefined; - if (optionContent === undefined) return []; - return [ - { - id, - label: typeof label === 'string' ? label : optionContent, - content: optionContent, - ...(typeof rationale === 'string' ? { rationale } : {}), - }, - ]; - }); - return options.length > 0 ? options : parseMarkdownPendingOptions(markdown); -} - -function parseMarkdownPendingOptions(markdown: string): PendingChoice[] { - const options: PendingChoice[] = []; - let pending: - | { - content: string; - rationale?: string; + for await (const chunk of options.input) { + buffered += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + let newlineIndex = buffered.indexOf('\n'); + while (newlineIndex >= 0) { + const line = buffered.slice(0, newlineIndex); + buffered = buffered.slice(newlineIndex + 1); + await dispatchJsonRpcLine(line, options); + newlineIndex = buffered.indexOf('\n'); } - | undefined; - - for (const line of markdown.split('\n')) { - const heading = /^###\s+\d+\.\s+(.+)$/.exec(line.trim()); - if (heading) { - pending = { content: heading[1]!.trim() }; - continue; } - const rationale = /^\*\*Rationale:\*\*\s+(.+)$/.exec(line.trim()); - if (rationale && pending) { - pending.rationale = rationale[1]!.trim(); - continue; - } - - const optionId = //.exec(line.trim()); - if (optionId && pending) { - const content = pending.content; - options.push({ - id: optionId[1]!.trim(), - label: content, - content, - ...(pending.rationale === undefined ? {} : { rationale: pending.rationale }), - }); - pending = undefined; + if (buffered.length > 0) { + await dispatchJsonRpcLine(buffered, options); } + } finally { + unsubscribe?.(); } - - return options; -} - -function structuredExchangePresentDetails(entry: unknown): StructuredExchangePresentDetails | undefined { - if (typeof entry !== 'object' || entry === null || (entry as { type?: unknown }).type !== 'message') { - return undefined; - } - const message = (entry as { message?: unknown }).message; - if ( - typeof message !== 'object' || - message === null || - (message as { role?: unknown }).role !== 'toolResult' - ) { - return undefined; - } - const details = (message as { details?: unknown }).details; - return isStructuredExchangePresentDetails(details) - ? (details as StructuredExchangePresentDetails) - : undefined; -} - -function firstNonEmptyMarkdownLine(markdown: string): string | undefined { - return markdown - .split('\n') - .map((line) => line.replace(/^#+\s*/, '').trim()) - .find((line) => line.length > 0); } -function textContent(content: unknown): string { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return content - .map((part) => - typeof part === 'object' && part !== null && typeof (part as { text?: unknown }).text === 'string' - ? (part as { text: string }).text - : '', - ) - .filter((text) => text.length > 0) - .join('\n'); -} - -function projectPendingElicitationExchange( - envelope: BrunchSessionEnvelope, -): Static { - const exchange = pendingExchangeFromEnvelope(envelope); - if (!exchange) { - return { status: 'idle', exchange: null }; - } - return { status: 'pending', exchange }; -} - -interface FlushableSessionManager { - _rewriteFile(): void; - setSessionFile(file: string): void; -} - -function flushSessionEntries(manager: unknown, sessionFile: string): void { - const flushable = manager as FlushableSessionManager; - flushable._rewriteFile(); - flushable.setSessionFile(sessionFile); -} - -type SessionProjectionParamsParseResult = - | { - ok: true; - value: ExplicitSessionProjectionParams | null; - } - | { ok: false }; - -function parseSessionProjectionParams(value: unknown): SessionProjectionParamsParseResult { - if (value === undefined) { - return { ok: true, value: null }; - } - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return { ok: false }; - } - - const keys = Object.keys(value); - if (!keys.every((key) => key === 'sessionId' || key === 'specId')) { - return { ok: false }; - } - - const sessionId = (value as { sessionId?: unknown }).sessionId; - const specId = (value as { specId?: unknown }).specId; - if ( - typeof sessionId !== 'string' || - sessionId.length === 0 || - (specId !== undefined && (typeof specId !== 'number' || !Number.isInteger(specId) || specId < 1)) - ) { - return { ok: false }; - } - - return { - ok: true, - value: specId === undefined ? { sessionId } : { sessionId, specId }, - }; -} - -async function selectedSessionFile(state: WorkspaceSessionState): Promise { - if (state.status !== 'ready') { - return { ok: false, code: -32001, message: 'No selected Brunch session' }; - } - - const readResult = await readBrunchSessionEnvelope(state.session.file); - if (!readResult.ok) { - return { - ok: false, - code: -32005, - message: 'Brunch session self-description is invalid', - }; +async function dispatchJsonRpcLine( + line: string, + options: { + output: Writable; + handlers: RpcHandlers; + }, +): Promise { + if (line.trim().length === 0) { + return; } - return { - ok: true, - envelope: readResult.envelope, - nonLinearMessage: 'Selected Brunch session transcript is non-linear', - }; -} - -export async function runJsonRpcLineServer(options: { - input: Readable; - output: Writable; - handlers: RpcHandlers; -}): Promise { - const lines = createInterface({ input: options.input }); - for await (const line of lines) { - if (line.trim().length === 0) { - continue; - } - - const response = await dispatchJsonRpcMessage(line, options.handlers); - options.output.write(`${JSON.stringify(response)}\n`); - } + const response = await dispatchJsonRpcMessage(line, options.handlers); + options.output.write(`${JSON.stringify(response)}\n`); } diff --git a/src/rpc/methods/graph.ts b/src/rpc/methods/graph.ts new file mode 100644 index 00000000..9e1bfad7 --- /dev/null +++ b/src/rpc/methods/graph.ts @@ -0,0 +1,147 @@ +import { Type, type Static } from 'typebox'; +import { Value } from 'typebox/value'; + +import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js'; +import type { RpcMethodContext, RpcMethodDefinition } from './registry.js'; +import { NonNegativeIntegerSchema, PositiveIntegerSchema } from './schemas.js'; + +const GraphOverviewParamsSchema = Type.Object( + { + specId: PositiveIntegerSchema, + }, + { additionalProperties: false }, +); + +type GraphOverviewParams = Static; + +const GraphNodeNeighborhoodParamsSchema = Type.Object( + { + specId: PositiveIntegerSchema, + nodeId: PositiveIntegerSchema, + hops: Type.Optional(PositiveIntegerSchema), + }, + { additionalProperties: false }, +); + +type GraphNodeNeighborhoodParams = Static; + +const GraphNodeResultSchema = Type.Object({}, { additionalProperties: true }); +const GraphEdgeResultSchema = Type.Object({}, { additionalProperties: true }); + +const GraphOverviewResultSchema = Type.Object( + { + nodes: Type.Array(GraphNodeResultSchema), + edges: Type.Array(GraphEdgeResultSchema), + nodeCount: NonNegativeIntegerSchema, + edgeCount: NonNegativeIntegerSchema, + lsn: NonNegativeIntegerSchema, + }, + { additionalProperties: false }, +); + +const GraphNodeNeighborhoodResultSchema = Type.Union([ + Type.Object( + { + status: Type.Literal('success'), + anchor: GraphNodeResultSchema, + neighbors: Type.Array(GraphNodeResultSchema), + edges: Type.Array(GraphEdgeResultSchema), + }, + { additionalProperties: false }, + ), + Type.Object( + { + status: Type.Literal('not_found'), + }, + { additionalProperties: false }, + ), +]); + +export const graphRpcMethods: readonly RpcMethodDefinition[] = [ + { + method: 'graph.overview', + access: 'read', + description: + 'Return the canonical selected-spec graph overview with nodes, edges, counts, and current graph LSN.', + paramsSchema: GraphOverviewParamsSchema, + resultSchema: GraphOverviewResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 12, + method: 'graph.overview', + params: { specId: 1 }, + }, + ], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + const params = parseGraphOverviewParams(request.params); + if (!params.ok) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const graph = await context.getGraphRuntime(); + return createJsonRpcSuccess(requestId, graph.forSpec(params.value.specId).getGraphOverview()); + }, + }, + { + method: 'graph.nodeNeighborhood', + access: 'read', + description: + 'Return a focused same-spec graph neighborhood around one node, or not_found when the node is absent from that spec.', + paramsSchema: GraphNodeNeighborhoodParamsSchema, + resultSchema: GraphNodeNeighborhoodResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 13, + method: 'graph.nodeNeighborhood', + params: { specId: 1, nodeId: 10, hops: 1 }, + }, + ], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + const params = parseGraphNodeNeighborhoodParams(request.params); + if (!params.ok) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const graph = await context.getGraphRuntime(); + return createJsonRpcSuccess( + requestId, + graph + .forSpec(params.value.specId) + .getNodeNeighborhood( + params.value.nodeId, + params.value.hops === undefined ? undefined : { hops: params.value.hops }, + ), + ); + }, + }, +]; + +type GraphOverviewParamsParseResult = + | { + ok: true; + value: GraphOverviewParams; + } + | { ok: false }; + +function parseGraphOverviewParams(value: unknown): GraphOverviewParamsParseResult { + if (!Value.Check(GraphOverviewParamsSchema, value)) { + return { ok: false }; + } + return { ok: true, value: Value.Parse(GraphOverviewParamsSchema, value) }; +} + +type GraphNodeNeighborhoodParamsParseResult = + | { + ok: true; + value: GraphNodeNeighborhoodParams; + } + | { ok: false }; + +function parseGraphNodeNeighborhoodParams(value: unknown): GraphNodeNeighborhoodParamsParseResult { + if (!Value.Check(GraphNodeNeighborhoodParamsSchema, value)) { + return { ok: false }; + } + return { ok: true, value: Value.Parse(GraphNodeNeighborhoodParamsSchema, value) }; +} diff --git a/src/rpc/methods/registry.ts b/src/rpc/methods/registry.ts new file mode 100644 index 00000000..1f9e0754 --- /dev/null +++ b/src/rpc/methods/registry.ts @@ -0,0 +1,57 @@ +import type { WorkspaceGraphRuntime } from '../../graph/workspace-store.js'; +import type { + DefaultWorkspaceCoordinator, + SpecSessionActivationCoordinator, +} from '../../session/workspace-session-coordinator.js'; +import type { ProductUpdatePublisher } from '../product-updates.js'; +import type { JsonRpcRequest, JsonRpcResponse } from '../protocol.js'; + +export type RpcMethodAccess = 'read' | 'write'; + +export interface RpcMethodDefinition { + readonly method: string; + readonly access: RpcMethodAccess; + readonly description: string; + readonly paramsSchema: unknown; + readonly resultSchema: unknown; + readonly examples: readonly JsonRpcRequest[]; + handle(context: Context, request: JsonRpcRequest): Promise; +} + +export interface RpcMethodContext { + readonly coordinator: DefaultWorkspaceCoordinator & SpecSessionActivationCoordinator; + readonly cwd: string; + readonly productUpdates?: ProductUpdatePublisher; + readonly getGraphRuntime: () => Promise; + readonly discoveryRegistry: readonly RpcMethodDefinition[]; +} + +export type RpcMethodRegistry = readonly RpcMethodDefinition[]; + +export type RpcMethodDiscovery = { + method: string; + description: string; + paramsSchema: unknown; + resultSchema: unknown; + examples: readonly JsonRpcRequest[]; +}; + +export function discoverRpcMethods(registry: RpcMethodRegistry): { + methods: RpcMethodDiscovery[]; +} { + return { + methods: registry.map(({ method, description, paramsSchema, resultSchema, examples }) => ({ + method, + description, + paramsSchema, + resultSchema, + examples, + })), + }; +} + +export function registryByMethod( + registry: RpcMethodRegistry, +): ReadonlyMap> { + return new Map(registry.map((definition) => [definition.method, definition])); +} diff --git a/src/rpc/methods/schemas.ts b/src/rpc/methods/schemas.ts new file mode 100644 index 00000000..70831ba8 --- /dev/null +++ b/src/rpc/methods/schemas.ts @@ -0,0 +1,6 @@ +import { Type } from 'typebox'; + +export const NonBlankStringSchema = Type.String({ minLength: 1, pattern: '\\S' }); +export const PositiveIntegerSchema = Type.Integer({ minimum: 1 }); +export const NoParamsSchema = Type.Void({ description: 'Omit JSON-RPC params.' }); +export const NonNegativeIntegerSchema = Type.Integer({ minimum: 0 }); diff --git a/src/rpc/methods/session.ts b/src/rpc/methods/session.ts new file mode 100644 index 00000000..fa9404b3 --- /dev/null +++ b/src/rpc/methods/session.ts @@ -0,0 +1,555 @@ +import { Type, type Static } from 'typebox'; +import { Value } from 'typebox/value'; + +import { + readBrunchSessionEnvelope, + NonLinearTranscriptError, + type BrunchSessionEnvelope, +} from '../../session/brunch-session-envelope.js'; +import { projectLinearSessionExchangeProjection } from '../../session/exchange-projection.js'; +import { projectSessionRuntimeState } from '../../session/runtime-state.js'; +import { + resolveExplicitSessionProjectionTarget, + type ExplicitSessionProjectionParams, + type SessionProjectionTarget, +} from '../../session/session-projection-reader.js'; +import { + acceptedResponseFromParams, + nextDeterministicStructuredExchange, + pendingExchangeFromEnvelope, + PendingStructuredExchangeSchema, + presentToolResultMessage, + projectPendingStructuredExchange, +} from '../../session/structured-exchange-loop.js'; +import type { + PendingStructuredExchange, + StructuredExchangeResponseInput, +} from '../../session/structured-exchange-loop.js'; +import type { + DefaultWorkspaceCoordinator, + WorkspaceActivationState, + WorkspaceSessionState, +} from '../../session/workspace-session-coordinator.js'; +import { selectedSessionProductUpdates, type ProductUpdatePublisher } from '../product-updates.js'; +import { + createJsonRpcFailure, + createJsonRpcSuccess, + jsonRpcRequestId, + type JsonRpcId, + type JsonRpcResponse, +} from '../protocol.js'; +import type { RpcMethodContext, RpcMethodDefinition } from './registry.js'; +import { + NoParamsSchema, + NonBlankStringSchema, + NonNegativeIntegerSchema, + PositiveIntegerSchema, +} from './schemas.js'; + +const SessionProjectionParamsSchema = Type.Object( + { + sessionId: NonBlankStringSchema, + specId: Type.Optional(PositiveIntegerSchema), + }, + { additionalProperties: false }, +); + +const RuntimeStateParamsSchema = Type.Object( + { + sessionId: NonBlankStringSchema, + specId: PositiveIntegerSchema, + }, + { additionalProperties: false }, +); + +const SessionExchangesResultSchema = Type.Object( + { + status: Type.String(), + exchanges: Type.Array(Type.Object({}, { additionalProperties: true })), + }, + { additionalProperties: true }, +); + +const RuntimeStateResultSchema = Type.Object( + { + status: Type.Literal('ready'), + specId: PositiveIntegerSchema, + sessionId: NonBlankStringSchema, + agent: Type.Object( + { + operationalMode: Type.Literal('elicit'), + role: Type.Literal('elicitor'), + strategy: Type.Union([ + Type.Literal('auto'), + Type.Literal('step-wise-decision-tree'), + Type.Literal('step-wise-disambiguate'), + Type.Literal('propose-graph'), + Type.Literal('project-graph'), + ]), + lens: Type.Union([ + Type.Literal('auto'), + Type.Literal('intent'), + Type.Literal('design'), + Type.Literal('oracle'), + ]), + goal: Type.Union([ + Type.Literal('auto'), + Type.Literal('grounding-advance'), + Type.Literal('elicit-expand'), + Type.Literal('commit-converge'), + Type.Literal('capture-posture'), + ]), + }, + { additionalProperties: false }, + ), + mentions: Type.Object( + { + graphNodes: Type.Array( + Type.Object( + { + id: NonBlankStringSchema, + handle: Type.Optional(NonBlankStringSchema), + title: Type.Optional(NonBlankStringSchema), + seenLsn: Type.Optional(PositiveIntegerSchema), + }, + { additionalProperties: false }, + ), + ), + files: Type.Array( + Type.Object( + { + path: NonBlankStringSchema, + seenGitHead: Type.Optional(NonBlankStringSchema), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + world: Type.Object( + { + graph: Type.Object( + { + latestLsn: Type.Union([NonNegativeIntegerSchema, Type.Null()]), + }, + { additionalProperties: false }, + ), + git: Type.Object( + { + head: Type.Union([NonBlankStringSchema, Type.Null()]), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, + ), + lifecycle: Type.Object( + { + specOrigin: Type.Union([Type.Literal('new'), Type.Literal('existing'), Type.Null()]), + sessionOrigin: Type.Union([Type.Literal('new'), Type.Literal('resumed'), Type.Null()]), + sessionIndexInSpec: Type.Union([PositiveIntegerSchema, Type.Null()]), + isFirstSessionForSpec: Type.Union([Type.Boolean(), Type.Null()]), + isTenthSessionForSpec: Type.Union([Type.Boolean(), Type.Null()]), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +const TriggerExchangeResultSchema = Type.Object( + { + status: Type.Literal('pending'), + exchange: PendingStructuredExchangeSchema, + }, + { additionalProperties: false }, +); + +const PendingExchangeResultSchema = Type.Union([ + TriggerExchangeResultSchema, + Type.Object( + { + status: Type.Literal('idle'), + exchange: Type.Null(), + }, + { additionalProperties: false }, + ), +]); + +const ExchangeResponseParamsSchema = Type.Object( + { + exchangeId: NonBlankStringSchema, + answer: Type.Union([ + Type.Object( + { text: NonBlankStringSchema }, + { + additionalProperties: false, + }, + ), + Type.Object( + { optionId: NonBlankStringSchema }, + { + additionalProperties: false, + }, + ), + Type.Object( + { optionIds: Type.Array(NonBlankStringSchema, { minItems: 1 }) }, + { additionalProperties: false }, + ), + ]), + note: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +const ExchangeResponseResultSchema = Type.Object( + { + status: Type.Literal('accepted'), + exchangeId: NonBlankStringSchema, + answer: Type.Object({}, { additionalProperties: true }), + note: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +type ExchangeResponseParams = StructuredExchangeResponseInput; +type ExchangeResponseResult = Static; + +export const sessionRpcMethods: readonly RpcMethodDefinition[] = [ + { + method: 'session.exchanges', + access: 'read', + description: + 'Project session exchanges from the selected or explicitly named linear Brunch session transcript.', + paramsSchema: SessionProjectionParamsSchema, + resultSchema: SessionExchangesResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 6, + method: 'session.exchanges', + params: { sessionId: 'session-1', specId: 1 }, + }, + ], + async handle(context, request) { + return handleSessionProjection( + jsonRpcRequestId(request), + request.params, + context, + projectLinearSessionExchangeProjection, + ); + }, + }, + { + method: 'session.runtimeState', + access: 'read', + description: + 'Return flattened transcript-backed runtime posture, mention, world-watermark, and lifecycle state for an explicit Brunch session.', + paramsSchema: RuntimeStateParamsSchema, + resultSchema: RuntimeStateResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 14, + method: 'session.runtimeState', + params: { sessionId: 'session-1', specId: 1 }, + }, + ], + async handle(context, request) { + return handleSessionProjection( + jsonRpcRequestId(request), + request.params, + context, + projectSessionRuntimeState, + { requireExplicitSpec: true }, + ); + }, + }, + { + method: 'session.triggerExchange', + access: 'write', + description: + "Start or resume the selected session's deterministic structured-exchange permutation loop and return the current pending exchange.", + paramsSchema: NoParamsSchema, + resultSchema: TriggerExchangeResultSchema, + examples: [{ jsonrpc: '2.0', id: 8, method: 'session.triggerExchange' }], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + return handleTriggerExchange(requestId, context); + }, + }, + { + method: 'session.pendingExchange', + access: 'read', + description: + 'Read the current transcript-backed pending structured exchange from the selected or explicitly named linear Brunch session.', + paramsSchema: SessionProjectionParamsSchema, + resultSchema: PendingExchangeResultSchema, + examples: [ + { jsonrpc: '2.0', id: 9, method: 'session.pendingExchange' }, + { + jsonrpc: '2.0', + id: 10, + method: 'session.pendingExchange', + params: { sessionId: 'session-1', specId: 1 }, + }, + ], + async handle(context, request) { + return handleSessionProjection( + jsonRpcRequestId(request), + request.params, + context, + projectPendingStructuredExchange, + ); + }, + }, + { + method: 'session.submitExchangeResponse', + access: 'write', + description: + "Submit a text, single-choice, or multi-choice answer for the selected session's current deterministic tuple-shaped pending structured exchange.", + paramsSchema: ExchangeResponseParamsSchema, + resultSchema: ExchangeResponseResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 11, + method: 'session.submitExchangeResponse', + params: { + exchangeId: 'deterministic-grounding-choice', + answer: { optionId: 'new-from-scratch' }, + note: 'This is a greenfield product.', + }, + }, + ], + async handle(context, request) { + return handleSubmitExchangeResponse(jsonRpcRequestId(request), request.params, context); + }, + }, +]; +async function handleSessionProjection( + requestId: JsonRpcId, + rawParams: unknown, + options: { + coordinator: DefaultWorkspaceCoordinator; + cwd: string; + }, + loadProjection: (envelope: BrunchSessionEnvelope) => T, + policy: { requireExplicitSpec?: boolean } = {}, +): Promise { + const params = parseSessionProjectionParams(rawParams); + if (!params.ok || (policy.requireExplicitSpec && params.value?.specId === undefined)) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + + const target = params.value + ? await resolveExplicitSessionProjectionTarget(options.cwd, params.value) + : await selectedSessionFile(await options.coordinator.openDefaultWorkspace()); + if (!target.ok) { + return createJsonRpcFailure(requestId, target.code, target.message); + } + + try { + return createJsonRpcSuccess(requestId, loadProjection(target.envelope)); + } catch (error) { + if (error instanceof NonLinearTranscriptError) { + return createJsonRpcFailure(requestId, -32002, target.nonLinearMessage); + } + throw error; + } +} + +async function handleTriggerExchange( + requestId: JsonRpcId, + options: { + coordinator: DefaultWorkspaceCoordinator; + cwd: string; + productUpdates?: ProductUpdatePublisher; + }, +): Promise { + const state = await options.coordinator.openDefaultWorkspace(); + if (state.status !== 'ready') { + return createJsonRpcFailure(requestId, -32001, 'No selected Brunch session'); + } + + const existingTarget = await selectedSessionFile(state); + if (!existingTarget.ok) { + return createJsonRpcFailure(requestId, existingTarget.code, existingTarget.message); + } + + const existing = pendingExchangeFromEnvelope(existingTarget.envelope); + if (existing) { + return createJsonRpcSuccess(requestId, { + status: 'pending', + exchange: existing, + }); + } + + const exchange = nextDeterministicStructuredExchange( + projectLinearSessionExchangeProjection(existingTarget.envelope).exchanges.length, + ); + const manager = state.session.manager; + manager.appendMessage(presentToolResultMessage(exchange)); + flushSessionEntries(manager, state.session.file); + + const reloadedTarget = await selectedSessionFile(state); + if (!reloadedTarget.ok) { + return createJsonRpcFailure(requestId, reloadedTarget.code, reloadedTarget.message); + } + const reloaded = pendingExchangeFromEnvelope(reloadedTarget.envelope); + + const result = { + status: 'pending' as const, + exchange: reloaded ?? exchange, + }; + publishSelectedSessionUpdates(options.productUpdates, state); + return createJsonRpcSuccess(requestId, result); +} + +async function handleSubmitExchangeResponse( + requestId: JsonRpcId, + rawParams: unknown, + options: { + coordinator: DefaultWorkspaceCoordinator; + cwd: string; + productUpdates?: ProductUpdatePublisher; + }, +): Promise { + if (!Value.Check(ExchangeResponseParamsSchema, rawParams)) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const params = Value.Parse(ExchangeResponseParamsSchema, rawParams) as ExchangeResponseParams; + + const state = await options.coordinator.openDefaultWorkspace(); + if (state.status !== 'ready') { + return createJsonRpcFailure(requestId, -32001, 'No selected Brunch session'); + } + + const target = await selectedSessionFile(state); + if (!target.ok) { + return createJsonRpcFailure(requestId, target.code, target.message); + } + + let pending: PendingStructuredExchange | null; + try { + pending = pendingExchangeFromEnvelope(target.envelope); + } catch (error) { + if (error instanceof NonLinearTranscriptError) { + return createJsonRpcFailure(requestId, -32002, target.nonLinearMessage); + } + throw error; + } + + if (!pending) { + return createJsonRpcFailure(requestId, -32008, 'No pending structured exchange'); + } + + if (params.exchangeId !== pending.exchangeId) { + return createJsonRpcFailure(requestId, -32006, 'Pending structured exchange does not match request'); + } + + const accepted = acceptedResponseFromParams(pending, params); + if (!accepted.ok) { + return createJsonRpcFailure(requestId, -32007, accepted.message); + } + + const result: ExchangeResponseResult = { + status: 'accepted', + exchangeId: pending.exchangeId, + answer: accepted.answer, + ...(params.note === undefined ? {} : { note: params.note }), + }; + + state.session.manager.appendMessage(accepted.toolResultMessage); + flushSessionEntries(state.session.manager, state.session.file); + + publishSelectedSessionUpdates(options.productUpdates, state); + return createJsonRpcSuccess(requestId, result); +} + +interface FlushableSessionManager { + _rewriteFile(): void; + setSessionFile(file: string): void; +} + +function flushSessionEntries(manager: unknown, sessionFile: string): void { + const flushable = manager as FlushableSessionManager; + flushable._rewriteFile(); + flushable.setSessionFile(sessionFile); +} + +type SessionProjectionParamsParseResult = + | { + ok: true; + value: ExplicitSessionProjectionParams | null; + } + | { ok: false }; + +function parseSessionProjectionParams(value: unknown): SessionProjectionParamsParseResult { + if (value === undefined) { + return { ok: true, value: null }; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { ok: false }; + } + + const keys = Object.keys(value); + if (!keys.every((key) => key === 'sessionId' || key === 'specId')) { + return { ok: false }; + } + + const sessionId = (value as { sessionId?: unknown }).sessionId; + const specId = (value as { specId?: unknown }).specId; + if ( + typeof sessionId !== 'string' || + sessionId.length === 0 || + (specId !== undefined && (typeof specId !== 'number' || !Number.isInteger(specId) || specId < 1)) + ) { + return { ok: false }; + } + + return { + ok: true, + value: specId === undefined ? { sessionId } : { sessionId, specId }, + }; +} + +async function selectedSessionFile(state: WorkspaceSessionState): Promise { + if (state.status !== 'ready') { + return { ok: false, code: -32001, message: 'No selected Brunch session' }; + } + + const readResult = await readBrunchSessionEnvelope(state.session.file); + if (!readResult.ok) { + return { + ok: false, + code: -32005, + message: 'Brunch session self-description is invalid', + }; + } + + return { + ok: true, + envelope: readResult.envelope, + nonLinearMessage: 'Selected Brunch session transcript is non-linear', + }; +} + +function publishSelectedSessionUpdates( + publisher: ProductUpdatePublisher | undefined, + state: WorkspaceActivationState | WorkspaceSessionState, +): void { + if (!publisher || state.status !== 'ready') { + return; + } + publisher.publish( + selectedSessionProductUpdates({ + specId: state.spec.id, + sessionId: state.session.id, + }), + ); +} diff --git a/src/rpc/methods/workspace.ts b/src/rpc/methods/workspace.ts new file mode 100644 index 00000000..391761dd --- /dev/null +++ b/src/rpc/methods/workspace.ts @@ -0,0 +1,225 @@ +import { Type, type Static } from 'typebox'; +import { Value } from 'typebox/value'; + +import { workspaceSnapshotFromState } from '../../print-snapshot.js'; +import type { + SpecSessionActivationDecision, + WorkspaceActivationState, + WorkspaceLaunchInventory, + WorkspaceSessionState, +} from '../../session/workspace-session-coordinator.js'; +import { selectedSessionProductUpdates } from '../product-updates.js'; +import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js'; +import type { RpcMethodContext, RpcMethodDefinition } from './registry.js'; +import { NoParamsSchema, NonBlankStringSchema, PositiveIntegerSchema } from './schemas.js'; + +export const SpecSessionActivationDecisionSchema = Type.Union([ + Type.Object( + { + action: Type.Literal('continue'), + specId: PositiveIntegerSchema, + sessionFile: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal('openSession'), + specId: PositiveIntegerSchema, + sessionFile: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal('newSession'), + specId: PositiveIntegerSchema, + }, + { additionalProperties: false }, + ), + Type.Object( + { + action: Type.Literal('newSpec'), + title: NonBlankStringSchema, + }, + { additionalProperties: false }, + ), + Type.Object({ action: Type.Literal('cancel') }, { additionalProperties: false }), +]); + +const WorkspaceActivationParamsSchema = Type.Object( + { + decision: SpecSessionActivationDecisionSchema, + }, + { additionalProperties: false }, +); + +type WorkspaceActivationParams = Static; + +const WorkspaceSnapshotResultSchema = Type.Object( + { + status: Type.String(), + cwd: Type.String(), + spec: Type.Union([Type.Null(), Type.Object({}, { additionalProperties: true })]), + session: Type.Optional(Type.Union([Type.Null(), Type.Object({}, { additionalProperties: true })])), + chrome: Type.Object({}, { additionalProperties: true }), + }, + { additionalProperties: true }, +); + +const WorkspaceSelectionStateResultSchema = Type.Object( + { + status: Type.String(), + requiresSelection: Type.Boolean(), + specs: Type.Array(Type.Object({}, { additionalProperties: true })), + sessions: Type.Array(Type.Object({}, { additionalProperties: true })), + }, + { additionalProperties: true }, +); + +const WorkspaceActivationResultSchema = Type.Union([ + Type.Object( + { + status: Type.Literal('ready'), + spec: Type.Object({}, { additionalProperties: true }), + session: Type.Object({}, { additionalProperties: true }), + chrome: Type.Object({}, { additionalProperties: true }), + }, + { additionalProperties: true }, + ), + Type.Object( + { + status: Type.Literal('cancelled'), + chrome: Type.Object({}, { additionalProperties: true }), + }, + { additionalProperties: true }, + ), +]); + +export const workspaceRpcMethods: readonly RpcMethodDefinition[] = [ + { + method: 'workspace.snapshot', + access: 'read', + description: + 'Return the current Brunch workspace/spec/session snapshot for the invocation cwd without changing activation state.', + paramsSchema: NoParamsSchema, + resultSchema: WorkspaceSnapshotResultSchema, + examples: [{ jsonrpc: '2.0', id: 2, method: 'workspace.snapshot' }], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const state = await context.coordinator.openDefaultWorkspace(); + return createJsonRpcSuccess(requestId, workspaceSnapshotFromState(state)); + }, + }, + { + method: 'workspace.selectionState', + access: 'read', + description: + 'Return the product-shaped workspace inventory and whether the client must choose or create a spec/session before an agent loop can run.', + paramsSchema: NoParamsSchema, + resultSchema: WorkspaceSelectionStateResultSchema, + examples: [{ jsonrpc: '2.0', id: 3, method: 'workspace.selectionState' }], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + if (request.params !== undefined) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const [state, inventory] = await Promise.all([ + context.coordinator.openDefaultWorkspace(), + context.coordinator.inspectWorkspace(), + ]); + return createJsonRpcSuccess(requestId, workspaceSelectionStateFromInventory(state, inventory)); + }, + }, + { + method: 'workspace.activate', + access: 'write', + description: + 'Apply an explicit workspace→spec→session activation decision such as continuing, opening a session, creating a session, creating a spec, or cancelling.', + paramsSchema: WorkspaceActivationParamsSchema, + resultSchema: WorkspaceActivationResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 4, + method: 'workspace.activate', + params: { decision: { action: 'newSpec', title: 'POC spec' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'workspace.activate', + params: { + decision: { + action: 'openSession', + specId: 1, + sessionFile: '.brunch/sessions/session-1.jsonl', + }, + }, + }, + ], + async handle(context, request) { + const requestId = jsonRpcRequestId(request); + const decision = parseWorkspaceActivationParams(request.params); + if (!decision.ok) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const state = await context.coordinator.activateWorkspace(decision.value); + const response = workspaceActivationSnapshotFromState(state); + if (context.productUpdates && state.status === 'ready') { + context.productUpdates.publish( + selectedSessionProductUpdates({ specId: state.spec.id, sessionId: state.session.id }), + ); + } + return createJsonRpcSuccess(requestId, response); + }, + }, +]; + +function workspaceSelectionStateFromInventory( + state: WorkspaceSessionState, + inventory: WorkspaceLaunchInventory, +): WorkspaceLaunchInventory & { + status: WorkspaceSessionState['status']; + requiresSelection: boolean; +} { + return { + ...inventory, + status: state.status, + requiresSelection: state.status !== 'ready', + }; +} + +function workspaceActivationSnapshotFromState(state: WorkspaceActivationState) { + if (state.status === 'cancelled') { + return { + status: 'cancelled' as const, + cwd: state.cwd, + spec: state.chrome.spec, + chrome: { + phase: state.chrome.phase, + chatMode: state.chrome.chatMode, + }, + }; + } + + return workspaceSnapshotFromState(state); +} + +type WorkspaceActivationParamsParseResult = + | { + ok: true; + value: SpecSessionActivationDecision; + } + | { ok: false }; + +function parseWorkspaceActivationParams(value: unknown): WorkspaceActivationParamsParseResult { + if (!Value.Check(WorkspaceActivationParamsSchema, value)) { + return { ok: false }; + } + const params: WorkspaceActivationParams = Value.Parse(WorkspaceActivationParamsSchema, value); + return { ok: true, value: params.decision }; +} diff --git a/src/rpc/product-updates.ts b/src/rpc/product-updates.ts new file mode 100644 index 00000000..b4b284c0 --- /dev/null +++ b/src/rpc/product-updates.ts @@ -0,0 +1,105 @@ +export const BRUNCH_UPDATED_METHOD = 'brunch.updated'; + +export type ProductUpdateTopic = + | 'workspace.snapshot' + | 'workspace.selectionState' + | 'session.pendingExchange' + | 'session.exchanges' + | 'session.runtimeState' + | 'graph.overview' + | 'graph.nodeNeighborhood'; + +export interface ProductUpdate { + readonly topic: ProductUpdateTopic; + readonly specId?: number; + readonly sessionId?: string; + readonly nodeId?: number; + readonly lsn?: number; +} + +export interface ProductUpdateNotification { + readonly jsonrpc: '2.0'; + readonly method: typeof BRUNCH_UPDATED_METHOD; + readonly params: { + readonly topics: readonly ProductUpdateTopic[]; + readonly updates: readonly ProductUpdate[]; + }; +} + +export type ProductUpdateListener = (updates: readonly ProductUpdate[]) => void; + +export interface ProductUpdatePublisher { + publish(update: ProductUpdate | readonly ProductUpdate[]): void; + subscribe(listener: ProductUpdateListener): () => void; +} + +export function createProductUpdatePublisher(): ProductUpdatePublisher { + const listeners = new Set(); + return { + publish(update) { + const updates = Array.isArray(update) ? update : [update]; + if (updates.length === 0) { + return; + } + for (const listener of listeners) { + listener(updates); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +export function createProductUpdateNotification( + updates: readonly ProductUpdate[], +): ProductUpdateNotification { + return { + jsonrpc: '2.0', + method: BRUNCH_UPDATED_METHOD, + params: { + topics: uniqueTopics(updates), + updates, + }, + }; +} + +export function selectedSessionProductUpdates(target?: { + readonly specId?: number; + readonly sessionId?: string; +}): readonly ProductUpdate[] { + return [ + productUpdate('workspace.snapshot', target), + productUpdate('session.pendingExchange', target), + productUpdate('session.exchanges', target), + productUpdate('session.runtimeState', target), + ]; +} + +export function graphMutationProductUpdates(target: { + readonly specId: number; + readonly lsn: number; +}): readonly ProductUpdate[] { + return [ + { topic: 'graph.overview', specId: target.specId, lsn: target.lsn }, + { topic: 'graph.nodeNeighborhood', specId: target.specId, lsn: target.lsn }, + ]; +} + +function productUpdate( + topic: ProductUpdateTopic, + target: { readonly specId?: number; readonly sessionId?: string } | undefined, +): ProductUpdate { + return { + topic, + ...(target?.specId === undefined ? {} : { specId: target.specId }), + ...(target?.sessionId === undefined ? {} : { sessionId: target.sessionId }), + }; +} + +function uniqueTopics(updates: readonly ProductUpdate[]): readonly ProductUpdateTopic[] { + return [...new Set(updates.map((update) => update.topic))]; +} diff --git a/src/rpc/web-host.test.ts b/src/rpc/web-host.test.ts index 31a0c1c3..40cd60f5 100644 --- a/src/rpc/web-host.test.ts +++ b/src/rpc/web-host.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,11 +6,13 @@ import { join } from 'node:path'; import { SessionManager } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; +import { openWorkspaceGraphRuntime } from '../graph/workspace-store.js'; import { assistantMessage, userMessage } from '../probes/test-helpers.js'; import { createWorkspaceSessionCoordinator, type WorkspaceSessionCoordinator, } from '../session/workspace-session-coordinator.js'; +import { createProductUpdatePublisher } from './product-updates.js'; import { startWebHost } from './web-host.js'; function text(response: Response): Promise { @@ -78,6 +80,25 @@ describe('web host', () => { } }); + it('serves index.html for client-side spec routes as an SPA fallback', async () => { + const assetRoot = await builtWebAssets(); + const host = await startWebHost({ + cwd: '/tmp/brunch-project', + port: 0, + webAssetRoot: assetRoot, + }); + try { + const response = await fetch(`${host.url}/spec/42`); + const html = await text(response); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(html).toContain('data-built-shell="true"'); + } finally { + await host.close(); + } + }); + it('serves built Vite JavaScript assets', async () => { const assetRoot = await builtWebAssets(); const host = await startWebHost({ @@ -181,7 +202,7 @@ describe('web host', () => { const exchanges = await websocketRpc(host.url, { jsonrpc: '2.0', id: 2, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }); expect(snapshot).toMatchObject({ @@ -225,13 +246,7 @@ describe('web host', () => { const response = await websocketRpc(host.url, { jsonrpc: '2.0', id: 14, - method: 'session.elicitationExchanges', - params: { sessionId: first.session.id, specId: first.spec.id }, - }); - const display = await websocketRpc(host.url, { - jsonrpc: '2.0', - id: 15, - method: 'session.transcriptDisplay', + method: 'session.exchanges', params: { sessionId: first.session.id, specId: first.spec.id }, }); @@ -243,23 +258,114 @@ describe('web host', () => { exchanges: [{ promptEntryIds: expect.arrayContaining([expect.any(String)]) }], }, }); - expect(display).toMatchObject({ + } finally { + await host.close(); + } + }); + + it('exposes the web sidecar as a read-only RPC attachment surface', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-web-rpc-read-only-')); + const coordinator = createWorkspaceSessionCoordinator({ cwd }); + const workspace = await coordinator.createSetupSession({ + specTitle: 'Read-only web spec', + }); + workspace.session.manager.appendMessage(assistantMessage('Question')); + workspace.session.manager.appendMessage(userMessage('Answer')); + const graph = await openWorkspaceGraphRuntime(cwd); + const commit = graph.commandExecutor.commitGraph({ + specId: workspace.spec.id, + nodes: [{ ref: 'goal', plane: 'intent', kind: 'goal', title: 'Visible goal' }], + edges: [], + }); + if (commit.status !== 'success') throw new Error('failed to seed graph'); + const host = await startWebHost({ + cwd, + port: 0, + coordinator: createWorkspaceSessionCoordinator({ cwd }), + }); + try { + const discovery = await websocketRpc(host.url, { + jsonrpc: '2.0', + id: 16, + method: 'rpc.discover', + }); + expect(discovery).toMatchObject({ jsonrpc: '2.0', - id: 15, + id: 16, result: { - rows: [ - { role: 'assistant', text: 'First question' }, - { role: 'prompt', text: 'Pick an explicit session direction.' }, - { role: 'user', text: 'First answer' }, - ], + methods: expect.arrayContaining([ + expect.objectContaining({ method: 'workspace.snapshot' }), + expect.objectContaining({ method: 'workspace.selectionState' }), + expect.objectContaining({ method: 'session.pendingExchange' }), + expect.objectContaining({ method: 'session.exchanges' }), + expect.objectContaining({ method: 'graph.overview' }), + expect.objectContaining({ method: 'graph.nodeNeighborhood' }), + ]), }, }); + const discoveredMethods = ( + discovery as { result: { methods: Array<{ method: string }> } } + ).result.methods.map((method) => method.method); + expect(discoveredMethods).not.toContain('workspace.activate'); + expect(discoveredMethods).not.toContain('session.triggerExchange'); + expect(discoveredMethods).not.toContain('session.submitExchangeResponse'); + + await expect( + websocketRpc(host.url, { + jsonrpc: '2.0', + id: 17, + method: 'workspace.activate', + params: { decision: { action: 'continue' } }, + }), + ).resolves.toEqual({ + jsonrpc: '2.0', + id: 17, + error: { code: -32601, message: 'Method not found' }, + }); + await expect( + websocketRpc(host.url, { + jsonrpc: '2.0', + id: 18, + method: 'session.triggerExchange', + }), + ).resolves.toEqual({ + jsonrpc: '2.0', + id: 18, + error: { code: -32601, message: 'Method not found' }, + }); + await expect( + websocketRpc(host.url, { + jsonrpc: '2.0', + id: 19, + method: 'session.submitExchangeResponse', + params: { exchangeId: 'missing', answer: { text: 'nope' } }, + }), + ).resolves.toEqual({ + jsonrpc: '2.0', + id: 19, + error: { code: -32601, message: 'Method not found' }, + }); + + await expect( + websocketRpc(host.url, { + jsonrpc: '2.0', + id: 20, + method: 'graph.overview', + params: { specId: workspace.spec.id }, + }), + ).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 20, + result: { nodes: [expect.objectContaining({ title: 'Visible goal' })] }, + }); + const sessionText = await readFile(workspace.session.file, 'utf8'); + expect(sessionText).not.toContain('deterministic-grounding-choice'); } finally { await host.close(); } }); - it('notifies attached web observers after RPC structured-exchange mutations', async () => { + it('rejects sidecar structured-exchange mutations without publishing product updates', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-web-rpc-live-')); await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: 'Live web spec', @@ -272,84 +378,56 @@ describe('web host', () => { const observer = await openWebSocket(`${host.url.replace(/^http/u, 'ws')}/rpc`); const actor = await openWebSocket(`${host.url.replace(/^http/u, 'ws')}/rpc`); try { - const observerNotification = nextWebSocketMessage(observer); const actorResponse = nextWebSocketMessage(actor); actor.send( JSON.stringify({ jsonrpc: '2.0', id: 21, - method: 'session.startElicitation', + method: 'session.triggerExchange', }), ); - await expect(actorResponse).resolves.toMatchObject({ + await expect(actorResponse).resolves.toEqual({ jsonrpc: '2.0', id: 21, - result: { - status: 'pending', - exchange: { exchangeId: 'deterministic-grounding-choice-1' }, - }, - }); - await expect(observerNotification).resolves.toEqual({ - jsonrpc: '2.0', - method: 'brunch.updated', - params: { - topics: [ - 'workspace.snapshot', - 'session.pendingExchange', - 'session.elicitationExchanges', - 'session.transcriptDisplay', - ], - }, + error: { code: -32601, message: 'Method not found' }, }); + expect(observer.readyState).toBe(WebSocket.OPEN); + } finally { + observer.close(); + actor.close(); + await host.close(); + } + }); - const responseNotification = nextWebSocketMessage(observer); - const respond = await websocketRpc(host.url, { - jsonrpc: '2.0', - id: 23, - method: 'elicitation.respond', - params: { - exchangeId: 'deterministic-grounding-choice-1', - answer: { optionId: 'new-from-scratch' }, - note: 'Observed by the web live-update proof.', - }, - }); + it('broadcasts product update bus events to attached web observers without a request mutation path', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-web-rpc-bus-')); + await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ + specTitle: 'Live bus spec', + }); + const productUpdates = createProductUpdatePublisher(); + const host = await startWebHost({ + cwd, + port: 0, + coordinator: createWorkspaceSessionCoordinator({ cwd }), + productUpdates, + }); + const observer = await openWebSocket(`${host.url.replace(/^http/u, 'ws')}/rpc`); + try { + const notification = nextWebSocketMessage(observer); + productUpdates.publish({ topic: 'graph.overview', specId: 1, lsn: 7 }); - expect(respond).toMatchObject({ - jsonrpc: '2.0', - id: 23, - result: { status: 'accepted' }, - }); - await expect(responseNotification).resolves.toMatchObject({ + await expect(notification).resolves.toEqual({ jsonrpc: '2.0', method: 'brunch.updated', - }); - - const display = await websocketRpc(host.url, { - jsonrpc: '2.0', - id: 22, - method: 'session.transcriptDisplay', - }); - expect(display).toMatchObject({ - jsonrpc: '2.0', - id: 22, - result: { - rows: [ - { - role: 'prompt', - text: expect.stringContaining('Is this a new product or feature from scratch?'), - }, - { - role: 'user', - text: expect.stringContaining('Observed by the web live-update proof.'), - }, - ], + params: { + topics: ['graph.overview'], + updates: [{ topic: 'graph.overview', specId: 1, lsn: 7 }], }, }); } finally { observer.close(); - actor.close(); await host.close(); } }); @@ -471,7 +549,7 @@ describe('web host', () => { const response = await websocketRpc(host.url, { jsonrpc: '2.0', id: 4, - method: 'session.elicitationExchanges', + method: 'session.exchanges', }); expect(response).toEqual({ diff --git a/src/rpc/web-host.ts b/src/rpc/web-host.ts index b74c5290..3715ae93 100644 --- a/src/rpc/web-host.ts +++ b/src/rpc/web-host.ts @@ -1,11 +1,12 @@ import { readFile } from 'node:fs/promises'; -import { createServer, type Server } from 'node:http'; +import { createServer, type Server, type ServerResponse } from 'node:http'; import { dirname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { WorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; -import { createRpcHandlers } from './handlers.js'; -import { attachWebRpcTransport } from './websocket.js'; +import { createReadOnlyRpcHandlers } from './handlers.js'; +import { createProductUpdatePublisher, type ProductUpdatePublisher } from './product-updates.js'; +import { attachWebRpcTransport, type WebRpcTransport } from './websocket.js'; export interface WebHostOptions { cwd: string; @@ -13,6 +14,7 @@ export interface WebHostOptions { hostname?: string; coordinator?: WorkspaceSessionCoordinator; webAssetRoot?: string; + productUpdates?: ProductUpdatePublisher; } export interface RunningWebHost { @@ -27,23 +29,8 @@ export async function startWebHost(options: WebHostOptions): Promise { - if (request.method === 'GET' && request.url === '/') { - void readFile(resolve(webAssetRoot, 'index.html')).then( - (asset) => { - response.writeHead(200, { - 'content-type': 'text/html; charset=utf-8', - 'cache-control': 'no-store', - }); - response.end(asset); - }, - () => { - response.writeHead(500, { - 'content-type': 'text/plain; charset=utf-8', - 'cache-control': 'no-store', - }); - response.end(MISSING_WEB_BUNDLE_MESSAGE); - }, - ); + if (request.method === 'GET' && isSpaFallbackRequest(request.url)) { + serveIndexHtml(response, webAssetRoot); return; } @@ -78,17 +65,20 @@ export async function startWebHost(options: WebHostOptions): Promise { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(asset); + }, + () => { + response.writeHead(500, { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(MISSING_WEB_BUNDLE_MESSAGE); + }, + ); +} interface ResolvedAssetRequest { file: string; relativePath: string; diff --git a/src/rpc/websocket.ts b/src/rpc/websocket.ts index a0479157..e3494b70 100644 --- a/src/rpc/websocket.ts +++ b/src/rpc/websocket.ts @@ -3,6 +3,7 @@ import type { Server as HttpServer } from 'node:http'; import { WebSocketServer, type RawData } from 'ws'; import type { RpcHandlers } from './handlers.js'; +import { createProductUpdateNotification, type ProductUpdatePublisher } from './product-updates.js'; import { dispatchJsonRpcMessage } from './protocol.js'; export interface WebRpcTransport { @@ -13,8 +14,26 @@ export function attachWebRpcTransport(options: { server: HttpServer; path: string; handlers: RpcHandlers; + productUpdates?: ProductUpdatePublisher; }): WebRpcTransport { const webSocketServer = new WebSocketServer({ noServer: true }); + let activeRequests = 0; + const deferredNotifications: string[] = []; + const flushDeferredNotifications = () => { + for (const notification of deferredNotifications.splice(0)) { + broadcastProductUpdate(notification); + } + }; + const publishNotification = (notification: string) => { + if (activeRequests > 0) { + deferredNotifications.push(notification); + return; + } + broadcastProductUpdate(notification); + }; + const unsubscribe = options.productUpdates?.subscribe((updates) => { + publishNotification(JSON.stringify(createProductUpdateNotification(updates))); + }); options.server.on('upgrade', (request, socket, head) => { if (request.url !== options.path) { @@ -29,10 +48,12 @@ export function attachWebRpcTransport(options: { webSocketServer.on('connection', (webSocket) => { webSocket.on('message', (data) => { - void handleMessage(options.handlers, data).then(({ response, method }) => { + activeRequests += 1; + void handleMessage(options.handlers, data).then((response) => { webSocket.send(JSON.stringify(response)); - if (isProductMutation(method) && !Object.hasOwn(response, 'error')) { - broadcastProductUpdate(); + activeRequests -= 1; + if (activeRequests === 0) { + flushDeferredNotifications(); } }); }); @@ -40,6 +61,7 @@ export function attachWebRpcTransport(options: { return { async close() { + unsubscribe?.(); for (const client of webSocketServer.clients) { client.close(); } @@ -54,19 +76,7 @@ export function attachWebRpcTransport(options: { }); }, }; - function broadcastProductUpdate(): void { - const notification = JSON.stringify({ - jsonrpc: '2.0', - method: 'brunch.updated', - params: { - topics: [ - 'workspace.snapshot', - 'session.pendingExchange', - 'session.elicitationExchanges', - 'session.transcriptDisplay', - ], - }, - }); + function broadcastProductUpdate(notification: string): void { for (const client of webSocketServer.clients) { client.send(notification); } @@ -75,31 +85,7 @@ export function attachWebRpcTransport(options: { async function handleMessage(handlers: RpcHandlers, data: RawData) { const message = websocketMessageToString(data); - return { - response: await dispatchJsonRpcMessage(message, handlers), - method: requestMethod(message), - }; -} - -function requestMethod(message: string): string | undefined { - try { - const value = JSON.parse(message) as unknown; - return typeof value === 'object' && - value !== null && - typeof (value as { method?: unknown }).method === 'string' - ? (value as { method: string }).method - : undefined; - } catch { - return undefined; - } -} - -function isProductMutation(method: string | undefined): boolean { - return ( - method === 'workspace.activate' || - method === 'session.startElicitation' || - method === 'elicitation.respond' - ); + return dispatchJsonRpcMessage(message, handlers); } function websocketMessageToString(data: RawData): string { diff --git a/src/session/README.md b/src/session/README.md index 609ba4a1..36dec0f6 100644 --- a/src/session/README.md +++ b/src/session/README.md @@ -10,9 +10,19 @@ plus the coordination logic for workspace/spec/session lifecycle. - **Transcript projection** — reading Pi JSONL, projecting Brunch-relevant structure (assistant/user rows, custom entries, tool results). -- **Exchange extraction** — elicitation exchange projection: prompt-side +- **Exchange extraction** — session exchange projection: prompt-side span + response-side span, per D13-L. +- **Runtime-state projection** — flattened transcript-backed agent posture, + mention, world-watermark, and lifecycle slots from linear Brunch session + envelopes. `.pi` may append operational-mode entries, but the pure projection + lives here. + +- **Structured-exchange loop helpers** — deterministic POC exchange generation, + pending prompt reconstruction from structured transcript tuples, and response + toolResult materialization. RPC maps these domain results to JSON-RPC status + and error codes; transcript mechanics stay here. + - **Workspace coordination** — boot flow, spec/session selection, `.brunch/workspace.json` management. The `WorkspaceSessionCoordinator` is the only module that creates/opens Pi sessions for Brunch user flows @@ -50,8 +60,10 @@ These files migrated here on 2026-06-02: | `workspace-session-coordinator.ts`| boot, spec/session selection | | `session-binding.ts` | session↔spec binding | | `brunch-session-envelope.ts` | session envelope reader | -| `session-projection-reader.ts` | JSONL projection | +| `session-projection-reader.ts` | JSONL projection target resolution | | `session-transcript.ts` | transcript row projection | -| `elicitation-exchange.ts` | exchange extraction | +| `exchange-projection.ts` | exchange extraction | +| `runtime-state.ts` | runtime state projection | | `structured-exchange.ts` | structured exchange schemas/types | +| `structured-exchange-loop.ts` | deterministic exchange loop helpers| | `project-identity.ts` | workspace identity (cwd discovery) | diff --git a/src/session/elicitation-exchange.test.ts b/src/session/exchange-projection.test.ts similarity index 93% rename from src/session/elicitation-exchange.test.ts rename to src/session/exchange-projection.test.ts index ebc53f83..4110acd5 100644 --- a/src/session/elicitation-exchange.test.ts +++ b/src/session/exchange-projection.test.ts @@ -8,12 +8,12 @@ import { describe, expect, it } from 'vitest'; import { assistantMessage, userMessage } from '../probes/test-helpers.js'; import { loadJsonlTranscriptEntries, - loadLinearElicitationExchangeProjection, + loadLinearSessionExchangeProjection, loadLinearTranscriptDisplayProjection, NonLinearTranscriptError, - projectElicitationExchanges, + projectSessionExchanges, projectTranscriptDisplay, -} from './elicitation-exchange.js'; +} from './exchange-projection.js'; import { createSessionBindingData } from './session-binding.js'; import { STRUCTURED_EXCHANGE_RESULT_SCHEMA } from './structured-exchange.js'; @@ -197,9 +197,9 @@ function appendBinding(manager: SessionManager): void { ); } -describe('elicitation exchange projection', () => { +describe('session exchange projection', () => { it('projects assistant prompt spans and user response spans with stable ranges', () => { - const exchanges = projectElicitationExchanges([ + const exchanges = projectSessionExchanges([ { id: 's1', type: 'session' }, assistant, structuredPrompt, @@ -237,7 +237,7 @@ describe('elicitation exchange projection', () => { }); it('includes known standalone elicitor custom entries on the prompt side', () => { - const projection = projectElicitationExchanges([ + const projection = projectSessionExchanges([ assistant, { id: 'offer-1', @@ -252,7 +252,7 @@ describe('elicitation exchange projection', () => { }); it('ignores unknown custom entries even when their type contains prompt', () => { - const projection = projectElicitationExchanges([ + const projection = projectSessionExchanges([ assistant, { id: 'operational-1', @@ -267,7 +267,7 @@ describe('elicitation exchange projection', () => { }); it('includes structured response entries on the response side', () => { - const projection = projectElicitationExchanges([assistant, user, structuredResponse]); + const projection = projectSessionExchanges([assistant, user, structuredResponse]); expect(projection.exchanges[0]?.responseEntryIds).toEqual(['u1', 'r1']); expect(projection.exchanges[0]?.responseRange).toEqual({ @@ -277,7 +277,7 @@ describe('elicitation exchange projection', () => { }); it('includes Pi toolResult messages on the prompt side', () => { - const projection = projectElicitationExchanges([assistant, toolResult, user]); + const projection = projectSessionExchanges([assistant, toolResult, user]); expect(projection.exchanges[0]?.promptEntryIds).toEqual(['a1', 't1']); expect(projection.exchanges[0]?.promptRange).toEqual({ @@ -287,7 +287,7 @@ describe('elicitation exchange projection', () => { }); it('projects an unmatched present tool result as an open prompt', () => { - const projection = projectElicitationExchanges([presentQuestionToolResult]); + const projection = projectSessionExchanges([presentQuestionToolResult]); expect(projection).toEqual({ status: 'open_prompt', @@ -300,7 +300,7 @@ describe('elicitation exchange projection', () => { }); it('closes a present/request structured-exchange tuple only when request details match', () => { - const projection = projectElicitationExchanges([presentQuestionToolResult, requestAnswerToolResult]); + const projection = projectSessionExchanges([presentQuestionToolResult, requestAnswerToolResult]); expect(projection).toEqual({ status: 'ready', @@ -320,7 +320,7 @@ describe('elicitation exchange projection', () => { }); it('does not close an open present with a mismatched request tuple', () => { - const projection = projectElicitationExchanges([ + const projection = projectSessionExchanges([ presentQuestionToolResult, mismatchedRequestAnswerToolResult, ]); @@ -358,7 +358,7 @@ describe('elicitation exchange projection', () => { }, }; - const projection = projectElicitationExchanges([presentOptions, requestChoices]); + const projection = projectSessionExchanges([presentOptions, requestChoices]); expect(projection.exchanges[0]?.responseEntryIds).toEqual([`request-choices-${status}`]); expect(projection.openPrompt).toBeNull(); @@ -394,7 +394,7 @@ describe('elicitation exchange projection', () => { }; for (const request of [wrongPresentToolRequest, unexpectedRequestTool]) { - const projection = projectElicitationExchanges([presentQuestionToolResult, request]); + const projection = projectSessionExchanges([presentQuestionToolResult, request]); expect(projection.exchanges).toEqual([]); expect(projection.openPrompt?.promptEntryIds).toEqual(['present-question-1']); @@ -419,7 +419,7 @@ describe('elicitation exchange projection', () => { }); it('classifies terminal structured-exchange tool results as response-side entries', () => { - const projection = projectElicitationExchanges([assistant, structuredExchangeToolResult]); + const projection = projectSessionExchanges([assistant, structuredExchangeToolResult]); expect(projection.exchanges[0]?.promptEntryIds).toEqual(['a1']); expect(projection.exchanges[0]?.responseEntryIds).toEqual(['sq1']); @@ -431,20 +431,20 @@ describe('elicitation exchange projection', () => { }); it('keeps non-terminal structured-exchange tool results on the prompt side', () => { - const projection = projectElicitationExchanges([assistant, unavailableStructuredExchangeToolResult]); + const projection = projectSessionExchanges([assistant, unavailableStructuredExchangeToolResult]); expect(projection.exchanges).toEqual([]); expect(projection.openPrompt?.promptEntryIds).toEqual(['a1', 'sq-unavailable']); }); it('returns an explicit empty/open shape for incomplete transcripts', () => { - expect(projectElicitationExchanges([])).toEqual({ + expect(projectSessionExchanges([])).toEqual({ status: 'empty', exchanges: [], openPrompt: null, }); - expect(projectElicitationExchanges([assistant])).toEqual({ + expect(projectSessionExchanges([assistant])).toEqual({ status: 'open_prompt', exchanges: [], openPrompt: { @@ -455,7 +455,7 @@ describe('elicitation exchange projection', () => { }); it('ignores orphan user responses before a prompt', () => { - const projection = projectElicitationExchanges([ + const projection = projectSessionExchanges([ user, { id: 'a2', @@ -481,7 +481,7 @@ describe('elicitation exchange projection', () => { manager.appendMessage(assistantMessage('Question')); manager.appendMessage(userMessage('Answer')); - const projection = await loadLinearElicitationExchangeProjection(manager.getSessionFile()!); + const projection = await loadLinearSessionExchangeProjection(manager.getSessionFile()!); expect(projection.status).toBe('ready'); expect(projection.exchanges).toHaveLength(1); @@ -518,7 +518,7 @@ describe('elicitation exchange projection', () => { timestamp: 0, }); - const projection = await loadLinearElicitationExchangeProjection(manager.getSessionFile()!); + const projection = await loadLinearSessionExchangeProjection(manager.getSessionFile()!); expect(projection.status).toBe('ready'); expect(projection.exchanges).toHaveLength(1); @@ -605,7 +605,7 @@ describe('elicitation exchange projection', () => { manager.resetLeaf(); manager.appendMessage(assistantMessage('Active prompt')); - await expect(loadLinearElicitationExchangeProjection(manager.getSessionFile()!)).rejects.toThrow( + await expect(loadLinearSessionExchangeProjection(manager.getSessionFile()!)).rejects.toThrow( NonLinearTranscriptError, ); }); @@ -720,6 +720,6 @@ describe('elicitation exchange projection', () => { const entries = await loadJsonlTranscriptEntries(file); - expect(projectElicitationExchanges(entries).exchanges).toHaveLength(1); + expect(projectSessionExchanges(entries).exchanges).toHaveLength(1); }); }); diff --git a/src/session/elicitation-exchange.ts b/src/session/exchange-projection.ts similarity index 93% rename from src/session/elicitation-exchange.ts rename to src/session/exchange-projection.ts index dd25d1fd..b2dabff0 100644 --- a/src/session/elicitation-exchange.ts +++ b/src/session/exchange-projection.ts @@ -41,7 +41,7 @@ export interface EntryRange { end: string; } -export interface ElicitationExchange { +export interface SessionExchange { promptRange: EntryRange; responseRange: EntryRange; promptEntryIds: string[]; @@ -53,9 +53,9 @@ export interface OpenPromptProjection { promptEntryIds: string[]; } -export interface ElicitationExchangeProjection { +export interface SessionExchangeProjection { status: 'empty' | 'open_prompt' | 'ready'; - exchanges: ElicitationExchange[]; + exchanges: SessionExchange[]; openPrompt: OpenPromptProjection | null; } @@ -71,10 +71,8 @@ export interface TranscriptDisplayProjection { export { loadJsonlTranscriptEntries, NonLinearTranscriptError }; -export async function loadLinearElicitationExchangeProjection( - file: string, -): Promise { - return projectLinearElicitationExchangeProjection(await loadBrunchSessionEnvelope(file)); +export async function loadLinearSessionExchangeProjection(file: string): Promise { + return projectLinearSessionExchangeProjection(await loadBrunchSessionEnvelope(file)); } export async function loadLinearTranscriptDisplayProjection( @@ -83,11 +81,11 @@ export async function loadLinearTranscriptDisplayProjection( return projectLinearTranscriptDisplayProjection(await loadBrunchSessionEnvelope(file)); } -export function projectLinearElicitationExchangeProjection( +export function projectLinearSessionExchangeProjection( envelope: BrunchSessionEnvelope, -): ElicitationExchangeProjection { +): SessionExchangeProjection { assertLinearBrunchSessionEnvelope(envelope); - return projectElicitationExchanges(envelope.entries); + return projectSessionExchanges(envelope.entries); } export function projectLinearTranscriptDisplayProjection( @@ -149,8 +147,8 @@ export function projectTranscriptDisplay(entries: readonly unknown[]): Transcrip return { rows }; } -export function projectElicitationExchanges(entries: readonly unknown[]): ElicitationExchangeProjection { - const exchanges: ElicitationExchange[] = []; +export function projectSessionExchanges(entries: readonly unknown[]): SessionExchangeProjection { + const exchanges: SessionExchange[] = []; let promptIds: string[] = []; let responseIds: string[] = []; let openStructuredExchange: StructuredExchangePresentDetails | undefined; diff --git a/src/session/runtime-state.test.ts b/src/session/runtime-state.test.ts new file mode 100644 index 00000000..894384c5 --- /dev/null +++ b/src/session/runtime-state.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { NonLinearTranscriptError, type BrunchSessionEnvelope } from './brunch-session-envelope.js'; +import { + BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + DEFAULT_BRUNCH_AGENT_STATE, + projectSessionRuntimeState, + type BrunchAgentState, +} from './runtime-state.js'; +import { createSessionBindingData } from './session-binding.js'; + +function envelope(entries: BrunchSessionEnvelope['entries'] = []): BrunchSessionEnvelope { + return { + header: { type: 'session', id: 'session-1', cwd: '/tmp/brunch' } as never, + binding: createSessionBindingData({ specId: 1 }), + entries: [ + { type: 'session', id: 'session-1', cwd: '/tmp/brunch' } as never, + { + id: 'binding-1', + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId: 1 }), + } as never, + ...entries, + ], + }; +} + +function runtimeEntry(id: string, state: BrunchAgentState, parentId = 'binding-1') { + return { + id, + type: 'custom', + parentId, + customType: BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, + data: { + schemaVersion: 1, + reason: 'switch', + state, + source: 'user', + }, + } as never; +} + +describe('session runtime-state projection', () => { + it('returns flattened defaults for an explicit linear session with no runtime entries', () => { + expect(projectSessionRuntimeState(envelope())).toEqual({ + status: 'ready', + specId: 1, + sessionId: 'session-1', + agent: { + operationalMode: DEFAULT_BRUNCH_AGENT_STATE.operationalMode, + role: 'elicitor', + strategy: DEFAULT_BRUNCH_AGENT_STATE.agentStrategy, + lens: DEFAULT_BRUNCH_AGENT_STATE.agentLens, + goal: DEFAULT_BRUNCH_AGENT_STATE.agentGoal, + }, + mentions: { graphNodes: [], files: [] }, + world: { graph: { latestLsn: null }, git: { head: null } }, + lifecycle: { + specOrigin: null, + sessionOrigin: null, + sessionIndexInSpec: null, + isFirstSessionForSpec: null, + isTenthSessionForSpec: null, + }, + }); + }); + + it('projects last-writer-wins runtime posture plus mention, world, and lifecycle slots', () => { + const first: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'step-wise-decision-tree', + agentLens: 'intent', + agentGoal: 'grounding-advance', + }; + const latest: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'project-graph', + agentLens: 'oracle', + agentGoal: 'commit-converge', + }; + + expect( + projectSessionRuntimeState( + envelope([ + runtimeEntry('runtime-1', first), + { + id: 'mention-1', + type: 'custom', + parentId: 'runtime-1', + customType: 'brunch.mention', + data: { entityId: 'node-1', handle: 'D12', title: 'Decision seam', snapshottedLsn: 7 }, + } as never, + { + id: 'file-mention-1', + type: 'custom', + parentId: 'mention-1', + customType: 'brunch.file_mention', + data: { path: 'src/session/runtime-state.ts', gitHead: 'abc123' }, + } as never, + { + id: 'world-1', + type: 'custom', + parentId: 'file-mention-1', + customType: 'worldUpdate', + details: { + changedSinceLsn: 12, + items: [{ id: 'node-1' }], + gitHead: 'def456', + rawBag: { hidden: true }, + }, + } as never, + { + id: 'lifecycle-1', + type: 'custom', + parentId: 'world-1', + customType: 'brunch.session_lifecycle', + data: { specOrigin: 'existing', sessionOrigin: 'resumed', sessionIndexInSpec: 10 }, + } as never, + runtimeEntry('runtime-2', latest, 'lifecycle-1'), + ]), + ), + ).toMatchObject({ + agent: { + operationalMode: 'elicit', + role: 'elicitor', + strategy: 'project-graph', + lens: 'oracle', + goal: 'commit-converge', + }, + mentions: { + graphNodes: [{ id: 'node-1', handle: 'D12', title: 'Decision seam', seenLsn: 7 }], + files: [{ path: 'src/session/runtime-state.ts', seenGitHead: 'abc123' }], + }, + world: { + graph: { + latestLsn: 12, + }, + git: { head: 'def456' }, + }, + lifecycle: { + specOrigin: 'existing', + sessionOrigin: 'resumed', + sessionIndexInSpec: 10, + isFirstSessionForSpec: false, + isTenthSessionForSpec: true, + }, + }); + }); + + it('rejects non-linear transcripts instead of flattening runtime state', () => { + expect(() => + projectSessionRuntimeState( + envelope([ + { + id: 'a', + type: 'message', + parentId: 'binding-1', + message: { role: 'assistant', content: [] }, + } as never, + { + id: 'b', + type: 'message', + parentId: 'binding-1', + message: { role: 'assistant', content: [] }, + } as never, + ]), + ), + ).toThrow(NonLinearTranscriptError); + }); +}); diff --git a/src/session/runtime-state.ts b/src/session/runtime-state.ts new file mode 100644 index 00000000..92ca693c --- /dev/null +++ b/src/session/runtime-state.ts @@ -0,0 +1,435 @@ +import type { FileEntry } from '@earendil-works/pi-coding-agent'; + +import { assertLinearBrunchSessionEnvelope, type BrunchSessionEnvelope } from './brunch-session-envelope.js'; + +export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = 'brunch.agent_runtime_state'; + +export type OperationalModeId = 'elicit'; +export type AgentRoleId = 'elicitor'; +export type AutoAxisSelection = 'auto'; +export type AgentStrategyId = + | 'step-wise-decision-tree' + | 'step-wise-disambiguate' + | 'propose-graph' + | 'project-graph'; +export type AgentStrategySelection = AutoAxisSelection | AgentStrategyId; +export type AgentLensId = 'intent' | 'design' | 'oracle'; +export type AgentLensSelection = AutoAxisSelection | AgentLensId; +export type AgentGoalId = 'grounding-advance' | 'elicit-expand' | 'commit-converge' | 'capture-posture'; +export type AgentGoalSelection = AutoAxisSelection | AgentGoalId; +export type ToolPolicyId = 'elicit-read-only'; +export type PromptPackId = 'brunch-base' | 'elicit' | 'elicitor'; +export type ModelPreference = 'default'; +export type ThinkingLevel = 'low' | 'medium' | 'high'; + +export interface BrunchAgentState { + schemaVersion: 1; + operationalMode: OperationalModeId; + agentStrategy: AgentStrategySelection; + agentLens: AgentLensSelection; + agentGoal: AgentGoalSelection; +} + +export interface OperationalModeDefinition { + id: OperationalModeId; + defaultRole: AgentRoleId; + allowedRoles: readonly AgentRoleId[]; + toolPolicyId: ToolPolicyId; + promptPackIds: readonly PromptPackId[]; +} + +export interface AgentRoleDefinition { + id: AgentRoleId; + operationalMode: OperationalModeId; + defaultStrategy: AgentStrategySelection; + allowedStrategies: readonly AgentStrategyId[]; + defaultLens: AgentLensSelection; + allowedLenses: readonly AgentLensId[]; + defaultGoal: AgentGoalSelection; + allowedGoals: readonly AgentGoalId[]; + promptPackIds: readonly PromptPackId[]; + modelPreference?: ModelPreference; + thinkingLevel?: ThinkingLevel; +} + +export interface ResolvedBrunchAgentState extends BrunchAgentState { + agentRole: AgentRoleId; + operationalModeDefinition: OperationalModeDefinition; + agentRoleDefinition: AgentRoleDefinition; +} + +export interface BrunchAgentStateEntryData { + schemaVersion: 1; + reason: 'init' | 'switch'; + state: BrunchAgentState; + previous?: BrunchAgentState; + source: 'system' | 'user' | 'agent' | 'extension'; +} + +export interface RuntimeStateProjection { + status: 'ready'; + specId: number; + sessionId: string; + agent: { + operationalMode: OperationalModeId; + role: AgentRoleId; + strategy: AgentStrategySelection; + lens: AgentLensSelection; + goal: AgentGoalSelection; + }; + mentions: { + graphNodes: GraphNodeMention[]; + files: FileMention[]; + }; + world: { + graph: { + latestLsn: number | null; + }; + git: { + head: string | null; + }; + }; + lifecycle: { + specOrigin: 'new' | 'existing' | null; + sessionOrigin: 'new' | 'resumed' | null; + sessionIndexInSpec: number | null; + isFirstSessionForSpec: boolean | null; + isTenthSessionForSpec: boolean | null; + }; +} + +export interface GraphNodeMention { + id: string; + handle?: string; + title?: string; + seenLsn?: number; +} + +export interface FileMention { + path: string; + seenGitHead?: string; +} + +export const DEFAULT_BRUNCH_AGENT_STATE: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'auto', + agentLens: 'auto', + agentGoal: 'grounding-advance', +}; + +export const OPERATIONAL_MODE_DEFINITIONS: Record = { + elicit: { + id: 'elicit', + defaultRole: 'elicitor', + allowedRoles: ['elicitor'], + toolPolicyId: 'elicit-read-only', + promptPackIds: ['brunch-base', 'elicit'], + }, +}; + +export const AGENT_ROLE_DEFINITIONS: Record = { + elicitor: { + id: 'elicitor', + operationalMode: 'elicit', + defaultStrategy: 'auto', + allowedStrategies: [ + 'step-wise-decision-tree', + 'step-wise-disambiguate', + 'propose-graph', + 'project-graph', + ], + defaultLens: 'auto', + allowedLenses: ['intent', 'design', 'oracle'], + defaultGoal: 'grounding-advance', + allowedGoals: ['grounding-advance', 'elicit-expand', 'commit-converge', 'capture-posture'], + promptPackIds: ['elicitor'], + }, +}; + +interface CustomEntryLike { + type?: unknown; + customType?: unknown; + data?: unknown; + details?: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && allowed.includes(value as T); +} + +function isAxisSelection( + value: unknown, + allowed: readonly T[], +): value is AutoAxisSelection | T { + return value === 'auto' || isOneOf(value, allowed); +} + +function parseBrunchAgentState(value: unknown): BrunchAgentState | undefined { + if (!isRecord(value)) return undefined; + const operationalModes = Object.keys(OPERATIONAL_MODE_DEFINITIONS) as OperationalModeId[]; + + if (value.schemaVersion !== 1) return undefined; + if (!isOneOf(value.operationalMode, operationalModes)) return undefined; + if ('agentRole' in value) return undefined; + + const mode = OPERATIONAL_MODE_DEFINITIONS[value.operationalMode]; + const role = AGENT_ROLE_DEFINITIONS[mode.defaultRole]; + if (!isAxisSelection(value.agentStrategy, role.allowedStrategies)) return undefined; + if (!isAxisSelection(value.agentLens, role.allowedLenses)) return undefined; + if (!isAxisSelection(value.agentGoal, role.allowedGoals)) return undefined; + + return { + schemaVersion: 1, + operationalMode: value.operationalMode, + agentStrategy: value.agentStrategy, + agentLens: value.agentLens, + agentGoal: value.agentGoal, + }; +} + +function parseBrunchAgentStateEntryData(value: unknown): BrunchAgentStateEntryData | undefined { + if (!isRecord(value)) return undefined; + if (value.schemaVersion !== 1) return undefined; + if (value.reason !== 'init' && value.reason !== 'switch') return undefined; + if ( + value.source !== 'system' && + value.source !== 'user' && + value.source !== 'agent' && + value.source !== 'extension' + ) { + return undefined; + } + const state = parseBrunchAgentState(value.state); + if (!state) return undefined; + const previous = value.previous === undefined ? undefined : parseBrunchAgentState(value.previous); + if (value.previous !== undefined && !previous) return undefined; + + return { + schemaVersion: 1, + reason: value.reason, + state, + ...(previous ? { previous } : {}), + source: value.source, + }; +} + +function resolveBrunchAgentState(state: BrunchAgentState): ResolvedBrunchAgentState { + const operationalModeDefinition = OPERATIONAL_MODE_DEFINITIONS[state.operationalMode]; + const agentRole = operationalModeDefinition.defaultRole; + return { + ...state, + agentRole, + operationalModeDefinition, + agentRoleDefinition: AGENT_ROLE_DEFINITIONS[agentRole], + }; +} + +export function latestValidBrunchAgentStateEntryData( + entries: readonly CustomEntryLike[], +): BrunchAgentStateEntryData | undefined { + let latest: BrunchAgentStateEntryData | undefined; + + for (const entry of entries) { + if (entry.type !== 'custom' || entry.customType !== BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE) { + continue; + } + const data = parseBrunchAgentStateEntryData(entry.data); + if (data) latest = data; + } + + return latest; +} + +export function projectBrunchAgentState(entries: readonly CustomEntryLike[]): ResolvedBrunchAgentState { + return resolveBrunchAgentState( + latestValidBrunchAgentStateEntryData(entries)?.state ?? DEFAULT_BRUNCH_AGENT_STATE, + ); +} + +export interface BrunchAgentStateEntrySessionManager { + getEntries(): readonly CustomEntryLike[]; + appendCustomEntry(customType: string, data: BrunchAgentStateEntryData): string; +} + +function requireValidBrunchAgentState(state: BrunchAgentState): BrunchAgentState { + const valid = parseBrunchAgentState(state); + if (!valid) { + throw new Error('Invalid BrunchAgentState runtime selection.'); + } + return valid; +} + +export function appendBrunchAgentRuntimeInit( + sessionManager: BrunchAgentStateEntrySessionManager, + source: BrunchAgentStateEntryData['source'] = 'extension', +): string | undefined { + if (latestValidBrunchAgentStateEntryData(sessionManager.getEntries())) { + return undefined; + } + + return sessionManager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { + schemaVersion: 1, + reason: 'init', + state: DEFAULT_BRUNCH_AGENT_STATE, + source, + }); +} + +export function appendBrunchAgentRuntimeSwitch( + sessionManager: BrunchAgentStateEntrySessionManager, + state: BrunchAgentState, + source: BrunchAgentStateEntryData['source'] = 'user', +): string { + const validState = requireValidBrunchAgentState(state); + const previous = projectBrunchAgentState(sessionManager.getEntries()); + + return sessionManager.appendCustomEntry(BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, { + schemaVersion: 1, + reason: 'switch', + state: validState, + previous: { + schemaVersion: previous.schemaVersion, + operationalMode: previous.operationalMode, + agentStrategy: previous.agentStrategy, + agentLens: previous.agentLens, + agentGoal: previous.agentGoal, + }, + source, + }); +} + +export function projectSessionRuntimeState(envelope: BrunchSessionEnvelope): RuntimeStateProjection { + assertLinearBrunchSessionEnvelope(envelope); + const agentState = projectBrunchAgentState(envelope.entries); + + return { + status: 'ready', + specId: envelope.binding.specId, + sessionId: envelope.header.id, + agent: { + operationalMode: agentState.operationalMode, + role: agentState.agentRole, + strategy: agentState.agentStrategy, + lens: agentState.agentLens, + goal: agentState.agentGoal, + }, + mentions: projectMentions(envelope.entries), + world: projectWorld(envelope.entries), + lifecycle: projectLifecycle(envelope.entries), + }; +} + +function projectMentions(entries: readonly FileEntry[]): RuntimeStateProjection['mentions'] { + const graphNodes: GraphNodeMention[] = []; + const files: FileMention[] = []; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + const customType = entry.customType; + const data = isRecord(entry.data) ? entry.data : undefined; + if (customType === 'brunch.mention' && data) { + const id = stringField(data.entityId) ?? stringField(data.nodeId) ?? stringField(data.id); + if (id) { + const handle = stringField(data.handle); + const title = stringField(data.title); + const seenLsn = integerField(data.snapshottedLsn); + graphNodes.push({ + id, + ...(handle === undefined ? {} : { handle }), + ...(title === undefined ? {} : { title }), + ...(seenLsn === undefined ? {} : { seenLsn }), + }); + } + } + if (customType === 'brunch.file_mention' && data) { + const path = stringField(data.path); + if (path) { + const seenGitHead = stringField(data.gitHead); + files.push({ + path, + ...(seenGitHead === undefined ? {} : { seenGitHead }), + }); + } + } + } + + return { graphNodes, files }; +} + +function projectWorld(entries: readonly FileEntry[]): RuntimeStateProjection['world'] { + let latestGraph: RuntimeStateProjection['world']['graph'] = { + latestLsn: null, + }; + let gitHead: string | null = null; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + if (entry.customType !== 'worldUpdate') continue; + const details = isRecord(entry.details) ? entry.details : isRecord(entry.data) ? entry.data : undefined; + if (!details) continue; + + const lsn = integerField(details.currentLsn) ?? integerField(details.changedSinceLsn) ?? null; + latestGraph = { + latestLsn: lsn, + }; + gitHead = stringField(details.gitHead) ?? gitHead; + } + + return { + graph: latestGraph, + git: { head: gitHead }, + }; +} + +function projectLifecycle(entries: readonly FileEntry[]): RuntimeStateProjection['lifecycle'] { + let lifecycle: RuntimeStateProjection['lifecycle'] = { + specOrigin: null, + sessionOrigin: null, + sessionIndexInSpec: null, + isFirstSessionForSpec: null, + isTenthSessionForSpec: null, + }; + + for (const entry of entries) { + if (!isRecord(entry) || entry.type !== 'custom') continue; + if (entry.customType !== 'brunch.session_lifecycle') continue; + const data = isRecord(entry.data) ? entry.data : undefined; + if (!data) continue; + const index = integerField(data.sessionIndexInSpec) ?? lifecycle.sessionIndexInSpec; + const specOrigin = originField(data.specOrigin, ['new', 'existing'] as const) ?? lifecycle.specOrigin; + const sessionOrigin = + originField(data.sessionOrigin, ['new', 'resumed'] as const) ?? lifecycle.sessionOrigin; + lifecycle = { + specOrigin, + sessionOrigin, + sessionIndexInSpec: index, + isFirstSessionForSpec: + booleanField(data.isFirstSessionForSpec) ?? (index === null ? null : index === 1), + isTenthSessionForSpec: + booleanField(data.isTenthSessionForSpec) ?? (index === null ? null : index === 10), + }; + } + + return lifecycle; +} + +function stringField(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function integerField(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) ? value : undefined; +} + +function booleanField(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function originField(value: unknown, allowed: readonly T[]): T | undefined { + return isOneOf(value, allowed) ? value : undefined; +} diff --git a/src/session/structured-exchange-loop.test.ts b/src/session/structured-exchange-loop.test.ts new file mode 100644 index 00000000..bf3927d3 --- /dev/null +++ b/src/session/structured-exchange-loop.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import type { BrunchSessionEnvelope } from './brunch-session-envelope.js'; +import { createSessionBindingData } from './session-binding.js'; +import { + acceptedResponseFromParams, + nextDeterministicStructuredExchange, + pendingExchangeFromEnvelope, +} from './structured-exchange-loop.js'; + +const header = { type: 'session', id: 'session-1', cwd: '/tmp/brunch-project', timestamp: 0 } as const; +const binding = createSessionBindingData({ specId: 1 }); +const bindingEntry = { + id: 'binding-1', + type: 'custom', + parentId: 'session-1', + timestamp: 0, + customType: 'brunch.session_binding', + data: binding, +} as const; + +describe('structured exchange loop helpers', () => { + it('materializes accepted text responses as request_answer tool results', () => { + const pending = nextDeterministicStructuredExchange(1); + + const accepted = acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { text: 'A local product specification workspace.' }, + }); + + expect(accepted).toMatchObject({ + ok: true, + answer: { text: 'A local product specification workspace.' }, + toolResultMessage: { + role: 'toolResult', + toolName: 'request_answer', + content: [{ text: '### Response\n\nA local product specification workspace.' }], + details: { + schema: 'brunch.structured_exchange.request', + exchangeId: pending.exchangeId, + requestTool: 'request_answer', + status: 'answered', + answer: 'A local product specification workspace.', + }, + }, + }); + }); + + it('materializes accepted single-select responses as request_choice tool results', () => { + const pending = nextDeterministicStructuredExchange(0); + + const accepted = acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { optionId: 'new-from-scratch' }, + note: 'This is greenfield.', + }); + + expect(accepted).toMatchObject({ + ok: true, + answer: { optionId: 'new-from-scratch', label: 'Yes — this is new from scratch' }, + toolResultMessage: { + toolName: 'request_choice', + content: [{ text: expect.stringContaining('> This is greenfield.') }], + details: { + requestTool: 'request_choice', + comment: 'This is greenfield.', + choice: { id: 'new-from-scratch' }, + }, + }, + }); + }); + + it('materializes accepted multi-select responses and requires comments for Other or None', () => { + const pending = nextDeterministicStructuredExchange(2); + + expect( + acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { optionIds: ['transcript', 'other'] }, + }), + ).toEqual({ + ok: false, + message: 'Elicitation response requires a comment for Other or None selections', + }); + + const accepted = acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { optionIds: ['transcript', 'other'] }, + note: 'Also verify friction reporting.', + }); + + expect(accepted).toMatchObject({ + ok: true, + answer: { optionIds: ['transcript', 'other'] }, + toolResultMessage: { + toolName: 'request_choices', + content: [{ text: expect.stringContaining('> Also verify friction reporting.') }], + details: { + requestTool: 'request_choices', + comment: 'Also verify friction reporting.', + choices: [{ id: 'transcript' }, { id: 'other' }], + }, + }, + }); + }); + + it('rejects response mode and option mismatches without materializing a tool result', () => { + const pending = nextDeterministicStructuredExchange(0); + + expect( + acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { text: 'Wrong shape.' }, + }), + ).toEqual({ + ok: false, + message: 'Elicitation response mode does not match pending exchange', + }); + expect( + acceptedResponseFromParams(pending, { + exchangeId: pending.exchangeId, + answer: { optionId: 'missing-option' }, + }), + ).toEqual({ ok: false, message: 'Invalid elicitation option' }); + }); + + it('reconstructs pending options from structured present markdown when details omit options', () => { + const envelope: BrunchSessionEnvelope = { + header: header as unknown as BrunchSessionEnvelope['header'], + binding, + entries: [ + header, + bindingEntry, + { + id: 'present-options-1', + type: 'message', + parentId: 'binding-1', + timestamp: 0, + message: { + role: 'toolResult', + toolCallId: 'present-call-1', + toolName: 'present_options', + content: [ + { + type: 'text', + text: [ + '## Choose proof quality', + '', + '### 1. Transcript fidelity', + '', + '**Rationale:** Pi JSONL keeps truth recoverable.', + '', + '', + ].join('\n'), + }, + ], + details: { + schema: 'brunch.structured_exchange.present', + schemaVersion: 1, + exchangeId: 'quality', + presentTool: 'present_options', + kind: 'options', + status: 'presented', + expectedRequest: { tool: 'request_choice', required: true }, + createdAtToolCallId: 'present-call-1', + }, + isError: false, + }, + }, + ] as unknown as BrunchSessionEnvelope['entries'], + }; + + expect(pendingExchangeFromEnvelope(envelope)).toMatchObject({ + exchangeId: 'quality', + mode: 'single-select', + prompt: 'Choose proof quality', + options: [ + { + id: 'transcript', + label: 'Transcript fidelity', + content: 'Transcript fidelity', + rationale: 'Pi JSONL keeps truth recoverable.', + }, + ], + }); + }); +}); diff --git a/src/session/structured-exchange-loop.ts b/src/session/structured-exchange-loop.ts new file mode 100644 index 00000000..5173727f --- /dev/null +++ b/src/session/structured-exchange-loop.ts @@ -0,0 +1,512 @@ +import { Type, type Static } from 'typebox'; +import { Value } from 'typebox/value'; + +import type { StructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/model.js'; +import { isStructuredExchangePresentDetails } from '../.pi/extensions/structured-exchange/shared/recovery.js'; +import type { BrunchSessionEnvelope } from './brunch-session-envelope.js'; +import { projectLinearSessionExchangeProjection } from './exchange-projection.js'; + +const NonBlankStringSchema = Type.String({ minLength: 1 }); + +export const PendingStructuredExchangeSchema = Type.Object( + { + exchangeId: NonBlankStringSchema, + lens: Type.Literal('intent'), + mode: Type.Union([Type.Literal('text'), Type.Literal('single-select'), Type.Literal('multi-select')]), + prompt: NonBlankStringSchema, + details: Type.Optional(NonBlankStringSchema), + options: Type.Array( + Type.Object( + { + id: NonBlankStringSchema, + label: NonBlankStringSchema, + content: NonBlankStringSchema, + rationale: Type.Optional(NonBlankStringSchema), + }, + { additionalProperties: false }, + ), + ), + note: Type.Object( + { allowed: Type.Boolean() }, + { + additionalProperties: false, + }, + ), + }, + { additionalProperties: false }, +); + +export interface StructuredExchangeTextResponseInput { + exchangeId: string; + answer: { text: string }; + note?: string | undefined; +} + +export interface StructuredExchangeSingleChoiceResponseInput { + exchangeId: string; + answer: { optionId: string }; + note?: string | undefined; +} + +export interface StructuredExchangeMultiChoiceResponseInput { + exchangeId: string; + answer: { optionIds: string[] }; + note?: string | undefined; +} + +export type StructuredExchangeResponseInput = + | StructuredExchangeTextResponseInput + | StructuredExchangeSingleChoiceResponseInput + | StructuredExchangeMultiChoiceResponseInput; + +export interface AcceptedToolTextContent { + type: 'text'; + text: string; +} + +export interface AcceptedToolResultMessage { + role: 'toolResult'; + toolCallId: string; + toolName: string; + content: AcceptedToolTextContent[]; + details: Record; + isError: false; + timestamp: 0; +} + +export type PendingStructuredExchange = Static; + +export type AcceptedStructuredExchangeResponse = + | { + ok: true; + answer: Record; + toolResultMessage: AcceptedToolResultMessage; + } + | { + ok: false; + message: string; + }; + +interface PendingChoice { + id: string; + label: string; + content: string; + rationale?: string; +} + +export function nextDeterministicStructuredExchange(completedCount: number): PendingStructuredExchange { + const turnNumber = completedCount + 1; + const script: PendingStructuredExchange[] = [ + { + exchangeId: `deterministic-grounding-choice-${turnNumber}`, + lens: 'intent', + mode: 'single-select', + prompt: 'Is this a new product or feature from scratch?', + details: 'Choose the best starting context so later elicitation can ask useful follow-ups.', + options: [ + { + id: 'new-from-scratch', + label: 'Yes — this is new from scratch', + content: 'Start a new spec workspace from a blank slate.', + rationale: 'This keeps the parity run focused on initial grounding.', + }, + { + id: 'existing-codebase', + label: 'No — this builds on existing code', + content: 'Ground the spec in existing implementation constraints.', + rationale: 'Existing code changes what the elicitor should inspect next.', + }, + { + id: 'relates-to-existing-spec', + label: 'It relates to an existing spec', + content: 'Connect this work to a prior specification thread.', + rationale: 'Continuity matters when prior graph intent exists.', + }, + ], + note: { allowed: true }, + }, + { + exchangeId: `deterministic-grounding-text-${turnNumber}`, + lens: 'intent', + mode: 'text', + prompt: 'What are we specifying?', + details: + "This covers the text-answer permutation in Brunch's deterministic public-RPC structured-exchange parity proof.", + options: [], + note: { allowed: true }, + }, + { + exchangeId: `deterministic-grounding-multi-${turnNumber}`, + lens: 'intent', + mode: 'multi-select', + prompt: 'Which proof qualities matter for this parity run?', + details: + 'Select all qualities the deterministic structured-exchange permutation proof should preserve.', + options: [ + { + id: 'transcript', + label: 'Transcript fidelity', + content: 'Pi JSONL keeps every present/request tuple recoverable.', + rationale: 'The transcript is the durable source of truth.', + }, + { + id: 'projection', + label: 'Projection fidelity', + content: 'Brunch projections preserve semantic option artifacts.', + rationale: 'Public clients depend on projected structured exchange data.', + }, + { + id: 'other', + label: 'Other', + content: 'Another proof quality should be captured in the note.', + rationale: 'Other requires a comment so the transcript stays explicit.', + }, + { + id: 'none', + label: 'None', + content: 'No additional proof qualities matter for this run.', + rationale: 'None requires a comment to avoid silent dismissal.', + }, + ], + note: { allowed: true }, + }, + ]; + return script[completedCount % script.length]!; +} + +export function presentToolResultMessage(exchange: PendingStructuredExchange) { + const presentTool = exchange.mode === 'text' ? 'present_question' : 'present_options'; + const requestTool = + exchange.mode === 'text' + ? 'request_answer' + : exchange.mode === 'multi-select' + ? 'request_choices' + : 'request_choice'; + const toolCallId = `${exchange.exchangeId}:${presentTool}`; + return { + role: 'toolResult' as const, + toolCallId, + toolName: presentTool, + content: [{ type: 'text' as const, text: presentMarkdown(exchange) }], + details: { + schema: 'brunch.structured_exchange.present', + schemaVersion: 1, + exchangeId: exchange.exchangeId, + presentTool, + kind: exchange.mode === 'text' ? 'question' : 'options', + status: 'presented', + expectedRequest: { tool: requestTool, required: true }, + createdAtToolCallId: toolCallId, + prompt: exchange.prompt, + details: exchange.details, + lens: exchange.lens, + options: exchange.options, + }, + isError: false as const, + timestamp: 0 as const, + }; +} + +export function pendingExchangeFromEnvelope( + envelope: BrunchSessionEnvelope, +): PendingStructuredExchange | null { + const projection = projectLinearSessionExchangeProjection(envelope); + if (!projection.openPrompt) { + return null; + } + + for (const entryId of projection.openPrompt.promptEntryIds) { + const entry = envelope.entries.find( + (candidate) => + candidate.type === 'custom_message' && + candidate.id === entryId && + candidate.customType === 'brunch.elicitation_prompt' && + Value.Check(PendingStructuredExchangeSchema, candidate.details), + ); + if (entry?.type === 'custom_message') { + return Value.Parse(PendingStructuredExchangeSchema, entry.details); + } + } + + for (const entryId of projection.openPrompt.promptEntryIds) { + const entry = envelope.entries.find( + (candidate) => candidate.type === 'message' && candidate.id === entryId, + ); + const details = structuredExchangePresentDetails(entry); + if (!details) continue; + const text = textContent((entry as { message: { content?: unknown } }).message.content); + return pendingExchangeFromStructuredPresent(details, text); + } + + return null; +} + +export function projectPendingStructuredExchange( + envelope: BrunchSessionEnvelope, +): { status: 'pending'; exchange: PendingStructuredExchange } | { status: 'idle'; exchange: null } { + const exchange = pendingExchangeFromEnvelope(envelope); + if (!exchange) { + return { status: 'idle', exchange: null }; + } + return { status: 'pending', exchange }; +} + +export function acceptedResponseFromParams( + pending: PendingStructuredExchange, + params: StructuredExchangeResponseInput, +): AcceptedStructuredExchangeResponse { + if ('text' in params.answer) { + if (pending.mode !== 'text') return invalidResponseMode(); + const details = requestDetailsBase(pending, 'request_answer'); + return { + ok: true, + answer: { text: params.answer.text }, + toolResultMessage: { + ...toolResultMessageBase(pending, 'request_answer'), + content: [{ type: 'text', text: `### Response\n\n${params.answer.text}` }], + details: { ...details, answer: params.answer.text }, + }, + }; + } + + if ('optionId' in params.answer) { + if (pending.mode !== 'single-select') return invalidResponseMode(); + const optionId = params.answer.optionId; + const choice = pending.options.find((option) => option.id === optionId); + if (!choice) return { ok: false, message: 'Invalid elicitation option' }; + const details = requestDetailsBase(pending, 'request_choice'); + if (params.note !== undefined && params.note.trim().length > 0) { + details.comment = params.note.trim(); + } + return { + ok: true, + answer: { optionId: choice.id, label: choice.label }, + toolResultMessage: { + ...toolResultMessageBase(pending, 'request_choice'), + content: [{ type: 'text', text: choiceResponseMarkdown([choice], params.note) }], + details: { ...details, choice }, + }, + }; + } + + if (pending.mode !== 'multi-select') return invalidResponseMode(); + const selected = params.answer.optionIds.map((id) => pending.options.find((option) => option.id === id)); + if (selected.some((choice) => choice === undefined)) { + return { ok: false, message: 'Invalid elicitation option' }; + } + const choices = selected as PendingChoice[]; + if ( + choices.some((choice) => choice.id === 'other' || choice.id === 'none') && + (params.note === undefined || params.note.trim().length === 0) + ) { + return { + ok: false, + message: 'Elicitation response requires a comment for Other or None selections', + }; + } + const details = requestDetailsBase(pending, 'request_choices'); + if (params.note !== undefined && params.note.trim().length > 0) { + details.comment = params.note.trim(); + } + return { + ok: true, + answer: { optionIds: choices.map((choice) => choice.id), choices }, + toolResultMessage: { + ...toolResultMessageBase(pending, 'request_choices'), + content: [{ type: 'text', text: choiceResponseMarkdown(choices, params.note) }], + details: { ...details, choices }, + }, + }; +} + +function invalidResponseMode(): AcceptedStructuredExchangeResponse { + return { + ok: false, + message: 'Elicitation response mode does not match pending exchange', + }; +} + +function requestDetailsBase( + pending: PendingStructuredExchange, + requestTool: 'request_answer' | 'request_choice' | 'request_choices', +): Record { + return { + schema: 'brunch.structured_exchange.request', + schemaVersion: 1, + exchangeId: pending.exchangeId, + requestTool, + status: 'answered', + respondsTo: { + exchangeId: pending.exchangeId, + presentTool: pending.mode === 'text' ? 'present_question' : 'present_options', + }, + createdAtToolCallId: `${pending.exchangeId}:${requestTool}`, + }; +} + +function toolResultMessageBase( + pending: PendingStructuredExchange, + requestTool: 'request_answer' | 'request_choice' | 'request_choices', +) { + return { + role: 'toolResult' as const, + toolCallId: `${pending.exchangeId}:${requestTool}`, + toolName: requestTool, + isError: false as const, + timestamp: 0 as const, + }; +} + +function choiceResponseMarkdown(choices: Array<{ label: string }>, comment: string | undefined): string { + const lines = ['### Response', '', ...choices.map((choice) => `- ${choice.label}`)]; + if (comment !== undefined && comment.trim().length > 0) { + lines.push('', 'Comment:', '', `> ${comment.trim()}`); + } + return lines.join('\n'); +} + +function presentMarkdown(exchange: PendingStructuredExchange): string { + if (exchange.mode === 'text') { + return [`## ${exchange.prompt}`, exchange.details].filter(Boolean).join('\n\n'); + } + const lines = [`## ${exchange.prompt}`]; + if (exchange.details) lines.push('', exchange.details); + exchange.options.forEach((option, index) => { + lines.push('', `### ${index + 1}. ${option.content}`); + if (option.rationale) { + lines.push('', `**Rationale:** ${option.rationale}`); + } + lines.push('', ``); + }); + return lines.join('\n'); +} + +function pendingExchangeFromStructuredPresent( + details: StructuredExchangePresentDetails, + markdown: string, +): PendingStructuredExchange { + const richDetails = details as StructuredExchangePresentDetails & { + prompt?: unknown; + details?: unknown; + options?: unknown; + }; + const prompt = + typeof richDetails.prompt === 'string' + ? richDetails.prompt + : (firstNonEmptyMarkdownLine(markdown) ?? markdown); + const detailsText = typeof richDetails.details === 'string' ? richDetails.details : markdown; + return { + exchangeId: details.exchangeId, + lens: 'intent', + mode: + details.expectedRequest?.tool === 'request_choices' + ? 'multi-select' + : details.presentTool === 'present_question' + ? 'text' + : 'single-select', + prompt, + ...(detailsText.length > 0 ? { details: detailsText } : {}), + options: parsePendingOptions(richDetails.options, markdown), + note: { allowed: true }, + }; +} + +function parsePendingOptions(value: unknown, markdown: string = ''): PendingChoice[] { + if (!Array.isArray(value)) return parseMarkdownPendingOptions(markdown); + const options = value.flatMap((option) => { + if (typeof option !== 'object' || option === null) return []; + const id = (option as { id?: unknown }).id; + const label = (option as { label?: unknown }).label; + const content = (option as { content?: unknown }).content; + const rationale = (option as { rationale?: unknown }).rationale; + if (typeof id !== 'string') return []; + const optionContent = + typeof content === 'string' ? content : typeof label === 'string' ? label : undefined; + if (optionContent === undefined) return []; + return [ + { + id, + label: typeof label === 'string' ? label : optionContent, + content: optionContent, + ...(typeof rationale === 'string' ? { rationale } : {}), + }, + ]; + }); + return options.length > 0 ? options : parseMarkdownPendingOptions(markdown); +} + +function parseMarkdownPendingOptions(markdown: string): PendingChoice[] { + const options: PendingChoice[] = []; + let pending: + | { + content: string; + rationale?: string; + } + | undefined; + + for (const line of markdown.split('\n')) { + const heading = /^###\s+\d+\.\s+(.+)$/.exec(line.trim()); + if (heading) { + pending = { content: heading[1]!.trim() }; + continue; + } + + const rationale = /^\*\*Rationale:\*\*\s+(.+)$/.exec(line.trim()); + if (rationale && pending) { + pending.rationale = rationale[1]!.trim(); + continue; + } + + const optionId = //.exec(line.trim()); + if (optionId && pending) { + const content = pending.content; + options.push({ + id: optionId[1]!.trim(), + label: content, + content, + ...(pending.rationale === undefined ? {} : { rationale: pending.rationale }), + }); + pending = undefined; + } + } + + return options; +} + +function structuredExchangePresentDetails(entry: unknown): StructuredExchangePresentDetails | undefined { + if (typeof entry !== 'object' || entry === null || (entry as { type?: unknown }).type !== 'message') { + return undefined; + } + const message = (entry as { message?: unknown }).message; + if ( + typeof message !== 'object' || + message === null || + (message as { role?: unknown }).role !== 'toolResult' + ) { + return undefined; + } + const details = (message as { details?: unknown }).details; + return isStructuredExchangePresentDetails(details) + ? (details as StructuredExchangePresentDetails) + : undefined; +} + +function firstNonEmptyMarkdownLine(markdown: string): string | undefined { + return markdown + .split('\n') + .map((line) => line.replace(/^#+\s*/, '').trim()) + .find((line) => line.length > 0); +} + +function textContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((part) => + typeof part === 'object' && part !== null && typeof (part as { text?: unknown }).text === 'string' + ? (part as { text: string }).text + : '', + ) + .filter((text) => text.length > 0) + .join('\n'); +} diff --git a/src/session/workspace-session-coordinator.test.ts b/src/session/workspace-session-coordinator.test.ts index 0b0cef7b..86df805e 100644 --- a/src/session/workspace-session-coordinator.test.ts +++ b/src/session/workspace-session-coordinator.test.ts @@ -6,7 +6,7 @@ import { SessionManager, type SessionEntry } from '@earendil-works/pi-coding-age import { describe, expect, it } from 'vitest'; import { assistantMessage, userMessage, isCustomEntry } from '../probes/test-helpers.js'; -import { projectElicitationExchanges } from './elicitation-exchange.js'; +import { projectSessionExchanges } from './exchange-projection.js'; import { SESSION_BINDING_TYPE } from './session-binding.js'; import { createWorkspaceSessionCoordinator, @@ -202,8 +202,8 @@ describe('WorkspaceSessionCoordinator', () => { result.session.manager.appendMessage(assistantMessage('Question')); result.session.manager.appendMessage(userMessage('Answer')); - const beforeReload = projectElicitationExchanges(result.session.manager.getBranch()); - const afterReload = projectElicitationExchanges( + const beforeReload = projectSessionExchanges(result.session.manager.getBranch()); + const afterReload = projectSessionExchanges( SessionManager.open(result.session.file, undefined, cwd).getBranch(), ); diff --git a/src/web/README.md b/src/web/README.md index 9fc5d102..6fd120a1 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -23,18 +23,43 @@ web/ close() app.tsx - creates QueryClient + TanStack Router runtime - root route loader ensureQueryData(workspace.snapshot) - current proof UI: + app/runtime/router assembly: + createBrunchWebRuntime + createBrunchWebRouter + BrunchWebApp shell + + query-client.ts + per-runtime QueryClient defaults + + query-keys.ts + method-shaped product query keys: workspace.snapshot - session.transcriptDisplay # proof-era method; rename debt per rpc/README - brunch.updated notification -> invalidate relevant queries + session.runtimeState + graph.overview + graph.nodeNeighborhood + + queries/ + workspace.ts -> workspace.snapshot query options + session.ts -> session.runtimeState query options + graph.ts -> graph overview/neighborhood query options + + subscriptions/ + brunch-updates.ts + brunch.updated -> exact Query invalidation where possible + + routes/ + root.tsx + root subscription + `/` workspace/session proof route + spec.tsx + `/spec/$specId` loader primes workspace.snapshot + graph.overview + + features/graph/GraphOverview.tsx + read-only selected-spec graph projection *.test.tsx / *.test.ts - component and transport oracles for current web proof -``` + component, route/cache, and transport oracles for current web proof -Current `app.tsx` intentionally keeps query options in-file because the surface is still tiny. Split to the topology below as soon as a second route, mutation, or graph projection lands. +``` ## Host / asset boundary @@ -123,7 +148,7 @@ web/ generic WebSocket JSON-RPC transport query-client.ts - QueryClient factory/defaults once defaults matter outside tests + QueryClient factory/defaults per runtime query-keys.ts one stable key factory object for all product resources @@ -134,10 +159,9 @@ web/ workspaceSelectionStateQueryOptions(rpc) session.ts - pendingExchangeQueryOptions(rpc, specId, sessionId) - sessionExchangesQueryOptions(rpc, specId, sessionId) - # proof-era compatibility may live here temporarily: - transcriptDisplayQueryOptions(rpc, specId, sessionId) + sessionRuntimeStateQueryOptions(rpc, target) + pendingExchangeQueryOptions(rpc, target) # target, when exchange UI lands + sessionExchangesQueryOptions(rpc, target) # target, when exchange history lands graph.ts graphOverviewQueryOptions(rpc, specId) @@ -153,13 +177,13 @@ web/ activateWorkspaceMutationOptions(rpc) session.ts - promptExchangeMutationOptions(rpc) + triggerExchangeMutationOptions(rpc) submitExchangeResponseMutationOptions(rpc) submitMessageMutationOptions(rpc) subscriptions/ brunch-updates.ts - useBrunchUpdateInvalidation(rpc, queryClient) + useBrunchUpdateSubscription(queryClient, rpc) maps notification topics/LSNs -> exact Query keys routes/ @@ -203,14 +227,14 @@ queryKeys = { }, session: { + runtimeState: (specId, sessionId) => + ['session.runtimeState', specId, sessionId], + pendingExchange: (specId, sessionId) => - ['session.pendingExchange', specId, sessionId], + ['session.pendingExchange', specId, sessionId], # target exchanges: (specId, sessionId) => - ['session.exchanges', specId, sessionId], - - transcriptDisplay: (specId, sessionId) => - ['session.transcriptDisplay', specId, sessionId], # proof-era only + ['session.exchanges', specId, sessionId], # target }, graph: { @@ -237,67 +261,71 @@ Avoid: ## RPC methods to web hooks -Method names follow `src/rpc/README.md`. Existing proof-era methods may remain until renamed, but new web work should use the stable vocabulary below. +Method names follow `src/rpc/README.md`. The TUI-started web sidecar is read-only today: current web code should use query options only. Mutation hook names below describe the expected TanStack Query shape for a future write-capable web/client surface; the current sidecar rejects those RPC methods. ```pseudo -rpc.discover - useRpcDiscoveryQuery(rpc) - Purpose: optional capability/schema introspection for debug panels and adaptive clients. - -workspace.snapshot - workspaceSnapshotQueryOptions(rpc) - Purpose: cwd product state, project/posture, current/default spec/session, chrome state. - Route loader: root route. - -workspace.selectionState - workspaceSelectionStateQueryOptions(rpc) - Purpose: boot/picker inventory and whether explicit activation is required. - Route: workspace/spec-session picker. - -workspace.activate - activateWorkspaceMutationOptions(rpc) - Purpose: apply explicit workspace -> spec -> session decision. - On success: invalidate workspace.snapshot, workspace.selectionState, session/graph keys for selected resources. - -session.promptExchange - promptExchangeMutationOptions(rpc) - Purpose: start/resume/advance assistant-first loop until pending exchange, idle, needs_human, or blocker. - On success: invalidate session.pendingExchange and session.exchanges. - -session.pendingExchange - pendingExchangeQueryOptions(rpc, specId, sessionId) - Purpose: current unresolved structured exchange. - Route: session/propose-graph panel. - -session.submitExchangeResponse - submitExchangeResponseMutationOptions(rpc) - Purpose: submit terminal response for one pending structured exchange. - On success: invalidate session.pendingExchange, session.exchanges, graph.overview, graph.coherenceSummary as applicable. - -session.submitMessage - submitMessageMutationOptions(rpc) - Purpose: ordinary non-exchange user text or explicit interruption. - Must not silently answer a pending exchange. - -session.exchanges - sessionExchangesQueryOptions(rpc, specId, sessionId) - Purpose: transcript-derived structured exchange history. - -future graph.overview - graphOverviewQueryOptions(rpc, specId) - Purpose: committed graph projection, node/edge counts, LSN. - -future graph.nodeNeighborhood - graphNodeNeighborhoodQueryOptions(rpc, specId, nodeId, hops) - Purpose: focused graph context around selected/mentioned node. - -future graph.recentChanges / graph.changesSince - graphRecentChangesQueryOptions(rpc, specId, sinceLsn) - Purpose: worldUpdate panels and cache patching. - -future graph.coherenceSummary - graphCoherenceSummaryQueryOptions(rpc, specId) - Purpose: coherence banner/badges after durable semantics are defined. +current implemented hooks: + workspace.snapshot + workspaceSnapshotQueryOptions(rpc) + query key: ['workspace.snapshot'] + route loader: root and spec routes + + session.runtimeState + sessionRuntimeStateQueryOptions(rpc, target) + query key: ['session.runtimeState', specId, sessionId] + route status: query option exists; panel not yet rendered + + graph.overview + graphOverviewQueryOptions(rpc, specId) + query key: ['graph.overview', specId] + route loader: spec route + + graph.nodeNeighborhood + graphNodeNeighborhoodQueryOptions(rpc, specId, nodeId, hops) + query key: ['graph.nodeNeighborhood', specId, nodeId, hops] + route status: query option exists; selection UI not yet wired + +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. + + session.exchanges + sessionExchangesQueryOptions(rpc, target) + Purpose: transcript-derived structured exchange history. + +planned mutation hooks (not sidecar-accepted today): + workspace.activate + activateWorkspaceMutationOptions(rpc) + On success: invalidate workspace.snapshot, workspace.selectionState, session/graph keys for selected resources. + + session.triggerExchange + triggerExchangeMutationOptions(rpc) + On success: invalidate session.pendingExchange, session.exchanges, and session.runtimeState. + + session.submitExchangeResponse + submitExchangeResponseMutationOptions(rpc) + On success: invalidate session.pendingExchange, session.exchanges, session.runtimeState; graph projections update only after agent-internal graph commit publishes graph topics. + +reserved future method: + session.submitMessage + submitMessageMutationOptions(rpc) + Must not silently answer a pending exchange. + +future graph projections: + graph.recentChanges / graph.changesSince + graphRecentChangesQueryOptions(rpc, specId, sinceLsn) + + graph.coherenceSummary + graphCoherenceSummaryQueryOptions(rpc, specId) ``` ## Subscription / notification bridge diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index 1b6d38e7..bc5610d7 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -1,16 +1,11 @@ // @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WorkspaceSnapshot } from '../print-snapshot.js'; -import type { TranscriptDisplayProjection } from '../session/elicitation-exchange.js'; import { BrunchWebApp, createBrunchWebRuntime } from './app.js'; -import type { - WebSocketRpcClient, - WebSocketRpcNotification, - WebSocketRpcNotificationListener, -} from './rpc-client.js'; +import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from './rpc-client.js'; interface RpcCall { method: string; @@ -37,24 +32,71 @@ const selectSpecSnapshot: WorkspaceSnapshot = { chatMode: 'select-spec', }, }; +const selectedSpecWithoutSessionSnapshot: WorkspaceSnapshot = { + status: 'select_spec', + cwd: '/tmp/brunch-project', + spec: { id: 2, title: 'Spec without session' }, + chrome: { + phase: 'select_spec', + chatMode: 'select-spec', + }, +}; -const readyProjection: TranscriptDisplayProjection = { - rows: [ - { id: 'prompt-1', role: 'prompt', text: 'Choose the better framing.' }, - { id: 'assistant-1', role: 'assistant', text: 'What should we build?' }, - { id: 'user-1', role: 'user', text: 'A read-only dashboard.' }, - ], +const emptyGraphOverview = { + nodes: [], + edges: [], + nodeCount: 0, + edgeCount: 0, + lsn: 0, }; +const populatedGraphOverview = { + nodes: [ + { + id: 10, + specId: 1, + plane: 'intent', + kind: 'requirement', + title: 'Spec A requirement', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + { + id: 11, + specId: 1, + plane: 'intent', + kind: 'assumption', + title: 'Spec A assumption', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + edges: [ + { + id: 20, + specId: 1, + category: 'support', + sourceId: 11, + targetId: 10, + stance: 'supports', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + nodeCount: 2, + edgeCount: 1, + lsn: 1, +}; function rpcClient(options?: { snapshot?: WorkspaceSnapshot; - projection?: TranscriptDisplayProjection | (() => TranscriptDisplayProjection); - projectionError?: Error; + graphOverview?: typeof emptyGraphOverview | typeof populatedGraphOverview; calls?: RpcCall[]; listeners?: Set; }): WebSocketRpcClient { const snapshot = options?.snapshot ?? readySnapshot; - const projection = options?.projection ?? readyProjection; const calls = options?.calls; const listeners = options?.listeners ?? new Set(); return { @@ -63,11 +105,11 @@ function rpcClient(options?: { if (method === 'workspace.snapshot') { return snapshot as T; } - if (method === 'session.transcriptDisplay') { - if (options?.projectionError) { - throw options.projectionError; - } - return (typeof projection === 'function' ? projection() : projection) as T; + if (method === 'session.runtimeState') { + throw new Error('session.runtimeState is not implemented in this test client'); + } + if (method === 'graph.overview') { + return (options?.graphOverview ?? emptyGraphOverview) as T; } throw new Error(`unexpected RPC method ${method}`); }, @@ -79,16 +121,10 @@ function rpcClient(options?: { } as unknown as WebSocketRpcClient; } -function emitNotification( - listeners: Set, - notification: WebSocketRpcNotification, -): void { - for (const listener of listeners) { - listener(notification); - } -} - -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + window.history.pushState(null, '', '/'); +}); describe('Brunch React web app', () => { it('renders workspace chrome from workspace.snapshot via the RPC client', async () => { @@ -103,91 +139,110 @@ describe('Brunch React web app', () => { expect(screen.getByText('responding-to-elicitation')).toBeTruthy(); }); - it('requests the selected session projection explicitly', async () => { + it('renders selected session identity without requesting session projections', async () => { const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) }); render(); - expect(await screen.findByText('Choose the better framing.')).toBeTruthy(); - expect(screen.getByText('What should we build?')).toBeTruthy(); - expect(screen.getByText('A read-only dashboard.')).toBeTruthy(); - expect(screen.getByLabelText('prompt message')).toBeTruthy(); + expect(await screen.findByText('Attached session: session-1')).toBeTruthy(); + expect(screen.getByText('Spec 1')).toBeTruthy(); expect(calls).toContainEqual({ method: 'workspace.snapshot' }); - expect(calls).toContainEqual({ - method: 'session.transcriptDisplay', - params: { sessionId: 'session-1', specId: 1 }, - }); + expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); }); - it('renders an empty transcript display state', async () => { + it('loads the spec route through Query-backed graph RPC options', async () => { + window.history.pushState(null, '', '/spec/1'); + const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ - rpcClient: rpcClient({ projection: { rows: [] } }), + rpcClient: rpcClient({ calls, graphOverview: populatedGraphOverview }), }); render(); - expect(await screen.findByText('No transcript messages yet.')).toBeTruthy(); + expect(await screen.findByText('Graph overview')).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(); + fireEvent.click(screen.getAllByText('Focus node')[0]!); + expect(screen.getByText('Focused read pending: graph.nodeNeighborhood(1, 11, 1)')).toBeTruthy(); }); - it('refetches selected session transcript when the RPC client reports a product update', async () => { + it('invalidates the exact selected-spec graph overview query on graph notifications', async () => { + window.history.pushState(null, '', '/spec/1'); + const calls: RpcCall[] = []; const listeners = new Set(); - let projection: TranscriptDisplayProjection = { rows: [] }; const runtime = createBrunchWebRuntime({ - rpcClient: rpcClient({ - listeners, - projection: () => projection, - }), + rpcClient: rpcClient({ calls, listeners, graphOverview: populatedGraphOverview }), }); render(); - expect(await screen.findByText('No transcript messages yet.')).toBeTruthy(); - - projection = { - rows: [ - { - id: 'prompt-2', - role: 'prompt', - text: 'Is this a new product or feature from scratch?', - }, - ], - }; - emitNotification(listeners, { - jsonrpc: '2.0', - method: 'brunch.updated', - params: { topics: ['session.transcriptDisplay'] }, - }); - - await waitFor(() => - expect(screen.getByText('Is this a new product or feature from scratch?')).toBeTruthy(), - ); + expect(await screen.findByText('Spec A requirement')).toBeTruthy(); + calls.length = 0; + for (const listener of listeners) { + listener({ + jsonrpc: '2.0', + method: 'brunch.updated', + params: { updates: [{ topic: 'graph.overview', specId: 1 }] }, + }); + } + + await waitFor(() => expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 1 } })); + expect(screen.getByText('Spec A requirement')).toBeTruthy(); + expect(calls).toEqual([{ method: 'graph.overview', params: { specId: 1 } }]); }); - it('does not request session projection when no session is selected', async () => { + it('treats the spec route as client-local view state without borrowing the TUI session transcript', async () => { + window.history.pushState(null, '', '/spec/2'); const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ - rpcClient: rpcClient({ snapshot: selectSpecSnapshot, calls }), + rpcClient: rpcClient({ + calls, + graphOverview: emptyGraphOverview, + }), }); render(); - expect(await screen.findByText('No Brunch session selected.')).toBeTruthy(); - expect(calls).toEqual([{ method: 'workspace.snapshot' }]); + 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(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' })); }); - it('renders read-only session projection errors', async () => { + it('loads the spec route without requesting session data when no session is selected', async () => { + window.history.pushState(null, '', '/spec/2'); + const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ - projectionError: new Error('Brunch session transcript is non-linear'), + snapshot: selectedSpecWithoutSessionSnapshot, + calls, + graphOverview: emptyGraphOverview, }), }); render(); - expect( - await screen.findByText('Transcript unavailable: Brunch session transcript is non-linear'), - ).toBeTruthy(); + expect(await screen.findByText('Spec without session')).toBeTruthy(); + expect(screen.getByText('No graph nodes yet. LSN 0; 0 nodes; 0 edges.')).toBeTruthy(); + expect(calls).toContainEqual({ method: 'workspace.snapshot' }); + expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 2 } }); + expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); + }); + + it('does not request session projection when no session is selected', async () => { + const calls: RpcCall[] = []; + const runtime = createBrunchWebRuntime({ + rpcClient: rpcClient({ snapshot: selectSpecSnapshot, calls }), + }); + + render(); + + expect(await screen.findByText('No Brunch session selected.')).toBeTruthy(); + expect(calls).toEqual([{ method: 'workspace.snapshot' }]); }); it('keeps one router and QueryClient across BrunchWebApp re-renders', async () => { diff --git a/src/web/app.tsx b/src/web/app.tsx index 8dd3c72f..8cd480be 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,43 +1,24 @@ -import { - QueryClient, - QueryClientProvider, - queryOptions, - useQuery, - useSuspenseQuery, -} from '@tanstack/react-query'; -import { RouterProvider, createRootRouteWithContext, createRouter } from '@tanstack/react-router'; -import { Suspense, useEffect } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, createRouter, type AnyRouter } from '@tanstack/react-router'; +import { Suspense } from 'react'; -import type { WorkspaceSnapshot } from '../print-snapshot.js'; -import type { TranscriptDisplayProjection } from '../session/elicitation-exchange.js'; +import { createBrunchQueryClient } from './query-client.js'; +import { indexRoute, rootRoute, type BrunchWebRouterContext } from './routes/root.js'; +import { specRoute } from './routes/spec.js'; import type { WebSocketRpcClient } from './rpc-client.js'; -type RouterContext = { - queryClient: QueryClient; - rpcClient: WebSocketRpcClient; -}; - -type SessionProjectionTarget = { - sessionId: string; - specId: number; -}; +export type BrunchWebRouter = AnyRouter; export interface BrunchWebRuntime { - queryClient: QueryClient; + queryClient: BrunchWebRouterContext['queryClient']; rpcClient: WebSocketRpcClient; - router: ReturnType; + router: BrunchWebRouter; dispose(): void; } -const rootRoute = createRootRouteWithContext()({ - loader: ({ context }) => - context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)), - component: WorkspaceSnapshotPage, -}); - -const routeTree = rootRoute; +const routeTree = rootRoute.addChildren([indexRoute, specRoute]); -export function createBrunchWebRouter(options: { queryClient: QueryClient; rpcClient: WebSocketRpcClient }) { +export function createBrunchWebRouter(options: BrunchWebRouterContext): BrunchWebRouter { return createRouter({ routeTree, defaultPreloadStaleTime: 0, @@ -50,12 +31,12 @@ export function createBrunchWebRouter(options: { queryClient: QueryClient; rpcCl declare module '@tanstack/react-router' { interface Register { - router: ReturnType; + router: BrunchWebRouter; } } export function createBrunchWebRuntime(options: { rpcClient: WebSocketRpcClient }): BrunchWebRuntime { - const queryClient = new QueryClient(); + const queryClient = createBrunchQueryClient(); const router = createBrunchWebRouter({ queryClient, rpcClient: options.rpcClient, @@ -79,140 +60,3 @@ export function BrunchWebApp(options: { runtime: BrunchWebRuntime }) { ); } - -function workspaceSnapshotQueryOptions(rpcClient: WebSocketRpcClient) { - return queryOptions({ - queryKey: ['workspace.snapshot'], - queryFn: () => rpcClient.request('workspace.snapshot'), - }); -} - -function sessionProjectionTargetFromSnapshot(snapshot: WorkspaceSnapshot): SessionProjectionTarget | null { - if (!snapshot.session || !snapshot.spec) { - return null; - } - return { sessionId: snapshot.session.id, specId: snapshot.spec.id }; -} - -function sessionTranscriptDisplayQueryOptions( - rpcClient: WebSocketRpcClient, - target: SessionProjectionTarget | null, -) { - return { - queryKey: ['session.transcriptDisplay', target?.sessionId ?? null, target?.specId ?? null], - queryFn: () => - rpcClient.request( - 'session.transcriptDisplay', - target ?? unreachableSessionProjectionTarget(), - ), - enabled: target !== null, - retry: false, - }; -} - -function unreachableSessionProjectionTarget(): never { - throw new Error('Session transcript query is disabled without a target'); -} - -function WorkspaceSnapshotPage() { - const { queryClient, rpcClient } = rootRoute.useRouteContext(); - useEffect( - () => - rpcClient.subscribe((notification) => { - if (notification.method !== 'brunch.updated') return; - void queryClient.invalidateQueries({ - queryKey: ['workspace.snapshot'], - }); - void queryClient.invalidateQueries({ - queryKey: ['session.transcriptDisplay'], - }); - }), - [queryClient, rpcClient], - ); - const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); - const target = sessionProjectionTargetFromSnapshot(snapshot); - const projection = useQuery(sessionTranscriptDisplayQueryOptions(rpcClient, target)); - - return ( -
-

Brunch workspace

-
-
-
cwd
-
{snapshot.cwd}
-
-
-
spec
-
{snapshot.spec?.title ?? ''}
-
-
-
session
-
{snapshot.session?.id ?? ''}
-
-
-
phase
-
{snapshot.chrome.phase}
-
-
-
chatMode
-
{snapshot.chrome.chatMode}
-
-
- -
- ); -} - -function TranscriptPanel(options: { - snapshot: WorkspaceSnapshot; - projection: ReturnType>; -}) { - if (!options.snapshot.session || !options.snapshot.spec) { - return ( -
-

Session transcript

-

No Brunch session selected.

-
- ); - } - - if (options.projection.isError) { - return ( -
-

Session transcript

-

{`Transcript unavailable: ${errorMessage(options.projection.error)}`}

-
- ); - } - - if (!options.projection.data) { - return ( -
-

Session transcript

-

Loading transcript…

-
- ); - } - - const projection = options.projection.data; - return ( -
-

Session transcript

- {projection.rows.length === 0 ?

No transcript messages yet.

: null} -
    - {projection.rows.map((row) => ( -
  1. -
    - {row.role} -

    {row.text}

    -
    -
  2. - ))} -
-
- ); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx new file mode 100644 index 00000000..13386f02 --- /dev/null +++ b/src/web/features/graph/GraphOverview.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; + +import type { GraphOverview } from '../../../graph/snapshot.js'; + +export function GraphOverviewPanel(options: { overview: GraphOverview }) { + const { overview } = options; + const [focusedNodeId, setFocusedNodeId] = useState(null); + const nodeGroups = groupNodes(overview.nodes); + const edgeSummary = summarizeEdges(overview.edges); + const focusedNode = + focusedNodeId === null ? undefined : overview.nodes.find((node) => node.id === focusedNodeId); + + return ( +
+

Graph overview

+
+
+
Nodes
+
{overview.nodeCount}
+
+
+
Edges
+
{overview.edgeCount}
+
+
+
LSN
+
{overview.lsn}
+
+
+ {overview.nodes.length === 0 ? ( +

{`No graph nodes yet. LSN ${overview.lsn}; 0 nodes; 0 edges.`}

+ ) : null} + {overview.nodes.length > 0 ? ( + <> +
+

Edge categories

+ {edgeSummary.length === 0 ? ( +

No edges yet.

+ ) : ( +
    + {edgeSummary.map(([category, count]) => ( +
  • {`${category}: ${count}`}
  • + ))} +
+ )} +
+ {nodeGroups.map((group) => ( +
+

{group.label}

+
    + {group.nodes.map((node) => ( +
  • +
    + {node.title} +

    {`${node.plane} / ${node.kind}`}

    + {node.body ?

    {node.body}

    : null} + +
    +
  • + ))} +
+
+ ))} + + ) : null} + {focusedNode ? ( +

{`Focused read pending: graph.nodeNeighborhood(${focusedNode.specId}, ${focusedNode.id}, 1)`}

+ ) : null} +
+ ); +} + +function groupNodes(nodes: GraphOverview['nodes']): Array<{ + label: string; + nodes: GraphOverview['nodes']; +}> { + const groups = new Map(); + for (const node of nodes) { + const label = `${node.plane} / ${node.kind}`; + groups.set(label, [...(groups.get(label) ?? []), node]); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([label, groupedNodes]) => ({ label, nodes: groupedNodes })); +} + +function summarizeEdges(edges: GraphOverview['edges']): Array<[string, number]> { + const counts = new Map(); + for (const edge of edges) { + counts.set(edge.category, (counts.get(edge.category) ?? 0) + 1); + } + return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)); +} diff --git a/src/web/queries/graph.ts b/src/web/queries/graph.ts new file mode 100644 index 00000000..8d74318b --- /dev/null +++ b/src/web/queries/graph.ts @@ -0,0 +1,29 @@ +import { queryOptions } from '@tanstack/react-query'; + +import type { GraphOverview, NeighborhoodResult } from '../../graph/snapshot.js'; +import { queryKeys } from '../query-keys.js'; +import type { WebSocketRpcClient } from '../rpc-client.js'; + +export function graphOverviewQueryOptions(rpcClient: WebSocketRpcClient, specId: number) { + return queryOptions({ + queryKey: queryKeys.graph.overview(specId), + queryFn: () => rpcClient.request('graph.overview', { specId }), + }); +} + +export function graphNodeNeighborhoodQueryOptions( + rpcClient: WebSocketRpcClient, + specId: number, + nodeId: number, + hops?: number, +) { + return queryOptions({ + queryKey: queryKeys.graph.nodeNeighborhood(specId, nodeId, hops ?? null), + queryFn: () => + rpcClient.request('graph.nodeNeighborhood', { + specId, + nodeId, + ...(hops === undefined ? {} : { hops }), + }), + }); +} diff --git a/src/web/queries/session.ts b/src/web/queries/session.ts new file mode 100644 index 00000000..4ca6bae4 --- /dev/null +++ b/src/web/queries/session.ts @@ -0,0 +1,20 @@ +import type { QueryObserverOptions } from '@tanstack/react-query'; + +import type { RuntimeStateProjection } from '../../session/runtime-state.js'; +import { queryKeys } from '../query-keys.js'; +import type { WebSocketRpcClient } from '../rpc-client.js'; + +export type SessionProjectionTarget = { + sessionId: string; + specId: number; +}; + +export function sessionRuntimeStateQueryOptions( + rpcClient: WebSocketRpcClient, + target: SessionProjectionTarget, +): QueryObserverOptions { + return { + queryKey: queryKeys.session.runtimeState(target), + queryFn: () => rpcClient.request('session.runtimeState', target), + }; +} diff --git a/src/web/queries/workspace.ts b/src/web/queries/workspace.ts new file mode 100644 index 00000000..48ee5999 --- /dev/null +++ b/src/web/queries/workspace.ts @@ -0,0 +1,12 @@ +import { queryOptions } from '@tanstack/react-query'; + +import type { WorkspaceSnapshot } from '../../print-snapshot.js'; +import { queryKeys } from '../query-keys.js'; +import type { WebSocketRpcClient } from '../rpc-client.js'; + +export function workspaceSnapshotQueryOptions(rpcClient: WebSocketRpcClient) { + return queryOptions({ + queryKey: queryKeys.workspace.snapshot(), + queryFn: () => rpcClient.request('workspace.snapshot'), + }); +} diff --git a/src/web/query-client.ts b/src/web/query-client.ts new file mode 100644 index 00000000..864ecffc --- /dev/null +++ b/src/web/query-client.ts @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; + +export function createBrunchQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1_000, + refetchOnWindowFocus: false, + retry: false, + }, + }, + }); +} diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts new file mode 100644 index 00000000..57dbe09b --- /dev/null +++ b/src/web/query-keys.ts @@ -0,0 +1,14 @@ +export const queryKeys = { + workspace: { + snapshot: () => ['workspace.snapshot'] as const, + }, + session: { + runtimeState: (target: { specId: number; sessionId: string }) => + ['session.runtimeState', target.specId, target.sessionId] as const, + }, + graph: { + overview: (specId: number) => ['graph.overview', specId] as const, + nodeNeighborhood: (specId: number, nodeId: number, hops: number | null = null) => + ['graph.nodeNeighborhood', specId, nodeId, hops] as const, + }, +}; diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx new file mode 100644 index 00000000..d213df9d --- /dev/null +++ b/src/web/routes/root.tsx @@ -0,0 +1,120 @@ +import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query'; +import { Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; + +import type { WorkspaceSnapshot } from '../../print-snapshot.js'; +import { workspaceSnapshotQueryOptions } from '../queries/workspace.js'; +import type { WebSocketRpcClient } from '../rpc-client.js'; +import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; + +export type SessionProjectionTarget = { + sessionId: string; + specId: number; +}; +export interface BrunchWebRouterContext { + queryClient: QueryClient; + rpcClient: WebSocketRpcClient; +} + +export const rootRoute = createRootRouteWithContext()({ + component: RootLayout, +}); + +export const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: ({ context }) => + context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)), + component: WorkspaceSnapshotPage, +}); + +export function sessionProjectionTargetFromSnapshot( + snapshot: WorkspaceSnapshot, + viewedSpecId?: number, +): SessionProjectionTarget | null { + if (!snapshot.session || !snapshot.spec) { + return null; + } + if (viewedSpecId !== undefined && snapshot.spec.id !== viewedSpecId) { + return null; + } + return { sessionId: snapshot.session.id, specId: snapshot.spec.id }; +} + +function RootLayout() { + const { queryClient, rpcClient } = rootRoute.useRouteContext(); + useBrunchUpdateSubscription(queryClient, rpcClient); + return ; +} + +function WorkspaceSnapshotPage() { + const { rpcClient } = indexRoute.useRouteContext(); + const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); + + return ( +
+

Brunch workspace

+ + +
+ ); +} + +export function WorkspaceChrome(options: { snapshot: WorkspaceSnapshot; fallbackSpecId?: number }) { + const { snapshot } = options; + const specLabel = + snapshot.spec?.title ?? + (options.fallbackSpecId === undefined ? 'No spec selected' : `Spec ${options.fallbackSpecId}`); + return ( +
+
+
cwd
+
{snapshot.cwd}
+
+
+
spec
+
{specLabel}
+
+
+
session
+
{snapshot.session?.id ?? 'No session selected'}
+
+
+
phase
+
{snapshot.chrome.phase}
+
+
+
chat mode
+
{snapshot.chrome.chatMode}
+
+
+ ); +} + +export function SessionPanel(options: { snapshot: WorkspaceSnapshot; viewedSpecId?: number }) { + if (!options.snapshot.session || !options.snapshot.spec) { + return ( +
+

Session

+

No Brunch session selected.

+
+ ); + } + + if (options.viewedSpecId !== undefined && options.snapshot.spec.id !== options.viewedSpecId) { + return ( +
+

Session

+

{`No session is attached for viewed Spec ${options.viewedSpecId}.`}

+

{`The TUI is active in Spec ${options.snapshot.spec.id}/${options.snapshot.session.id}.`}

+
+ ); + } + + return ( +
+

Session

+

{`Attached session: ${options.snapshot.session.id}`}

+

{`Spec ${options.snapshot.spec.id}`}

+
+ ); +} diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx new file mode 100644 index 00000000..5d98ecac --- /dev/null +++ b/src/web/routes/spec.tsx @@ -0,0 +1,37 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; + +import { GraphOverviewPanel } from '../features/graph/GraphOverview.js'; +import { graphOverviewQueryOptions } from '../queries/graph.js'; +import { workspaceSnapshotQueryOptions } from '../queries/workspace.js'; +import { rootRoute, SessionPanel, WorkspaceChrome } from './root.js'; + +export const specRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/spec/$specId', + loader: ({ context, params }) => { + const specId = Number(params.specId); + return Promise.all([ + context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)), + context.queryClient.ensureQueryData(graphOverviewQueryOptions(context.rpcClient, specId)), + ]); + }, + component: SpecRoutePage, +}); + +function SpecRoutePage() { + const { rpcClient } = specRoute.useRouteContext(); + const { specId } = specRoute.useParams(); + const parsedSpecId = Number(specId); + const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); + const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, parsedSpecId)); + + return ( +
+

Brunch workspace

+ + + +
+ ); +} diff --git a/src/web/rpc-client.test.ts b/src/web/rpc-client.test.ts index 60e8e2e4..b8cbce5e 100644 --- a/src/web/rpc-client.test.ts +++ b/src/web/rpc-client.test.ts @@ -48,7 +48,7 @@ describe('browser WebSocket RPC client', () => { it('opens one persistent socket and queues requests until open', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); expect(FakeWebSocket.instances).toHaveLength(1); const socket = FakeWebSocket.instances[0]!; @@ -90,7 +90,7 @@ describe('browser WebSocket RPC client', () => { JSON.stringify({ jsonrpc: '2.0', method: 'brunch.updated', - params: { topics: ['session.transcriptDisplay'] }, + params: { topics: ['session.runtimeState'] }, }), ); socket.emit('message', JSON.stringify({ jsonrpc: '2.0', id: 1, result: 'snapshot' })); @@ -100,7 +100,7 @@ describe('browser WebSocket RPC client', () => { { jsonrpc: '2.0', method: 'brunch.updated', - params: { topics: ['session.transcriptDisplay'] }, + params: { topics: ['session.runtimeState'] }, }, ]); }); @@ -143,7 +143,7 @@ describe('browser WebSocket RPC client', () => { it('rejects all pending requests and later calls on malformed response frames', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -159,7 +159,7 @@ describe('browser WebSocket RPC client', () => { it('rejects all pending requests and later calls on invalid response frames', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -175,7 +175,7 @@ describe('browser WebSocket RPC client', () => { it('rejects all pending requests and later calls on unknown response IDs', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -191,7 +191,7 @@ describe('browser WebSocket RPC client', () => { it('rejects all pending requests on socket close', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -204,7 +204,7 @@ describe('browser WebSocket RPC client', () => { it('treats socket errors as terminal connection failures', async () => { const client = rpcClient(); const first = client.request('workspace.snapshot'); - const second = client.request('session.elicitationExchanges'); + const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts new file mode 100644 index 00000000..4aeb4475 --- /dev/null +++ b/src/web/subscriptions/brunch-updates.ts @@ -0,0 +1,101 @@ +import type { QueryClient, QueryKey } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { queryKeys } from '../query-keys.js'; +import type { WebSocketRpcClient, WebSocketRpcNotification } from '../rpc-client.js'; + +type ProductUpdate = { + readonly topic?: unknown; + readonly specId?: unknown; + readonly sessionId?: unknown; + readonly nodeId?: unknown; +}; + +export function useBrunchUpdateSubscription(queryClient: QueryClient, rpcClient: WebSocketRpcClient): void { + useEffect( + () => + rpcClient.subscribe((notification) => { + if (notification.method !== 'brunch.updated') { + return; + } + invalidateBrunchUpdate(queryClient, notification); + }), + [queryClient, rpcClient], + ); +} + +export function invalidateBrunchUpdate( + queryClient: QueryClient, + notification: WebSocketRpcNotification, +): void { + const params = notification.params; + if (!isRecord(params)) { + return; + } + + const updates = Array.isArray(params.updates) ? params.updates : []; + if (updates.length > 0) { + for (const update of updates) { + invalidateProductUpdate(queryClient, update as ProductUpdate); + } + return; + } + + if (Array.isArray(params.topics)) { + for (const topic of params.topics) { + if (typeof topic === 'string') { + invalidateTopic(queryClient, topic); + } + } + } +} + +function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate): void { + if (update.topic === 'workspace.snapshot') { + invalidateExact(queryClient, queryKeys.workspace.snapshot()); + return; + } + if (update.topic === 'graph.overview' && typeof update.specId === 'number') { + invalidateExact(queryClient, queryKeys.graph.overview(update.specId)); + return; + } + if ( + update.topic === 'graph.nodeNeighborhood' && + typeof update.specId === 'number' && + typeof update.nodeId === 'number' + ) { + void queryClient.invalidateQueries({ + queryKey: ['graph.nodeNeighborhood', update.specId, update.nodeId], + }); + return; + } + if (typeof update.topic === 'string') { + invalidateTopic(queryClient, update.topic); + } +} + +function invalidateTopic(queryClient: QueryClient, topic: string): void { + if (topic === 'workspace.snapshot') { + invalidateExact(queryClient, queryKeys.workspace.snapshot()); + return; + } + if (topic === 'session.runtimeState') { + void queryClient.invalidateQueries({ queryKey: ['session.runtimeState'] }); + return; + } + if (topic === 'graph.overview') { + void queryClient.invalidateQueries({ queryKey: ['graph.overview'] }); + return; + } + if (topic === 'graph.nodeNeighborhood') { + void queryClient.invalidateQueries({ queryKey: ['graph.nodeNeighborhood'] }); + } +} + +function invalidateExact(queryClient: QueryClient, queryKey: QueryKey): void { + void queryClient.invalidateQueries({ queryKey, exact: true }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +}