diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index 06e0914a5..472d8299e 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -30,6 +30,8 @@ Never scan or pick by mtime, alphabetical order, or directory-listing heuristics 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. +If the selected file is `Mode: coverage`, it holds a row ledger rather than cards — follow the [Coverage execution mode](#coverage-execution-mode) loop below instead of card-based selection. + Re-enter before red. If this is a fresh thread or an unfamiliar area, reload: @@ -85,6 +87,22 @@ Even when `ln-scope` honored the hard anti-speculation gate (no card's scope was Never silently continue past a stale-downstream signal. Never silently delete a stale chain before a replacement exists. +## Coverage execution mode + +When a scope file is `Mode: coverage` (see [`ln-scope`](../ln-scope/SKILL.md) §Coverage scope files), it holds a closed enumerated ledger of one capability layer rather than a sequence of full cards. The build loop is row-driven: + +1. take the next open required (`●`) row — one whose Status is `spec`, `new`, or `partial` +2. build it under the **fill mode declared in that row** (`proving` → tracer that retires the row's unknown; `earned` → land and lock the settled capability). A `new` row needs its micro-decision resolved (`ln-disambiguate` / `ln-spec`) before it can be built +3. run red → green → refactor and the verification harness for that row +4. flip the row's Status to `built` in the ledger and reconcile canonical state +5. commit the row-sized change +6. continue until **no `●` row remains in `spec` / `new` / `partial`** — that aggregate DoD, not any single row, completes the coverage frontier + +The chain stop conditions and Stale-downstream re-orient apply per row. Two coverage-specific rules: + +- **Do not add rows as you go** except to record a genuinely-missing capability (Status `new`, one-line justification). The ledger is a closed list; filling it never means "do everything that rhymes" (global `AGENTS.md` §completionist sprawl). +- **A row that grows past ledger-row size** spawns its own `single` scope file; replace the row's Owner / next cell with a pointer rather than fattening the ledger. + ## 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. diff --git a/.agents/skills/ln-consult/SKILL.md b/.agents/skills/ln-consult/SKILL.md index 6c8f3db0d..82548c877 100644 --- a/.agents/skills/ln-consult/SKILL.md +++ b/.agents/skills/ln-consult/SKILL.md @@ -102,6 +102,7 @@ Spikes are the escape hatch, not the default. | Plausible interpretations diverge; examples would clarify faster than open-ended questioning | structural | `ln-disambiguate` | | Understanding exists, needs a written spec | structural | `ln-spec` | | Spec exists, needs work sequencing | structural | `ln-plan` | +| A capability layer is load-bearing as a whole but vertical slices keep leaving it shallow | structural | `ln-plan` — author a coverage frontier (see `ln-plan` §Horizontal coverage frontiers) | | 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 a `Mode: chain` scope file under `memory/cards/` | diff --git a/.agents/skills/ln-judo-review/SKILL.md b/.agents/skills/ln-judo-review/SKILL.md index 41c6c9fc1..90e1ac453 100644 --- a/.agents/skills/ln-judo-review/SKILL.md +++ b/.agents/skills/ln-judo-review/SKILL.md @@ -32,6 +32,8 @@ Make the change easy, then make the easy change (Beck): if the diff feels tangle Boring code over magic (Hunt & Thomas): generic mechanisms that hide simple data-shape assumptions are a defect, not a feature. +Ambient-contract reliance: an invariant the code assumes but never enforces, threads, or names — uniqueness keys that silently last-win, dedups that drop kept data, hardcoded literals standing in for upstream provenance, persisted absolute paths/`cwd` leaking into committed fixtures, magic shape-checks instead of named predicates. The judo move is to make the contract intentional: enforce it loudly, thread the real value, or name it — not to tidy the assumption in place. (Full cue list in `ln-review` §Contract integrity.) + Functional core / imperative shell (Gary Bernhardt): when independent work is needlessly serialized, or related updates can leave state half-applied, ask whether orchestration should be separated from business logic — and whether the cleaner structure is parallel or atomic. ### Specific rules @@ -77,7 +79,7 @@ Prefer a small number of high-conviction comments over a long list of cosmetic n ```md ## Judo Review: [area] -1. **[Description]** — [category: judo|depth|spaghetti|boundary|file-size|naming] — [impact: low|medium|high] +1. **[Description]** — [category: judo|depth|spaghetti|boundary|contract|file-size|naming] — [impact: low|medium|high] [1-2 sentence explanation and suggested action] ``` diff --git a/.agents/skills/ln-plan/SKILL.md b/.agents/skills/ln-plan/SKILL.md index bfb9f55a1..1c6d53bbd 100644 --- a/.agents/skills/ln-plan/SKILL.md +++ b/.agents/skills/ln-plan/SKILL.md @@ -54,7 +54,7 @@ The posture is **per frontier**, not per project. A mostly-earned repo can carry Posture annotations are **required** on every `Active` / `Next` frontier (see the matching reference for the field set). If no posture-specific annotation applies, the frontier is not earning its slot — reshape, reclassify, or demote it. -When implementation later reveals the posture was wrong, treat that as a state transition (downgrade earned → proving, reshape the slice, route back through `ln-plan` if the frontier itself splits). Do not invent a third permanent posture. +When implementation later reveals the posture was wrong, treat that as a state transition (downgrade earned → proving, reshape the slice, route back through `ln-plan` if the frontier itself splits). Do not invent a third permanent posture. (A horizontal **coverage frontier** is a frontier *shape*, not a third posture — see [§Horizontal coverage frontiers](#horizontal-coverage-frontiers-frontier-shape-not-a-posture).) Defensive parsing: depend primarily on `.pi/POSTURE.md`'s `certainty:` field; tolerate extra or mismatched fields rather than failing on schema drift. @@ -124,6 +124,30 @@ Sequencing pressures and required annotation fields depend on the active frontie A plan may contain a mix of postures across its `Active` / `Next` frontiers. Load both references when planning a mixed plan. +### Horizontal coverage frontiers (frontier *shape*, not a posture) + +Posture answers *how to rank the next vertical slice*; it carries **no completeness test**. Vertical tracers touch a horizontal capability layer (for example "the agent's READ tools as a whole") only as far as each claim needs, so a load-bearing layer can stay permanently shallow while every individual slice is still "done." + +A **coverage frontier** fills that gap. It is a different frontier *shape*, not a third posture: it adds no row-level execution mechanics — each row is still built under `proving` or `earned`. What it adds is a layer-level **aggregate definition of done**: *no required row in a closed enumerated inventory is left open.* + +**Recognition trigger.** Reach for a coverage frontier only when all three hold: + +1. a **named layer is load-bearing as a whole** — its value *is* its breadth (an agent's capability surface, a public API's method set, a renderer family), not just one claim it proves; +2. you can **author a closed, enumerated inventory** up front of what the layer must contain; and +3. rows can be marked **required vs deferred** (e.g. POC `●` / later `○`). + +If you cannot close the enumeration, it is not a coverage frontier — stay tracer-shallow. Most product layers should (correct YAGNI). Coverage mode is safe *only because the surface is a closed list*; without this gate it degenerates into completionist sprawl (global `AGENTS.md` §completionist sprawl). + +**Frontier definition fields.** A coverage frontier names: + +- the **layer boundary** — what is in the layer and explicitly what is out; +- the **aggregate DoD** — "every `●` row is closed"; +- a pointer to the **`Mode: coverage` scope file** under `memory/cards/` that holds the row ledger (authored via `ln-scope`). + +Each ledger row declares its own **fill mode** — `proving` if the row still carries an unknown, `earned` if it is settled-but-unbuilt. `ln-build` closes rows; the frontier completes when no `●` row remains in a `spec` / `new` / `partial` state — the ledger DoD, not a single tracer claim. + +**Maturity gate.** The coverage shape is young. Treat it as a recognized scope-file mode, **not** a canonical posture or doc type. Promote it to first-class (a `references/coverage.md` posture, a canonical coverage store) only on rule-of-three — at least three real coverage cases *and* a recurring need for row-level mechanics beyond "closed ledger + per-row proving/earned." Until then, do not add a third posture reference or an alternate planning store. + ## Procedure 0. Read `.pi/POSTURE.md` if present for the project's default certainty posture. For each `Active` / `Next` frontier, check for an explicit `Certainty:` override and load the matching reference (`references/proving.md` or `references/earned.md`). Load both when the plan is mixed. diff --git a/.agents/skills/ln-review/SKILL.md b/.agents/skills/ln-review/SKILL.md index 8cc70c249..937d5b1b3 100644 --- a/.agents/skills/ln-review/SKILL.md +++ b/.agents/skills/ln-review/SKILL.md @@ -1,6 +1,6 @@ --- name: ln-review -description: "Audit code quality focusing on deep modules, naming, model hygiene, topographic legibility, and architectural clarity. Use after a burst of development, when codebase structure needs assessment, or to make code more agent-navigable." +description: "Audit code quality focusing on deep modules, naming, model hygiene, ambient-contract reliance, topographic legibility, and architectural clarity. Use after a burst of development, when codebase structure needs assessment, or to make code more agent-navigable." argument-hint: "[area of codebase to review, or 'recent' for recently changed files]" --- @@ -46,6 +46,22 @@ Check the functional core / imperative shell boundary (Gary Bernhardt, "Boundari Make invalid states unrepresentable (Yaron Minsky). Split optional fields into distinct types. Use branded types for domain-distinct values. +### Contract integrity (category: `contract`) + +Find invariants the code *assumes* but never enforces, threads, or names — "ambient-contract reliance." The seam works today only because the assumption happens to hold; nothing makes the contract intentional, and a reviewer can't tell intended behavior from accident. Each finding routes to one of three repairs: **enforce it loudly** (fail on violation), **thread the real value** (carry provenance instead of hardcoding it), or **name the contract** (a predicate/type/comment that makes the assumption explicit). + +Concrete cues to look for: + +- A `Map`/object built from a list keyed by a field assumed unique → duplicates silently last-win. Repair: throw on collision. +- A dedup or "first wins / last wins" that silently drops data the caller meant to keep. Repair: thread distinct keys, or fail loudly. +- A hardcoded literal standing in for a value that should be carried from upstream (`respondsToPresentTool: 'present_options'` when the originating tool varies). Repair: thread the real provenance. +- Persisted or serialized data that assumes an ambient environment (absolute paths, `cwd`, tempdirs, machine-local roots leaking into committed fixtures). Repair: name the portable contract and normalize at the boundary. +- A magic check inferring readiness/state from an object's incidental shape instead of a named constant or predicate. Repair: name the predicate against the canonical constant. +- Ordering or position encoded by a numeric index/splice rather than by structure. Repair: make the order declarative. +- A type alias or name that implies a wider contract than it points at. Repair: point it at the real union, or rename. + +Collect findings as numbered items (category: `contract`). Frame each as: the assumed contract in one sentence, the failure mode when it breaks, and which of the three repairs applies. Most are concrete fixes (`ln-scope`/`ln-build`); clusters across a seam route to `ln-refactor`. + ### Oracle coverage (category: `oracle-coverage`) If `memory/SPEC.md` §Oracle Strategy by Loop Tier exists, check whether recent work implemented the oracles declared by the relevant `memory/PLAN.md` frontier definition. If a full or light scope card is available in session context, use it as a higher-resolution slice supplement, not the primary source of truth. Look for: @@ -109,7 +125,7 @@ Present findings as numbered candidates. Use the compact form for ordinary findi ```md ## Review: [area] -1. **[Description]** — [category: depth|naming|model|coupling|seam|oracle-coverage|topography] — [impact: low|medium|high] +1. **[Description]** — [category: depth|naming|model|contract|coupling|seam|oracle-coverage|topography] — [impact: low|medium|high] [1-2 sentence explanation and suggested action] 2. ... diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index ebbfd89f5..2413e780b 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -64,11 +64,11 @@ Every scope file starts with this header: Frontier: | n/a Status: active | superseded | done -Mode: single | chain +Mode: single | chain | coverage 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. +`Mode: single` means one card in this file. `Mode: chain` means several cards intended as a sequential mini-queue. `Mode: coverage` means the file holds a **closed enumerated ledger** for a horizontal coverage frontier (see [§Coverage scope files](#coverage-scope-files-mode-coverage)). Independent concerns belong in **separate files**, not separate sections within one file. ### Why one file per concern, not one file for everything @@ -100,6 +100,32 @@ Chain discipline: - 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) +## Coverage scope files (`Mode: coverage`) + +A `Mode: coverage` scope file is the execution artifact for a **horizontal coverage frontier** (see [`ln-plan`](../ln-plan/SKILL.md) §Horizontal coverage frontiers). Where `single` / `chain` files group *vertical* slices, a coverage file holds a **closed enumerated ledger** of one capability layer, and its definition of done is *aggregate*: every required row closed. + +Write one only when `ln-plan` has established a coverage frontier whose three-part gate is satisfied — a named layer that is load-bearing as a whole, a closeable enumeration, and required-vs-deferred marking. If you cannot close the enumeration, do not use this mode; write ordinary vertical cards instead. + +### Ledger shape + +The file body is a coverage ledger — one table per sub-seam if the layer splits: + +| Capability | Status | Req | Fill | Owner / next | Notes | +| --- | --- | --- | --- | --- | --- | +| *one capability the layer must contain* | `have` \| `partial` \| `spec` \| `new` \| `built` | `●` \| `○` | `proving` \| `earned` | *card / decision / pointer* | *links* | + +- **Status:** `have` (in code) · `partial` (exists, incomplete vs target) · `spec` (designed, not built) · `new` (beyond spec, needs a decision first) · `built` (closed this push). +- **Req:** `●` required for the DoD · `○` deferred. The DoD is "every `●` row is `have` or `built`." +- **Fill:** the posture each row's build inherits — `proving` if the row still carries an unknown, `earned` if it is settled-but-unbuilt. A `new` row usually needs a micro-decision (`ln-disambiguate` / `ln-spec`) before it can be filled. + +### Each row is still a vertical fill + +The file is horizontal; each **row** is built as an ordinary thin slice under its declared fill posture. `ln-build` implements rows and flips their Status to `built`; the row's target *is* the acceptance criterion. A row whose scope turns out to need its own full card may spawn a sibling `single` file — leave a pointer in that row's Owner / next cell rather than fattening the ledger. + +### Anti-sprawl boundary + +The ledger is a **closed list**, not a generative one. "Fill the layer" means *close these enumerated rows*, never "do everything that rhymes" (global `AGENTS.md` §completionist sprawl). Add a row mid-flight only when a genuinely-missing capability is discovered — record it with Status `new` and a one-line justification, never as completionist symmetry. + ## 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. diff --git a/.fixtures/README.md b/.fixtures/README.md index c1c605b0c..cbc57454b 100644 --- a/.fixtures/README.md +++ b/.fixtures/README.md @@ -25,7 +25,7 @@ for the current architecture. ├── session.jsonl # Source transcript / canonical run evidence ├── transcript.md # Human-readable semantic rendering ├── report.json # Probe report and artifact paths - └── graph-snapshot.json # Optional graph readback when graph truth is the proof target + └── graph-overview.json # Optional graph readback when graph truth is the proof target ``` ## Current runs diff --git a/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json b/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json index edc32f970..36095a7cd 100644 --- a/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json +++ b/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json @@ -6,7 +6,7 @@ "seedSet": "bilal-port-variants", "seedSlug": "macro-view-grounded-intent", "selectedBaseProfile": "grounded-intent", - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-fixture-curation-vg9Eo2", + "cwd": "", "specId": 1, "sessionId": "019e96f4-ae08-762d-9783-ddd94163e7b3", "prompt": "Brunch fixture-curation tracer: the selected spec is a Bilal-derived explicit base seed named \"macro-view-grounded-intent\".\n\nUse read_graph once in overview mode. Then use commit_graph exactly once to add a small intent-plane expansion that improves launch/usefulness of this existing spec without duplicating base nodes. Create one to three new intent-plane nodes, connect them legally to existing graph truth when possible, use basis implicit through the propose-graph tool path, and stop after a successful commit_graph result.", @@ -36,7 +36,7 @@ 11, 12 ], - "content": "Graph committed successfully (LSN 3).\nNodes created: n1 → R1, n2 → R2\nEdges created: #7, #8, #9, #10, #11, #12" + "content": "Graph committed successfully (LSN 3).\nNodes created: n1 \u2192 R1, n2 \u2192 R2\nEdges created: #7, #8, #9, #10, #11, #12" } ], "createdNodes": [ @@ -68,10 +68,10 @@ }, "friction": [], "artifacts": { - "runDir": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z", - "sessionJsonl": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/session.jsonl", - "transcriptMarkdown": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/transcript.md", - "reportJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json", - "graphSnapshotJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-snapshot.json" + "runDir": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z", + "sessionJsonl": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z/session.jsonl", + "transcriptMarkdown": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z/transcript.md", + "reportJson": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z/report.json", + "graphSnapshotJson": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-snapshot.json" } } diff --git a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json index fb1d84438..61c617085 100644 --- a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json @@ -4,10 +4,10 @@ "runId": "2026-06-06-project-graph-review-cycle", "generatedAt": "2026-06-06T14:02:59.202Z", "mission": "Prove the project-graph strategy can present an exact review set and approve it through public RPC.", - "evaluationFocus": "FE-809 real agent proposal → present_review_set → session.submitExchangeResponse approval → explicit graph readback.", + "evaluationFocus": "FE-809 real agent proposal \u2192 present_review_set \u2192 session.submitExchangeResponse approval \u2192 explicit graph readback.", "seedSet": "bilal-port-variants", "seedSlug": "macro-view-grounded-intent", - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-project-graph-review-XEjX3W", + "cwd": "", "specId": 1, "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac", "prompt": "Brunch FE-809 project-graph proof. The selected spec is seeded from \"macro-view-grounded-intent\" and already has explicit intent-plane graph truth.\n\nUse read_graph in overview mode to inspect existing node codes. Then use present_review_set exactly once to propose a small exact review set derived from the existing macro-view intent graph.\n\nProposal constraints:\n- Create one or two new intent-plane requirement or criterion nodes.\n- Include at least one edge using category \"support\" with stance \"for\" or category \"realization\".\n- When referencing existing graph truth, use existingCode strings from read_graph output, never raw ids.\n- Use schemaVersion 1, lens \"intent\", epistemicStatus \"inferred\", non-empty grounding.summary, grounding.support, pitch.title, and pitch.narrative.\n- Do not call commit_graph.\n- Do not call request_review; stop after a successful present_review_set so the external Brunch RPC reviewer can approve it.", @@ -117,11 +117,10 @@ ], "friction": [], "artifacts": { - "runDir": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle", - "sessionJsonl": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl", - "transcriptMarkdown": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md", - "reportJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json", - "graphSnapshotJson": "/Users/lunelson/Code/hashintel/brunch-next/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json" + "runDir": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle", + "sessionJsonl": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/session.jsonl", + "transcriptMarkdown": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/transcript.md", + "reportJson": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/report.json", + "graphSnapshotJson": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json" } } - diff --git a/.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json b/.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json index 122035051..26c85cd5b 100644 --- a/.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json +++ b/.fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json @@ -6,7 +6,7 @@ "mission": "Prove the propose-graph strategy can commit graph truth through commit_graph.", "evaluationFocus": "A14-L structural legality for direct commitGraph batches.", "success": true, - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-propose-graph-commit-28lS9B", + "cwd": "", "specId": 1, "sessionId": "019e88ac-c864-7eb4-8ee0-85cd75c8dcc6", "prompt": "Brunch A14-L probe: the user has accepted the following concept-level proposal and asked you to persist it now.\n\nConcept: A Brunch specification workspace needs an explicit launch-readiness subgraph that records the launch goal, the rollback requirement, the operator visibility criterion, and the assumption that users can recover from a failed launch.\n\nUse the read_graph tool once in overview mode, then use commit_graph to persist a coherent intent-plane graph. Requirements for the commit_graph call:\n- create at least four intent-plane nodes\n- include at least one goal, one requirement, one criterion, and one assumption\n- create at least three edges connecting the nodes\n- use only legal edge categories from the tool guidance\n- include stance only on support or proof edges\n- avoid decision and term nodes for this proof so detail schemas are not needed\n\nIf commit_graph returns STRUCTURAL_ILLEGAL, read the diagnostics and retry once with a corrected complete batch. Stop after a successful commit_graph result.", @@ -49,9 +49,9 @@ ], "friction": [], "artifacts": { - "runDir": ".fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit", - "sessionJsonl": ".fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/session.jsonl", - "transcriptMarkdown": ".fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/transcript.md", - "reportJson": ".fixtures/runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json" + "runDir": "runs/propose-graph-commit/2026-06-02-propose-graph-commit", + "sessionJsonl": "runs/propose-graph-commit/2026-06-02-propose-graph-commit/session.jsonl", + "transcriptMarkdown": "runs/propose-graph-commit/2026-06-02-propose-graph-commit/transcript.md", + "reportJson": "runs/propose-graph-commit/2026-06-02-propose-graph-commit/report.json" } } diff --git a/.fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json b/.fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json index feab69715..bc4e146e5 100644 --- a/.fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json +++ b/.fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json @@ -7,7 +7,7 @@ "evaluationFocus": "A14-L ambiguity handling without unsupported graph overcommit.", "scenarioId": "ambiguity-no-overcommit", "success": true, - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-propose-graph-commit-qHP5ki", + "cwd": "", "specId": 1, "sessionId": "019e931d-0757-7c85-874a-186bffe8dea6", "prompt": "Brunch A14-L ambiguity/no-overcommit probe.\n\nThe user says: \"Maybe our launch process has some risk somewhere; please update the spec graph if that seems useful.\"\n\nUse read_graph once in overview mode. Because the prompt does not provide a concrete accepted graph fact, do not call commit_graph. Instead, respond with a concise clarification request or explanation that there is not enough accepted graph truth to commit yet.", @@ -33,9 +33,9 @@ "ambiguityOutcome": "no_op_or_clarification", "friction": [], "artifacts": { - "runDir": ".fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit", - "sessionJsonl": ".fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/session.jsonl", - "transcriptMarkdown": ".fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/transcript.md", - "reportJson": ".fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json" + "runDir": "runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit", + "sessionJsonl": "runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/session.jsonl", + "transcriptMarkdown": "runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/transcript.md", + "reportJson": "runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/report.json" } } diff --git a/.fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json b/.fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json index 3987b0e4e..5c892290e 100644 --- a/.fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json +++ b/.fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json @@ -7,7 +7,7 @@ "evaluationFocus": "A14-L selected-spec projected-code reference through the default runtime.", "scenarioId": "existing-code-ref", "success": true, - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-propose-graph-commit-7xu0IC", + "cwd": "", "specId": 1, "sessionId": "019e9317-2252-7279-9f8c-37344e811041", "prompt": "Brunch A14-L probe: the selected specification graph already contains a launch-readiness goal.\n\nUse read_graph once in overview mode. Find the projected code for the existing launch-readiness goal, then use commit_graph to create one new requirement node titled \"Rollback path is documented\" and one legal edge connecting that new requirement to the existing goal by using the existing node's projected code as {existingCode: \"G1\"}. Do not recreate the existing goal. Stop after a successful commit_graph result.", @@ -60,9 +60,9 @@ }, "friction": [], "artifacts": { - "runDir": ".fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref", - "sessionJsonl": ".fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/session.jsonl", - "transcriptMarkdown": ".fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/transcript.md", - "reportJson": ".fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json" + "runDir": "runs/propose-graph-commit/2026-06-04-existing-code-ref", + "sessionJsonl": "runs/propose-graph-commit/2026-06-04-existing-code-ref/session.jsonl", + "transcriptMarkdown": "runs/propose-graph-commit/2026-06-04-existing-code-ref/transcript.md", + "reportJson": "runs/propose-graph-commit/2026-06-04-existing-code-ref/report.json" } } diff --git a/.fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json b/.fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json index 9556d0e92..e9f840a89 100644 --- a/.fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json +++ b/.fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json @@ -7,7 +7,7 @@ "evaluationFocus": "A14-L retry behavior after structured commit_graph diagnostics.", "scenarioId": "retry-diagnostics", "success": true, - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-propose-graph-commit-3Q6x07", + "cwd": "", "specId": 1, "sessionId": "019e9319-d4d7-7bfc-838b-b4b8d3734888", "prompt": "Brunch A14-L retry diagnostics probe.\n\nUse read_graph once in overview mode. Then intentionally make exactly one structurally illegal commit_graph attempt by creating two intent-plane nodes and a proof edge between them without the required stance field. Read the STRUCTURAL_ILLEGAL diagnostics. Then retry once with a corrected complete batch that creates the same two nodes and a legal proof edge with stance \"for\". Stop after the corrected commit_graph succeeds.", @@ -72,9 +72,9 @@ }, "friction": [], "artifacts": { - "runDir": ".fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics", - "sessionJsonl": ".fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/session.jsonl", - "transcriptMarkdown": ".fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/transcript.md", - "reportJson": ".fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json" + "runDir": "runs/propose-graph-commit/2026-06-04-retry-diagnostics", + "sessionJsonl": "runs/propose-graph-commit/2026-06-04-retry-diagnostics/session.jsonl", + "transcriptMarkdown": "runs/propose-graph-commit/2026-06-04-retry-diagnostics/transcript.md", + "reportJson": "runs/propose-graph-commit/2026-06-04-retry-diagnostics/report.json" } } diff --git a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json index c90566da7..8e7d1549b 100644 --- a/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json +++ b/.fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json @@ -8,7 +8,7 @@ "maxTurnBudget": 3, "completedTurns": 3, "friction": [], - "cwd": "/var/folders/2c/ptn6jcrj61lck_yzfz_p3b5m0000gn/T/brunch-public-rpc-parity-Y7G3Y6", + "cwd": "", "specId": "spec-98433c35-3e61-4ab7-9c4f-72331e210aa2", "sessionId": "019e73ee-02c2-7d43-90e5-7de4cd6ed486", "toolCoverage": [ @@ -25,9 +25,9 @@ ], "transcriptDisplayRows": 6, "artifacts": { - "runDir": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity", - "sessionJsonl": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl", - "transcriptMarkdown": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md", - "reportJson": ".fixtures/runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json" + "runDir": "runs/public-rpc-parity/2026-05-29-public-rpc-parity", + "sessionJsonl": "runs/public-rpc-parity/2026-05-29-public-rpc-parity/session.jsonl", + "transcriptMarkdown": "runs/public-rpc-parity/2026-05-29-public-rpc-parity/transcript.md", + "reportJson": "runs/public-rpc-parity/2026-05-29-public-rpc-parity/report.json" } } diff --git a/.fixtures/seeds/bilal-port/_port-script.ts b/.fixtures/seeds/bilal-port/_port-script.ts index 8155637c0..d8249ee19 100644 --- a/.fixtures/seeds/bilal-port/_port-script.ts +++ b/.fixtures/seeds/bilal-port/_port-script.ts @@ -90,6 +90,7 @@ import { fileURLToPath } from 'node:url'; import { createDb } from '../../../src/db/connection.js'; import { CommandExecutor } from '../../../src/graph/command-executor.js'; import { seedFixture, type SeedFixture } from '../../../src/graph/seed-fixtures.js'; +import { dedupeSeedEdgesByPrecedence, type OriginTaggedEdge } from './duplicate-edge-policy.js'; // --------------------------------------------------------------------------- // Paths @@ -610,9 +611,11 @@ function portSpec(sourceName: string, slug: string, displayName: string): SpecPo }); } - // Phase 3: emit brunch edges - const brunchEdges: BrunchEdgeFixture[] = []; - const emittedEdgeKeys = new Set(); + // Phase 3: collect edge candidates, then resolve duplicates by precedence. + // Synthetic fill-in edges (the audit-check realization edges) are tagged + // `synthetic`; ported source edges are tagged `source`. On a key collision + // the source edge wins, so synthetic fill-ins never hide ported rationale. + const edgeCandidates: OriginTaggedEdge[] = []; const stats = { nodes_in: nodes.length, edges_in: edges.length, @@ -624,29 +627,22 @@ function portSpec(sourceName: string, slug: string, displayName: string): SpecPo edges_dropped_duplicate_after_collapse: 0, }; - const emitEdge = (edge: BrunchEdgeFixture): void => { - const key = `${edge.source_local_id}\0${edge.target_local_id}\0${edge.category}\0${edge.stance ?? ''}`; - if (emittedEdgeKeys.has(key)) { - stats.edges_dropped_duplicate_after_collapse++; - return; - } - emittedEdgeKeys.add(key); - brunchEdges.push(edge); - }; - // 3a. synthesize one realization edge per evidence node, from the audit check if (auditCheckLocalId !== null) { for (const node of nodes) { if (node.kind !== 'content' || node.semanticRole !== 'evidence') continue; const evidenceLocalId = bilalUuidToLocalId.get(node.id); if (evidenceLocalId === undefined) continue; - emitEdge({ - category: 'realization', - source_local_id: auditCheckLocalId, - target_local_id: evidenceLocalId, - stance: null, - basis: 'explicit', - rationale: null, + edgeCandidates.push({ + origin: 'synthetic', + edge: { + category: 'realization', + source_local_id: auditCheckLocalId, + target_local_id: evidenceLocalId, + stance: null, + basis: 'explicit', + rationale: null, + }, }); } } @@ -677,15 +673,22 @@ function portSpec(sourceName: string, slug: string, displayName: string): SpecPo stats.edges_absorbed++; continue; } - emitEdge({ - category: mapping.category, - source_local_id: sourceLocalId, - target_local_id: targetLocalId, - stance: mapping.stance, - basis: 'explicit', - rationale: edge.rationale, + edgeCandidates.push({ + origin: 'source', + edge: { + category: mapping.category, + source_local_id: sourceLocalId, + target_local_id: targetLocalId, + stance: mapping.stance, + basis: 'explicit', + rationale: edge.rationale, + }, }); } + + const deduped = dedupeSeedEdgesByPrecedence(edgeCandidates); + const brunchEdges = deduped.edges; + stats.edges_dropped_duplicate_after_collapse = deduped.duplicatesDropped; stats.edges_emitted = brunchEdges.length; return { slug, brunchNodes, brunchEdges, stats, bilalDisplayIdByLocalId }; diff --git a/.fixtures/seeds/bilal-port/duplicate-edge-policy.test.ts b/.fixtures/seeds/bilal-port/duplicate-edge-policy.test.ts new file mode 100644 index 000000000..549940e6d --- /dev/null +++ b/.fixtures/seeds/bilal-port/duplicate-edge-policy.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { + dedupeSeedEdgesByPrecedence, + seedEdgeKey, + type OriginTaggedEdge, + type SeedEdgeIdentity, +} from './duplicate-edge-policy.js'; + +interface TestEdge extends SeedEdgeIdentity { + readonly rationale: string | null; +} + +function source(edge: TestEdge): OriginTaggedEdge { + return { edge, origin: 'source' }; +} + +function synthetic(edge: TestEdge): OriginTaggedEdge { + return { edge, origin: 'synthetic' }; +} + +const supportFor = (rationale: string | null): TestEdge => ({ + category: 'support', + source_local_id: 1, + target_local_id: 2, + stance: 'for', + rationale, +}); + +describe('seed-port duplicate-edge precedence policy', () => { + it('keys edges by endpoint, category, and stance', () => { + expect(seedEdgeKey(supportFor('a'))).toBe(seedEdgeKey(supportFor('b'))); + expect(seedEdgeKey(supportFor('a'))).not.toBe( + seedEdgeKey({ ...supportFor('a'), stance: 'against' }), + ); + }); + + it('lets a ported source edge outrank a synthetic edge emitted first', () => { + const result = dedupeSeedEdgesByPrecedence([ + synthetic(supportFor(null)), + source(supportFor('ported rationale')), + ]); + + expect(result.duplicatesDropped).toBe(1); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.rationale).toBe('ported rationale'); + }); + + it('lets a ported source edge outrank a synthetic edge emitted later', () => { + const result = dedupeSeedEdgesByPrecedence([ + source(supportFor('ported rationale')), + synthetic(supportFor(null)), + ]); + + expect(result.duplicatesDropped).toBe(1); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.rationale).toBe('ported rationale'); + }); + + it('keeps the first edge when two source edges collide', () => { + const result = dedupeSeedEdgesByPrecedence([ + source(supportFor('first')), + source(supportFor('second')), + ]); + + expect(result.duplicatesDropped).toBe(1); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.rationale).toBe('first'); + }); + + it('keeps every edge when keys are distinct', () => { + const result = dedupeSeedEdgesByPrecedence([ + source(supportFor('a')), + source({ ...supportFor('b'), target_local_id: 3 }), + synthetic({ ...supportFor(null), category: 'realization', stance: null }), + ]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.edges).toHaveLength(3); + }); +}); diff --git a/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts b/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts new file mode 100644 index 000000000..41e0b22d9 --- /dev/null +++ b/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts @@ -0,0 +1,82 @@ +/** + * Duplicate-edge precedence policy for the Bilal seed port (throwaway + * data-prep, co-located with `_port-script.ts`; not product code). + * + * When the porter emits edges it can produce two edges that collapse to the + * same `(source_local_id, target_local_id, category, stance)` key — for + * example a ported source edge and a synthetic fill-in edge minted by the + * porter. Earlier the first-emitted edge won, which let a synthetic fill-in + * hide a ported source edge (and its authored rationale). + * + * This module names the contract explicitly: a ported `source` edge outranks + * a `synthetic` fill-in edge on a key collision, regardless of emission order. + * Equal-precedence collisions keep the first edge. Every dropped edge is + * counted so the porter can keep duplicate-drop stats visible. + */ + +export type SeedPortEdgeOrigin = 'source' | 'synthetic'; + +/** The fields that identify an edge for duplicate detection. */ +export interface SeedEdgeIdentity { + readonly category: string; + readonly source_local_id: number; + readonly target_local_id: number; + readonly stance: 'for' | 'against' | null; +} + +/** A candidate edge tagged with its origin for precedence resolution. */ +export interface OriginTaggedEdge { + readonly edge: E; + readonly origin: SeedPortEdgeOrigin; +} + +export interface DedupedSeedEdges { + readonly edges: E[]; + readonly duplicatesDropped: number; +} + +/** Stable duplicate key: endpoints, category, and stance. */ +export function seedEdgeKey(edge: SeedEdgeIdentity): string { + return `${edge.source_local_id}\0${edge.target_local_id}\0${edge.category}\0${edge.stance ?? ''}`; +} + +const ORIGIN_PRECEDENCE: Readonly> = { + source: 2, + synthetic: 1, +}; + +/** + * Dedupe candidate edges by precedence. Processes candidates in order; on a + * key collision keeps the higher-precedence origin (`source` over + * `synthetic`), replacing an already-kept lower-precedence edge in place when + * a higher-precedence candidate arrives later. Equal precedence keeps the + * first edge. Returns the surviving edges in first-seen order plus the number + * of dropped duplicates. + */ +export function dedupeSeedEdgesByPrecedence( + candidates: readonly OriginTaggedEdge[], +): DedupedSeedEdges { + const slotByKey = new Map(); + const edges: E[] = []; + const originByIndex: SeedPortEdgeOrigin[] = []; + let duplicatesDropped = 0; + + for (const candidate of candidates) { + const key = seedEdgeKey(candidate.edge); + const existingIndex = slotByKey.get(key); + if (existingIndex === undefined) { + slotByKey.set(key, edges.length); + edges.push(candidate.edge); + originByIndex.push(candidate.origin); + continue; + } + duplicatesDropped += 1; + const existingOrigin = originByIndex[existingIndex]!; + if (ORIGIN_PRECEDENCE[candidate.origin] > ORIGIN_PRECEDENCE[existingOrigin]) { + edges[existingIndex] = candidate.edge; + originByIndex[existingIndex] = candidate.origin; + } + } + + return { edges, duplicatesDropped }; +} diff --git a/.fixtures/seeds/edge-spread/README.md b/.fixtures/seeds/edge-spread/README.md new file mode 100644 index 000000000..f19930d77 --- /dev/null +++ b/.fixtures/seeds/edge-spread/README.md @@ -0,0 +1,13 @@ +# `.fixtures/seeds/edge-spread/` + +Hand-authored edge-category coverage fixture. + +Purpose: + +- exercise every D51-L edge category through the real seed loader +- keep a thesis-with-no-proof absence case available for future `IS_NOT` / omission renderers +- provide multiple directional exemplars so neighborhood/list projections can show incoming and outgoing relationships without depending on live curation + +Contents: + +- `category-directions.json` — one spec with explicit-basis nodes and paired edge-category exemplars diff --git a/.fixtures/seeds/edge-spread/category-directions.json b/.fixtures/seeds/edge-spread/category-directions.json new file mode 100644 index 000000000..3cfce16c4 --- /dev/null +++ b/.fixtures/seeds/edge-spread/category-directions.json @@ -0,0 +1,60 @@ +{ + "spec": { + "slug": "category-directions", + "name": "Category Directions", + "readiness_grade": "commitments_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "thesis", "title": "Unproven thesis exemplar", "basis": "explicit", "source": "fixture" }, + { "local_id": 2, "plane": "intent", "kind": "requirement", "title": "Outbound dependency source", "basis": "explicit", "source": "fixture" }, + { "local_id": 3, "plane": "intent", "kind": "assumption", "title": "Outbound dependency target", "basis": "explicit", "source": "fixture" }, + { "local_id": 4, "plane": "intent", "kind": "requirement", "title": "Inbound dependency target", "basis": "explicit", "source": "fixture" }, + { "local_id": 5, "plane": "intent", "kind": "context", "title": "Inbound dependency source", "basis": "explicit", "source": "fixture" }, + { "local_id": 6, "plane": "oracle", "kind": "evidence", "title": "Proof evidence for a goal", "basis": "explicit", "source": "fixture" }, + { "local_id": 7, "plane": "intent", "kind": "goal", "title": "Goal with supporting evidence", "basis": "explicit", "source": "fixture" }, + { "local_id": 8, "plane": "oracle", "kind": "check", "title": "Check that refutes a criterion", "basis": "explicit", "source": "fixture" }, + { "local_id": 9, "plane": "intent", "kind": "criterion", "title": "Criterion challenged by a check", "basis": "explicit", "source": "fixture" }, + { "local_id": 10, "plane": "intent", "kind": "context", "title": "Context that supports a requirement", "basis": "explicit", "source": "fixture" }, + { "local_id": 11, "plane": "intent", "kind": "requirement", "title": "Requirement with contextual support", "basis": "explicit", "source": "fixture" }, + { "local_id": 12, "plane": "intent", "kind": "example", "title": "Example that argues against a decision", "basis": "explicit", "source": "fixture" }, + { "local_id": 13, "plane": "intent", "kind": "decision", "title": "Decision questioned by an example", "basis": "explicit", "source": "fixture", "detail": { "chosen_option": "Lock one edge rendering style", "rejected": ["Keep raw ids in preview text"], "rationale": "Directional edge fixtures should render with projected codes only." } }, + { "local_id": 14, "plane": "design", "kind": "module", "title": "Neighborhood renderer module", "basis": "explicit", "source": "fixture" }, + { "local_id": 15, "plane": "intent", "kind": "requirement", "title": "Requirement realized by the module", "basis": "explicit", "source": "fixture" }, + { "local_id": 16, "plane": "design", "kind": "interface", "title": "Preview CLI interface", "basis": "explicit", "source": "fixture" }, + { "local_id": 17, "plane": "design", "kind": "module", "title": "Module that realizes the preview CLI", "basis": "explicit", "source": "fixture" }, + { "local_id": 18, "plane": "intent", "kind": "constraint", "title": "Constraint bounding the renderer", "basis": "explicit", "source": "fixture" }, + { "local_id": 19, "plane": "design", "kind": "module", "title": "Renderer bounded by the constraint", "basis": "explicit", "source": "fixture" }, + { "local_id": 20, "plane": "intent", "kind": "constraint", "title": "Constraint bounding a frontier", "basis": "explicit", "source": "fixture" }, + { "local_id": 21, "plane": "plan", "kind": "frontier", "title": "Frontier bounded by the constraint", "basis": "explicit", "source": "fixture" }, + { "local_id": 22, "plane": "plan", "kind": "milestone", "title": "Milestone composing a frontier", "basis": "explicit", "source": "fixture" }, + { "local_id": 23, "plane": "plan", "kind": "frontier", "title": "Frontier composed by a milestone", "basis": "explicit", "source": "fixture" }, + { "local_id": 24, "plane": "plan", "kind": "frontier", "title": "Frontier composing a slice", "basis": "explicit", "source": "fixture" }, + { "local_id": 25, "plane": "plan", "kind": "slice", "title": "Slice composed by a frontier", "basis": "explicit", "source": "fixture" }, + { "local_id": 26, "plane": "intent", "kind": "requirement", "title": "Superseded requirement predecessor", "basis": "explicit", "source": "fixture" }, + { "local_id": 27, "plane": "intent", "kind": "requirement", "title": "Superseding requirement successor", "basis": "explicit", "source": "fixture" }, + { "local_id": 28, "plane": "intent", "kind": "assumption", "title": "Superseded assumption predecessor", "basis": "explicit", "source": "fixture" }, + { "local_id": 29, "plane": "intent", "kind": "assumption", "title": "Superseding assumption successor", "basis": "explicit", "source": "fixture" }, + { "local_id": 30, "plane": "intent", "kind": "goal", "title": "Associated goal A", "basis": "explicit", "source": "fixture" }, + { "local_id": 31, "plane": "intent", "kind": "goal", "title": "Associated goal B", "basis": "explicit", "source": "fixture" }, + { "local_id": 32, "plane": "intent", "kind": "term", "title": "Associated term A", "basis": "explicit", "source": "fixture", "detail": { "definition": "A first associated term." } }, + { "local_id": 33, "plane": "intent", "kind": "term", "title": "Associated term B", "basis": "explicit", "source": "fixture", "detail": { "definition": "A second associated term." } } + ], + "edges": [ + { "category": "dependency", "source_local_id": 2, "target_local_id": 3, "basis": "explicit" }, + { "category": "dependency", "source_local_id": 5, "target_local_id": 4, "basis": "explicit" }, + { "category": "proof", "source_local_id": 6, "target_local_id": 7, "stance": "for", "basis": "explicit" }, + { "category": "proof", "source_local_id": 8, "target_local_id": 9, "stance": "against", "basis": "explicit" }, + { "category": "support", "source_local_id": 10, "target_local_id": 11, "stance": "for", "basis": "explicit" }, + { "category": "support", "source_local_id": 12, "target_local_id": 13, "stance": "against", "basis": "explicit" }, + { "category": "realization", "source_local_id": 14, "target_local_id": 15, "basis": "explicit" }, + { "category": "realization", "source_local_id": 17, "target_local_id": 16, "basis": "explicit" }, + { "category": "boundary", "source_local_id": 18, "target_local_id": 19, "basis": "explicit" }, + { "category": "boundary", "source_local_id": 20, "target_local_id": 21, "basis": "explicit" }, + { "category": "composition", "source_local_id": 22, "target_local_id": 23, "basis": "explicit" }, + { "category": "composition", "source_local_id": 24, "target_local_id": 25, "basis": "explicit" }, + { "category": "supersession", "source_local_id": 27, "target_local_id": 26, "basis": "explicit" }, + { "category": "supersession", "source_local_id": 29, "target_local_id": 28, "basis": "explicit" }, + { "category": "association", "source_local_id": 30, "target_local_id": 31, "basis": "explicit" }, + { "category": "association", "source_local_id": 32, "target_local_id": 33, "basis": "explicit" } + ] +} diff --git a/.fixtures/seeds/kind-band-spread/README.md b/.fixtures/seeds/kind-band-spread/README.md new file mode 100644 index 000000000..33d513b3d --- /dev/null +++ b/.fixtures/seeds/kind-band-spread/README.md @@ -0,0 +1,15 @@ +# `.fixtures/seeds/kind-band-spread/` + +Hand-authored coverage fixture for renderer development. + +Purpose: + +- provide one explicit-basis node of every currently shipped graph kind +- guarantee all three readiness bands appear in one deterministic seed +- give graph-slice renderers a compact, legal seed that is not tied to any one real spec archive + +Contents: + +- `coverage-matrix.json` — one spec whose nodes cover every intent/oracle/design/plan kind + +The fixture is intentionally small and hand-curated. It exists to exercise projection and rendering breadth, not to mirror a realistic spec narrative. diff --git a/.fixtures/seeds/kind-band-spread/coverage-matrix.json b/.fixtures/seeds/kind-band-spread/coverage-matrix.json new file mode 100644 index 000000000..2e38ec510 --- /dev/null +++ b/.fixtures/seeds/kind-band-spread/coverage-matrix.json @@ -0,0 +1,38 @@ +{ + "spec": { + "slug": "coverage-matrix", + "name": "Coverage Matrix", + "readiness_grade": "elicitation_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Anchor the product problem", "basis": "explicit", "source": "fixture" }, + { "local_id": 2, "plane": "intent", "kind": "thesis", "title": "A graph-native workspace can hold evolving specification truth", "basis": "explicit", "source": "fixture" }, + { "local_id": 3, "plane": "intent", "kind": "term", "title": "Workspace", "basis": "explicit", "source": "fixture", "detail": { "definition": "A cwd-scoped Brunch project root." } }, + { "local_id": 4, "plane": "intent", "kind": "context", "title": "The POC favors deterministic local fixtures over ambient state", "basis": "explicit", "source": "fixture" }, + { "local_id": 5, "plane": "intent", "kind": "assumption", "title": "Seed fixtures should stay small enough to eyeball", "basis": "explicit", "source": "fixture" }, + { "local_id": 6, "plane": "intent", "kind": "constraint", "title": "Preview harnesses must not bypass the command layer", "basis": "explicit", "source": "fixture" }, + { "local_id": 7, "plane": "intent", "kind": "requirement", "title": "Renderers should emit stable graph-node codes", "basis": "explicit", "source": "fixture" }, + { "local_id": 8, "plane": "intent", "kind": "criterion", "title": "A preview can be locked as a diffable golden file", "basis": "explicit", "source": "fixture" }, + { "local_id": 9, "plane": "intent", "kind": "decision", "title": "Golden files co-locate with renderer tests", "basis": "explicit", "source": "fixture", "detail": { "chosen_option": "Co-locate previews", "rejected": ["Put them under .fixtures"], "rationale": "Renderer changes should diff next to renderer tests." } }, + { "local_id": 10, "plane": "intent", "kind": "example", "title": "A neighborhood preview for R1 is human-reviewable", "basis": "explicit", "source": "fixture" }, + { "local_id": 11, "plane": "intent", "kind": "invariant", "title": "Rendered edges should not leak raw database ids", "basis": "explicit", "source": "fixture" }, + { "local_id": 12, "plane": "oracle", "kind": "validation_method", "title": "Seed fixture smoke test", "basis": "explicit", "source": "fixture" }, + { "local_id": 13, "plane": "oracle", "kind": "check", "title": "Verify every set loads through seedFixture", "basis": "explicit", "source": "fixture" }, + { "local_id": 14, "plane": "oracle", "kind": "evidence", "title": "Render preview writes a stable markdown file", "basis": "explicit", "source": "fixture" }, + { "local_id": 15, "plane": "oracle", "kind": "obligation", "title": "Keep preview artifacts readable in PR diffs", "basis": "explicit", "source": "fixture" }, + { "local_id": 16, "plane": "design", "kind": "module", "title": "Graph preview harness", "basis": "explicit", "source": "fixture" }, + { "local_id": 17, "plane": "design", "kind": "interface", "title": "render-preview CLI", "basis": "explicit", "source": "fixture" }, + { "local_id": 18, "plane": "plan", "kind": "milestone", "title": "Cross-cut render feedback loop", "basis": "explicit", "source": "fixture" }, + { "local_id": 19, "plane": "plan", "kind": "frontier", "title": "Preview harness slice", "basis": "explicit", "source": "fixture" }, + { "local_id": 20, "plane": "plan", "kind": "slice", "title": "Lock one neighborhood preview", "basis": "explicit", "source": "fixture" } + ], + "edges": [ + { "category": "support", "source_local_id": 1, "target_local_id": 7, "stance": "for", "basis": "explicit", "rationale": "The preview exists to advance renderer-facing product understanding." }, + { "category": "boundary", "source_local_id": 6, "target_local_id": 16, "basis": "explicit", "rationale": "The graph layer owns DB access; the preview harness consumes that seam." }, + { "category": "dependency", "source_local_id": 7, "target_local_id": 8, "basis": "explicit", "rationale": "Stable graph-node codes make useful golden assertions possible." }, + { "category": "realization", "source_local_id": 16, "target_local_id": 7, "basis": "explicit", "rationale": "The harness materializes the renderer-facing requirement." }, + { "category": "proof", "source_local_id": 14, "target_local_id": 8, "stance": "for", "basis": "explicit", "rationale": "A written preview file witnesses the golden-lock criterion." }, + { "category": "composition", "source_local_id": 18, "target_local_id": 19, "basis": "explicit" }, + { "category": "composition", "source_local_id": 19, "target_local_id": 20, "basis": "explicit" } + ] +} diff --git a/.fixtures/seeds/workspace-spread/README.md b/.fixtures/seeds/workspace-spread/README.md new file mode 100644 index 000000000..0ac58c30e --- /dev/null +++ b/.fixtures/seeds/workspace-spread/README.md @@ -0,0 +1,16 @@ +# `.fixtures/seeds/workspace-spread/` + +Hand-authored multi-spec workspace seeds. + +Purpose: + +- give workspace-level projections a deterministic two-spec inventory in one seeded database +- provide distinct readiness grades so specs-overview and future sessions-overview renderers can exercise grade contrast without live curation +- keep the graph fixtures explicit-basis and small enough to pair with deterministic session creation in tests or probes + +Contents: + +- `alpha-grounding.json` — early-grade spec with grounding-oriented graph truth +- `beta-commitments.json` — later-grade spec with commitment-heavy graph truth + +These are graph seeds, not session transcripts. Future session-overview harnesses can deterministically bind one or more sessions onto these two specs and vary turn counts without changing the graph seed contract. diff --git a/.fixtures/seeds/workspace-spread/alpha-grounding.json b/.fixtures/seeds/workspace-spread/alpha-grounding.json new file mode 100644 index 000000000..f939064b0 --- /dev/null +++ b/.fixtures/seeds/workspace-spread/alpha-grounding.json @@ -0,0 +1,17 @@ +{ + "spec": { + "slug": "alpha-grounding", + "name": "Alpha Grounding", + "readiness_grade": "grounding_onboarding" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Help a user orient inside one workspace", "basis": "explicit", "source": "fixture" }, + { "local_id": 2, "plane": "intent", "kind": "context", "title": "A workspace may hold multiple specs", "basis": "explicit", "source": "fixture" }, + { "local_id": 3, "plane": "intent", "kind": "constraint", "title": "Selection must stay scoped to the chosen spec", "basis": "explicit", "source": "fixture" }, + { "local_id": 4, "plane": "intent", "kind": "term", "title": "Selected spec", "basis": "explicit", "source": "fixture", "detail": { "definition": "The one spec whose graph and sessions a current interaction targets." } } + ], + "edges": [ + { "category": "support", "source_local_id": 2, "target_local_id": 1, "stance": "for", "basis": "explicit" }, + { "category": "boundary", "source_local_id": 3, "target_local_id": 1, "basis": "explicit" } + ] +} diff --git a/.fixtures/seeds/workspace-spread/beta-commitments.json b/.fixtures/seeds/workspace-spread/beta-commitments.json new file mode 100644 index 000000000..333c78d26 --- /dev/null +++ b/.fixtures/seeds/workspace-spread/beta-commitments.json @@ -0,0 +1,19 @@ +{ + "spec": { + "slug": "beta-commitments", + "name": "Beta Commitments", + "readiness_grade": "commitments_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "requirement", "title": "Workspace overviews should report node counts per spec", "basis": "explicit", "source": "fixture" }, + { "local_id": 2, "plane": "intent", "kind": "criterion", "title": "Specs overview should show grade contrast at a glance", "basis": "explicit", "source": "fixture" }, + { "local_id": 3, "plane": "intent", "kind": "decision", "title": "Keep workspace context rendering separate from graph slices", "basis": "explicit", "source": "fixture", "detail": { "chosen_option": "Separate workspace renderers", "rejected": ["Fold workspace context into graph overview"], "rationale": "Workspace context mixes spec/session inventory with graph summaries." } }, + { "local_id": 4, "plane": "design", "kind": "module", "title": "Workspace overview renderer", "basis": "explicit", "source": "fixture" }, + { "local_id": 5, "plane": "oracle", "kind": "check", "title": "Workspace inventory witness", "basis": "explicit", "source": "fixture" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 1, "target_local_id": 2, "basis": "explicit" }, + { "category": "realization", "source_local_id": 4, "target_local_id": 1, "basis": "explicit" }, + { "category": "proof", "source_local_id": 5, "target_local_id": 2, "stance": "for", "basis": "explicit" } + ] +} diff --git a/docs/architecture/pi-seam-extensions.md b/docs/architecture/pi-seam-extensions.md index 3afbc776c..56c0f4ac2 100644 --- a/docs/architecture/pi-seam-extensions.md +++ b/docs/architecture/pi-seam-extensions.md @@ -21,7 +21,7 @@ For each one this document records the pi seams it relies on, the Brunch-owned w ### Need -The PRD already commits to treating turns as snapshot-oriented reasoning units and surfacing external graph divergence at the next turn boundary via `worldUpdate`. Brunch also needs to be able to dispatch non-blocking inference work *during* a primary-agent turn — for example, an oracle-side analysis or a candidate-proposal expansion — whose result must reach the primary agent on a later turn without disturbing the active turn. +The PRD already commits to treating turns as stable-context reasoning units and surfacing external graph divergence at the next turn boundary via `worldUpdate`. Brunch also needs to be able to dispatch non-blocking inference work *during* a primary-agent turn — for example, an oracle-side analysis or a candidate-proposal expansion — whose result must reach the primary agent on a later turn without disturbing the active turn. ### Pi seams used @@ -200,28 +200,28 @@ The observer treats `captureHint` as priors, not authority. The user retains esc ### Need -The user (and the agent, on the user's behalf) should be able to refer to graph entities directly inside chat input using a `#` mention. Mentions must resolve to a stable graph identity, must persist in the transcript, and — crucially — must participate in Brunch's staleness-detection so that the agent does not silently reuse a now-stale snapshot of an entity that has been mutated since it was last read. +The user (and the agent, on the user's behalf) should be able to refer to graph entities directly inside chat input using a `#` mention. Mentions must resolve to a stable graph identity, must persist in the transcript, and — crucially — must participate in Brunch's staleness-detection so that the agent does not silently reuse a now-stale context read of an entity that has been mutated since it was last read. ### Pi seams used - `ctx.ui.addAutocompleteProvider((current) => ...)` over Pi's prompt editor. The autocomplete item's `value` is inserted into the editor; Pi does not persist hidden autocomplete metadata. - `before_agent_start` system-prompt injection for teaching the active agent how to interpret Brunch `#` handles and when to call a lookup/re-read tool. The inserted handle is just transcript text unless Brunch adds a later parser/indexer. -- Brunch custom transcript entries (`pi.appendEntry`, `pi.registerMessageRenderer`) for future mention ledger/staleness records and resolved entity snapshots; these are separate from the autocomplete insertion itself. +- Brunch custom transcript entries (`pi.appendEntry`, `pi.registerMessageRenderer`) for future mention ledger/staleness records and resolved entity reads; these are separate from the autocomplete insertion itself. - `prepareNextTurn` for injecting mention-staleness hints into the agent's next-turn context, alongside the existing `worldUpdate` flow. -- The reconciliation-need substrate and spec-local LSN (see §Reconciliation-need substrate and §Graph clock) for comparing the `{specId, lsn}` at which a mention was last *snapshotted into the model's working context* against the entity's current LSN. +- The reconciliation-need substrate and spec-local LSN (see §Reconciliation-need substrate and §Graph clock) for comparing the `{specId, lsn}` at which a mention was last read into the model's working context against the entity's current LSN. ### Brunch-owned work - A `#` autocomplete provider sourced from `SpecRegistry` + current spec's graph index. It may search current titles and descriptions, but the inserted `value` must be a stable handle such as `#A12` or `#`; popup `label`/`description` are UI-only and are not session metadata. - A Brunch mention indexer that parses user/assistant text for stable `#` handles after input and resolves them to `{ specId, id: NodeId, title_at_mention: string, lsn_at_mention: number }` for the session mention ledger. This parsing/indexing step, not Pi autocomplete, is what creates structured mention state. - A graph lookup/re-read tool (for example `brunch.entity_reread`) whose prompt guidance tells the agent to resolve `#A12` by passing the handle without the `#` when deeper entity detail matters. -- A `SessionMentionLedger` in the session-scoped state: for each `{specId, id}` ever mentioned in this session, the highest `snapshotted_lsn` — i.e. the spec-local LSN at which the agent most recently received the full entity payload (either via initial context, a `worldUpdate` cascade, or an explicit re-read tool call). The ledger persists with the session and survives compaction. +- A `SessionMentionLedger` in the session-scoped state: for each `{specId, id}` ever mentioned in this session, the highest `context_lsn` — i.e. the spec-local LSN at which the agent most recently received the full entity payload (either via initial context, a `worldUpdate` cascade, or an explicit re-read tool call). The ledger persists with the session and survives compaction. - A staleness check executed during `prepareNextTurn`: 1. Walk the session's `SessionMentionLedger`. - 2. For every entry where the entity's current `{specId, lsn}` is newer than `snapshotted_lsn` for that same spec, the entity is **stale-in-context** for this session. - 3. Brunch synthesizes a `brunch.mention_staleness_hint` entry (custom message, `deliverAs: "nextTurn"`) summarising the stale set. The hint is **discretionary advice to the agent**, not a forced re-read: it tells the agent "if you intend to reason over `#foo` again, re-read it; the snapshot you have is from spec-local LSN 412, current is LSN 487." - 4. The agent decides whether to invoke a re-read tool (which then updates `snapshotted_lsn`) or to proceed with the existing snapshot, accepting the staleness. -- A `brunch.entity_reread` command/tool (through the shared command layer) that re-snapshots a named entity and updates `snapshotted_lsn` to the LSN observed at re-read. + 2. For every entry where the entity's current `{specId, lsn}` is newer than `context_lsn` for that same spec, the entity is **stale-in-context** for this session. + 3. Brunch synthesizes a `brunch.mention_staleness_hint` entry (custom message, `deliverAs: "nextTurn"`) summarising the stale set. The hint is **discretionary advice to the agent**, not a forced re-read: it tells the agent "if you intend to reason over `#foo` again, re-read it; the context you have is from spec-local LSN 412, current is LSN 487." + 4. The agent decides whether to invoke a re-read tool (which then updates `context_lsn`) or to proceed with the existing context, accepting the staleness. +- A `brunch.entity_reread` command/tool (through the shared command layer) that re-reads a named entity and updates `context_lsn` to the LSN observed at re-read. ### Posture diff --git a/docs/architecture/pi-ui-extension-patterns.md b/docs/architecture/pi-ui-extension-patterns.md index 234fde900..d76be409c 100644 --- a/docs/architecture/pi-ui-extension-patterns.md +++ b/docs/architecture/pi-ui-extension-patterns.md @@ -21,7 +21,7 @@ This memo records evidence for the `pi-ui-extension-patterns` frontier. It is in - **Pi version/source:** `pi --version` reports `0.75.4`; audited installed docs under `npm-mariozechner-pi-coding-agent/0.73.1` whose package version is `0.75.4`, plus source at `~/Clones/earendil-works/pi/packages/coding-agent`. - **Source audit oracle:** `src/core/slash-commands.ts`, `src/modes/interactive/interactive-mode.ts`, `src/core/agent-session.ts`, `src/core/extensions/runner.ts`, `docs/extensions.md`, `docs/rpc.md`, and `docs/keybindings.md`. - **Raw Pi harness oracle:** a temporary project-local Pi extension was loaded with `pi --mode rpc --no-session -e ...`, then deleted after probing. This proves extension command handling, `input` handling, lifecycle cancellation, and RPC-visible `setStatus` / string `setWidget` events. It does **not** prove interactive autocomplete visual behavior. -- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/tui-client/pi-extension-shell.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state snapshot. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. `src/probes/scripts/verify-startup-no-resume.sh` now supplies the Brunch-host branded startup pty oracle: the captured startup screen contains the compact Brunch wordmark, version/Pi line, selection copy, and no stale transcript before activation. +- **Brunch-host oracle:** FE-744 now exposes a thin internal extension entrypoint at `src/tui-client/pi-extension-shell.ts`, with product modules for chrome (`src/tui-client/.pi/extensions/chrome.ts`), session-lifecycle binding (`session-lifecycle.ts`), command policy (`command-policy.ts`), the spec/session picker (`workspace-dialog.ts` plus private `src/tui-client/.pi/components/workspace-dialog/*` compatibility paths), operational-mode policy (`operational-mode.ts`), fixture-backed mention autocomplete (`mention-autocomplete.ts`), and alternatives cards (`alternatives.ts`). Tests prove one Brunch-owned wrapper drives `setHeader`, owns a live TUI footer compositor over product facts plus Pi footer telemetry, filters out a chrome-owned status key while rendering foreign status entries, publishes diagnostic `setWidget` content, and sets the terminal title from one product-state value. Existing branch-cancellation coverage still protects `I19-L`; spec/session picker tests prove decision UI remains separate from coordinator activation and runs as the same centered overlay component at startup and in-session. `src/probes/scripts/verify-startup-no-resume.sh` now supplies the Brunch-host branded startup pty oracle: the captured startup screen contains the compact Brunch wordmark, version/Pi line, selection copy, and no stale transcript before activation. - **Raw TUI visual oracle:** a temporary extension loaded with `script -q /tmp/brunch-chrome-tui-proof.typescript /bin/bash -lc "pi --no-session -e "`; the transcript contained `BRUNCH HEADER PROOF`, `BRUNCH FOOTER PROOF`, `Spec: Proof Spec`, `observer: running`, and `lens: problem-framing`, proving header/footer/widget text is actually visible in a live Pi TUI render. The temp extension was deleted after the run. - **Raw RPC chrome oracle:** a temporary extension loaded with `pi --mode rpc --no-session -e ` emitted `extension_ui_request` events for `setStatus`, `setWidget`, and `notify`; header/footer/working-indicator calls produced no RPC events as expected from Pi's RPC implementation. The temp extension was deleted after the run. - **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. @@ -132,7 +132,7 @@ The Brunch extension entrypoint is intentionally a registration map. `src/tui-cl - `operational-mode.ts` owns the current `elicit` read-only tool policy pending transcript-backed runtime state. - `mention-autocomplete.ts` owns fixture-backed `#` mention autocomplete. - `alternatives.ts` owns the transcript-persistent alternatives/card primitive, using reusable widgets from `src/tui-client/.pi/components/*`. -- `structured-exchange/` owns the remodeled present/request structured-exchange tool family; the active registry currently exposes `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`, while review/candidate modules are stubs until their product flows land. +- `exchanges/` owns the remodeled present/request structured-exchange tool family; the active registry currently exposes `present_question`, `present_options`, `request_answer`, `request_choice`, and `request_choices`, while review/candidate modules are stubs until their product flows land. `renderBrunchChrome(ctx.ui, state)` is the product-named wrapper downstream affordances should call instead of scattering raw Pi UI calls. The current code renders only facts present in `BrunchChromeState`: @@ -141,15 +141,15 @@ The Brunch extension entrypoint is intentionally a registration map. `src/tui-cl - widget: cwd, spec, session, runtime, context, and chat-mode diagnostics; - title: compact Brunch-owned terminal title derived from activated workspace state. -The wrapper uses the shared compact Brunch wordmark plus plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; widget/title provide deterministic state strings for tests and RPC-compatible clients. `ctx.ui.setStatus(key, text)` remains available as a lateral contribution channel for other extensions and future dynamic Brunch state; the chrome wrapper does not publish a `brunch.chrome` status key and filters that key if a stale producer contributes it. The wrapper deliberately does not fabricate build version, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product snapshot, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide a fresh product snapshot; the wrapper does not own durable state. +The wrapper uses the shared compact Brunch wordmark plus plain, narrow-terminal-safe text/glyphs (`brunch`, `·`) and does not depend on Pi branding/footer text as the primary product surface. Header/footer rendering is TUI-only; widget/title provide deterministic state strings for tests and RPC-compatible clients. `ctx.ui.setStatus(key, text)` remains available as a lateral contribution channel for other extensions and future dynamic Brunch state; the chrome wrapper does not publish a `brunch.chrome` status key and filters that key if a stale producer contributes it. The wrapper deliberately does not fabricate build version, worker state, coherence verdicts, establishment offers, or a working-indicator abstraction until those producers exist. `session_start` reconstructs chrome from the supplied product state, and replacement-session binding still runs through the existing session-lifecycle hooks before rendering. Reload/session replacement therefore requires callers to provide fresh product state; the wrapper does not own durable state. Observed behavior: | Scenario | Result | Evidence | | --- | --- | --- | -| Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one snapshot; tests assert the same formatter output used by the wrapper. | `src/app/brunch-tui.test.ts` | -| `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with a fresh Brunch snapshot. | source/API behavior; wrapper is stateless by design | -| Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace snapshot. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/app/brunch-tui.test.ts` | +| Idle TUI mount | Header, footer, status, diagnostic widget, and title are called from one product-state value; tests assert the same formatter output used by the wrapper. | `src/app/brunch-tui.test.ts` | +| `/reload` / extension reload | Chrome is not durable inside Pi UI; reload must rerun extension setup and call `renderBrunchChrome` with fresh Brunch state. | source/API behavior; wrapper is stateless by design | +| Session replacement / selected-session reopen | Existing Brunch extension calls the session-lifecycle binding hook on `session_start`, `before_agent_start`, and assistant `message_start`; `session_start` then renders chrome for the supplied workspace state. The `/brunch` settings-switcher action activates decisions through the coordinator, calls `ctx.switchSession()`, and renders fresh chrome/notification only through `withSession` replacement context. | `src/app/brunch-tui.test.ts` | | RPC degradation | `setStatus`, string-array `setWidget`, `setTitle`, and `notify` emit RPC `extension_ui_request` events; `setHeader`, `setFooter`, and `setWorkingIndicator` are RPC no-ops. Brunch chrome currently uses TUI-only header/footer plus diagnostic widget/title; fixture drivers should not assert TUI-only header/footer or a chrome-owned status key. | Pi RPC source + temp RPC JSONL probe | ## Startup/splash logo asset decision diff --git a/docs/architecture/pi-wrapper-comparative.md b/docs/architecture/pi-wrapper-comparative.md index 0e8e8d44d..e3d86f84e 100644 --- a/docs/architecture/pi-wrapper-comparative.md +++ b/docs/architecture/pi-wrapper-comparative.md @@ -56,7 +56,7 @@ modes (same Node process for tui/print, separate processes for rpc/web sidecar): read-only JSON-RPC handlers serves the React+Vite SPA from dist-web print - snapshot render of workspace state, no agent loop + state render of workspace state, no agent loop stores: Pi JSONL transcript (.brunch/sessions/*.jsonl) diff --git a/docs/architecture/prd.md b/docs/architecture/prd.md index 5b92c1bde..cb45c4f8f 100644 --- a/docs/architecture/prd.md +++ b/docs/architecture/prd.md @@ -371,7 +371,7 @@ The POC should plan for at least one Brunch-specific custom message role: - `worldUpdate` - injected between turns when relevant graph state changed outside the current session -Later roles may include graph snapshot summaries, repair suggestions, or review artifacts, but `worldUpdate` is the essential proof point. +Later roles may include graph context summaries, repair suggestions, or review artifacts, but `worldUpdate` is the essential proof point. ### Tool model @@ -416,7 +416,7 @@ The React client should treat the WebSocket RPC channel as the primary data plan The web app needs three client primitives over one connection: 1. Query - one-shot request/response for metadata and occasional reads. -2. Subscription - initial snapshot plus pushed updates for graph views and session state. +2. Subscription - initial state payload plus pushed updates for graph views and session state. 3. Mutation - writes that update shared caches and surface structured conflicts. ### Recommended stack @@ -479,11 +479,11 @@ Before each model call, Brunch should run a `prepareNextTurn`-style check that: 3. reads the current coherence state 4. appends a `worldUpdate` custom message when the session needs to know about divergence -Brunch should treat a turn as a snapshot-oriented reasoning unit. Mid-turn external changes should normally surface on the next turn, not mutate the current turn's world under the agent. +Brunch should treat a turn as a stable-context reasoning unit. Mid-turn external changes should normally surface on the next turn, not mutate the current turn's world under the agent. ### Within-turn consistency -A turn should reason over a stable snapshot. If relevant external writes land during the turn, the default POC stance should be accept-and-flag at the boundary rather than live mid-turn interruption: surface the divergence on the next turn, and let coherence state reflect the disturbance. +A turn should reason over a stable context. If relevant external writes land during the turn, the default POC stance should be accept-and-flag at the boundary rather than live mid-turn interruption: surface the divergence on the next turn, and let coherence state reflect the disturbance. ### Compaction must carry the coherence anchor diff --git a/docs/design/GRAPH_MODEL.md b/docs/design/GRAPH_MODEL.md index 354b5d9e2..3cc62e26f 100644 --- a/docs/design/GRAPH_MODEL.md +++ b/docs/design/GRAPH_MODEL.md @@ -8,7 +8,7 @@ prior "large semantic edge-type catalogue + relation-policy registry" direction and the deferred `framing_as` modality. It is also the source of truth for the type definitions under [`src/graph/`](../../src/graph/) and the per-category policy table -consumed by snapshot/projection builders and the `CommandExecutor`. +consumed by query/projection builders and the `CommandExecutor`. `memory/SPEC.md` and `memory/PLAN.md` are reconciled to this doc; if later planning text drifts, treat this document as the canonical @@ -21,8 +21,8 @@ graph-model contract. kind categories, `source` field, `provenance` retirement. Locked. - **Current lock:** stable node reference codes, `basis` as approval strength (`explicit | implicit`), non-exclusive - readiness bands, supersession acyclicity, and snapshot - graph-truth vs active-context separation. Locked. + readiness bands, supersession acyclicity, and graph-context + read separation. Locked. ## Scope and posture @@ -33,7 +33,7 @@ proposals, and reviewer findings. Two pressures follow from that: 1. **Authoring burden must be low.** The agent should not be asked to choose among many named relation kinds, each with its own tuple-specific legality and its own projection policy. -2. **Interpretation burden at snapshot time must be low.** Context +2. **Interpretation burden at read/render time must be low.** Context builders should derive dependency/dependent/support/realization buckets from the stored edge's category and endpoint roles, not from a per-relation policy registry. @@ -190,7 +190,7 @@ Notes on the categories most likely to be confused: ## Per-category policy The category drives all policy. The `CommandExecutor` enforces -structural legality at write time; snapshot/projection builders use +structural legality at write time; query/projection builders use this table to bucket edges; coherence triggers use the cascade column. @@ -215,7 +215,7 @@ Legend: - **criteria-help** — used by the interviewer to suggest criteria for the target node ("requirement with no `proof` incoming → suggest criterion"). -- **projection effect** — how snapshot/neighborhood builders treat +- **projection effect** — how query/neighborhood builders treat the edge in active-context views. Only `dependency` triggers automatic cascades. Other categories @@ -298,7 +298,7 @@ deferred. ## Tuple-label lookup Tuple-label lookup is a presentation concern only. It produces -plain-language phrasing for graph snapshots, UI, and prompt +plain-language phrasing for graph context, UI, and prompt context. It does not change category policy; it only renders the stored edge readably from one endpoint's perspective. @@ -340,10 +340,10 @@ more realization sub-clusters that demand distinct cascade or projection policy, split `realization` into siblings (see [§Open questions](#open-questions)). -## Snapshot projections and bucketing +## Context projections and bucketing -Snapshot buckets come from category and endpoint role, not from the -derived label string. Snapshot callers must also choose which +Context buckets come from category and endpoint role, not from the +derived label string. Callers must also choose which projection they want: - **`graph_truth`** — accepted graph truth records. Superseded @@ -351,7 +351,7 @@ projection they want: part of auditably accepted graph state. - **`active_context`** — the context the agent/user should treat as current. Superseded predecessor nodes are hidden, and edges whose - endpoints are hidden are also omitted so active-context snapshots + endpoints are hidden are also omitted so active-context reads never contain dangling references. The read family should stay product-shaped and close to observed @@ -371,7 +371,7 @@ relatedNodes({ overview({ projection: "graph_truth" | "active_context" }) ``` -A neighborhood snapshot of an intent node: +A rendered neighborhood context of an intent node: ```text anchor: R1 : requirement @@ -418,9 +418,9 @@ supersedes: edges live outside graph truth (preface / capture analysis / review-set drafts) until accepted. - Tuple-label lookup cannot change category policy. -- Snapshot bucket assignment comes from category and endpoint role, +- Context bucket assignment comes from category and endpoint role, not from label strings. -- Active-context snapshots omit superseded nodes and any edge whose +- Active-context reads omit superseded nodes and any edge whose endpoint is omitted. - `composition` does not imply sequencing or dependency. - `support` does not imply blocking / staleness by default. @@ -641,7 +641,7 @@ type NodeBasis = GraphBasis - **`kind`** — per-plane closed enum. Structurally validated by the `CommandExecutor`. See [§Per-plane node kinds](#per-plane-node-kinds). - **`title`** — required, non-empty. The human-readable name of the - node. Used for mentions, snapshot display, and search. + node. Used for mentions, context display, and search. - **`body`** — optional markdown content. Carries the semantic detail the agent authored. Most kinds put their primary content here. - **`basis`** — item-level approval strength: `explicit` or @@ -649,8 +649,8 @@ type NodeBasis = GraphBasis - **`source`** — free-form string for epistemic attribution. Convention by prompt (e.g. "stakeholder", "regulatory", "derived", "domain expert", "market research", "agent synthesis"), not - structural validation. Exists for context-snapshot enrichment — - it will be transformed back into sparse text in prompt snapshots, + structural validation. Exists for context-render enrichment — + it will be transformed back into sparse text in prompt context, not used for policy or filtering. - **`detail`** — optional JSON object with per-kind validated sub-structure. See [§Per-kind detail schemas](#per-kind-detail-schemas). @@ -687,7 +687,7 @@ Allocation rules: 4. DB constraints enforce `unique(spec_id, plane, kind, kind_ordinal)`. There is no `code` column and no `unique(spec_id, code)` database constraint. -5. Snapshots and prompts render projected codes as primary handles. +5. Context renders and prompts use projected codes as primary handles. Raw IDs may appear in diagnostics, but product/agent references should use projected codes. @@ -764,11 +764,11 @@ for what kind of material the node captures. Metadata is a pure function of `(plane, kind)`. It is not stored as a nested object on each node. Readiness-band membership is consumed by -snapshot / prompt filters; reference-code labels are consumed by +context / prompt filters; reference-code labels are consumed by presentation code that combines the label with stored `kindOrdinal`. Readiness bands are **non-exclusive**. They guide elicitor goals, -snapshot filters, and grade-advancement rubrics; they do not make any +context filters, and grade-advancement rubrics; they do not make any node kind illegal at earlier grades. If the user clearly states a requirement or criterion during grounding, capture it as graph truth with the right `basis`; it simply does not by itself prove the diff --git a/docs/design/SPEC_INITIATIVE_MODEL.md b/docs/design/SPEC_INITIATIVE_MODEL.md index 39f81335a..4cbf21f0a 100644 --- a/docs/design/SPEC_INITIATIVE_MODEL.md +++ b/docs/design/SPEC_INITIATIVE_MODEL.md @@ -463,11 +463,11 @@ These are the next questions that still need real design work. Plausible options: -- full entity snapshots +- full entity state replacements - patch operations - append-only events -Recommendation: favor changesets or patch-like operations that can be materialized into current state rather than only whole-entity snapshots. +Recommendation: favor changesets or patch-like operations that can be materialized into current state rather than only whole-entity state replacements. ### 2. Which claim kinds deserve first-class status? diff --git a/docs/praxis/ln-skills.md b/docs/praxis/ln-skills.md index 705dee57a..af15f017d 100644 --- a/docs/praxis/ln-skills.md +++ b/docs/praxis/ln-skills.md @@ -73,6 +73,10 @@ Earned posture is not a license for sprawl — guardrails (one named seam, named Regression earned → proving is a state transition, not a third mode: downgrade the frontier or slice, reshape as a tracer, route back through `ln-plan` if the frontier itself splits. +#### Coverage frontiers (a frontier shape, not a posture) + +Posture ranks the next *vertical* slice; it has no completeness test, so vertical tracers can leave a horizontal capability layer permanently shallow while every slice is "done." A **coverage frontier** closes that gap with a layer-level **aggregate DoD** — "no required row in a closed enumerated inventory is left open" — while each row still builds under `proving` or `earned`. It is therefore a different frontier *shape*, not a third posture, and it does not relax the anti-sprawl norm: it fires only over a **closed, enumerated** surface (named load-bearing layer, up-front inventory, required-vs-deferred marking). `ln-plan` recognizes and bounds it; the row ledger lives in a `Mode: coverage` scope file under `memory/cards/` (authored via `ln-scope`); `ln-build` closes rows. The shape is young: do not promote it to a canonical posture or doc type before rule-of-three. + #### Posture distribution across skills `ln-plan`, `ln-design`, `ln-scope`, and `ln-consult` all carry posture-dependent sequencing pressure. `ln-plan` reads posture and loads the matching reference; `ln-scope` inherits posture from the containing frontier and applies the matching posture check. `ln-refactor` owns closure as safe mechanics (when an earned frontier is principally restructuring); `ln-sync` owns closure as canonical garbage collection (when artifacts the planner is already done with need cleanup). @@ -142,6 +146,7 @@ There is currently no project-local `ln-map` skill in `.agents/skills/`. If you | “Which interpretation is intended?” | `ln-disambiguate` | | “What should the canonical truth say?” | `ln-spec` | | “What work items should exist?” | `ln-plan` | +| “Is a whole capability layer going shallow under vertical slicing?” | `ln-plan` (coverage frontier) | | “What is the smallest buildable slice?” | `ln-scope` | | “Which module/API shape should we choose?” | `ln-design` | | “How will we know this works?” | `ln-oracles` | diff --git a/docs/praxis/manual-testing.md b/docs/praxis/manual-testing.md index d22fc6368..cb8fb5593 100644 --- a/docs/praxis/manual-testing.md +++ b/docs/praxis/manual-testing.md @@ -5,7 +5,7 @@ Outer-loop verification for slices that touch the user-facing boundary. Manual t ## Setup 1. **Dev server**: use `/cli-cmux` to open a terminal pane, run `npm run dev` there. Do NOT use cmux for browser panes. -2. **Browser**: use `/cli-cdp` to launch Chrome with DevTools Protocol, open the dev URL, and interact (snapshot, fill, click, eval, console). +2. **Browser**: use `/cli-cdp` to launch Chrome with DevTools Protocol, open the dev URL, and interact (inspect, fill, click, eval, console). This keeps the dev server and browser observable without leaving the agent session. @@ -88,7 +88,7 @@ This keeps golden fixtures runtime-shaped without hand-authoring JSON or redoing ## Recommended walkthrough seeds -Prefer the richer `issue-tracker-*` fixtures for manual walkthroughs. They now cover the main phase-transition states explicitly instead of relying on ambiguous mid-stream snapshots. +Prefer the richer `issue-tracker-*` fixtures for manual walkthroughs. They now cover the main phase-transition states explicitly instead of relying on ambiguous mid-stream states. - `issue-tracker-kickoff-ready` — empty grounding-entry workspace and resume from a seeded blank project - `issue-tracker-grounding-closure-pending` — closure proposal visible and awaiting explicit confirmation diff --git a/docs/testing/seeded-dev-rpc.md b/docs/testing/seeded-dev-rpc.md index 75bf6b814..807e41817 100644 --- a/docs/testing/seeded-dev-rpc.md +++ b/docs/testing/seeded-dev-rpc.md @@ -192,7 +192,7 @@ A successful run writes: ├── session.jsonl ├── transcript.md ├── report.json -└── graph-snapshot.json +└── graph-overview.json ``` The existing reference run is `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/`. Its report shows 70 explicit base nodes plus implicit product-created nodes/edges from one real `commit_graph` tool call. diff --git a/memory/CROSS_CUT_PLAN.md b/memory/CROSS_CUT_PLAN.md new file mode 100644 index 000000000..1f9545c24 --- /dev/null +++ b/memory/CROSS_CUT_PLAN.md @@ -0,0 +1,334 @@ + + +# Cross-Cut Plan — Elicitor Capability Surface + +## Why this doc exists + +The PLAN sequences **vertical** frontiers — each a tracer bullet that proves one +architectural claim end-to-end. That structure can leave **horizontal capability +layers too shallow**: every frontier dips into "the agent's tools and knowledge" +only as far as its claim requires, so no single frontier ever *fills the layer*. + +This doc cuts the other way. It treats the active agent's (elicitor's) **capability +surface** as three horizontal seams and drives each to completeness, independent of +which vertical frontier first touched it. + +```diagram + PLAN frontiers (vertical: prove one claim end-to-end) + review-cycle │ authority-shell │ live-ship │ probes │ seed-fixtures + ─────────────┼─────────────────┼───────────┼────────┼────────────── + READ context │ ╎ ╎ ╎ ╎ ╎ + WRITE mutate │ ╎ each frontier touches each seam only shallowly ╎ + KNOW prompts │ ╎ ╎ ╎ ╎ ╎ +``` + +The plan is **broadly locked**; the open items are design questions, not the cut +itself. + +## Current authority split + +- `memory/PLAN.md` owns frontier ids, sequencing, dependency judgment, and which work is active next. +- This file owns only the temporary elicitor READ / WRITE / KNOW row inventory and its aggregate coverage DoD. +- When one row escapes row-sized work, it gets promoted back into PLAN. As of 2026-06-08, the D65-L row is now the active PLAN frontier `elicitation-backlog`; the remaining prompt-resource body-depth pass stays temporary cross-cut work. + +## The seams (locked) + +The elicitor's capability surface = **READ × WRITE × KNOW**, where KNOW splits into +an *orienting* sub-layer and a *procedural* sub-layer (this matches the existing +`src/.pi/skills/` split): + +| Seam | What it is | Code home | +| --- | --- | --- | +| **1 — READ** | tools/context for *getting* state (workspace, graph, session) | `.pi/extensions/{graph,context}/`, `.pi/agents/contexts/`, D60-L | +| **2 — WRITE** | tools for *mutating* world (workspace, graph, session/runtime state) | `.pi/extensions/{graph,runtime,workspace}/`, `graph/CommandExecutor` | +| **3a — KNOW / orient** | goals, strategies, lenses — *what to pursue, how to shape, what to focus on* | `.pi/skills/{goals,strategies,lenses}/`, D59-L/D25-L | +| **3b — KNOW / mechanics** | methods — *how to run exchanges, capture, commit, propose* | `.pi/skills/methods/`, D58-L | + +## Seam coverage ledger (the governing DoD) + +This inventory is **not** an orienting note — it is the **layer-level definition-of-done** +for the cross-cut. The ln-* tracer protocol is risk-first ("does this slice prove a +claim?") and has no completeness test; that is exactly why vertical slicing leaves these +layers shallow. This ledger supplies the missing **coverage-first DoD**: a seam is done +when no POC-required (●) row is left in a `spec` / `new` / `partial` state. + +The ledger is also the **anti-sprawl boundary**: "fill the layer" means *close these +specifically-enumerated rows*, never "do everything that rhymes" (cf. global AGENTS.md +§completionist sprawl). Coverage-mode is only safe because the surface is a closed list. +Most product layers should stay tracer-shallow (correct YAGNI); this layer earns a +completeness pass because the elicitor's value *is* its capability surface. + +Column shape follows the now-canonical `ln-scope` coverage-ledger +(`Capability | Status | Req | Fill | Owner/next | Notes`). + +**Status:** `have` (in code) · `partial` (exists, incomplete vs target) · `spec` +(designed in SPEC, not built) · `new` (beyond SPEC, needs a decision) · `built` (closed +this push). **Req:** ● required for POC · ○ deferred/post-POC. **Fill:** the posture each +row's build inherits — `proving` (row still carries an unknown) · `earned` (settled design, +just unbuilt) · — (already `have`/`built`, no build). + +### Seam 1 — READ / context + +DoD: every ● row is `have` or `built`. + +| Capability | Status | Req | Fill | Owner / next | Notes | +| --- | --- | --- | --- | --- | --- | +| `read_graph` overview \| neighborhood | have | ● | — | — | baseline | +| generic `read`/`grep`/`find`/`ls` | have | ● | — | — | read-only in `elicit` | +| graph slice — list by kind(s) | built | ● | — | done — `read_graph` `list_by_kind` (67e986b8) | D60-L | +| graph slice — list by readiness band(s) | built | ● | — | done — `read_graph` `list_by_band` (67e986b8) | D60-L, D64-L | +| graph slice — find related-to-anchor (edge cat/dir/hops) | built | ● | — | done — `read_graph` `related` (62971be7) | D60-L, D51-L | +| graph slice — IS_NOT / absence queries | built | ● | — | done — `read_graph` `gaps` mode (79f92bc5) | D60-L 4th shape; serves D65-L backlog | +| workspace context — tree + file counts (gitignore-aware) | built | ● | — | done — `read_workspace_context` `cwd_inventory` (54ae7f86) | D60-L `cwd`; stub replaced | +| workspace context — specs overview (title, #sessions, #nodes) | built | ● | — | done — `read_workspace_context` `workspace_overview` (3642b777) | D60-L; fork resolved → agent-context read | +| workspace context — sessions overview (turn count, grade) | built | ● | — | done — `workspace_overview` (3642b777) | D60-L | +| session context read — binding + runtime frame | built | ● | — | done — `read_session_context` tool + `renderRuntimeFrame` (b2a89e04) | projection reused; R1 matcher remediation folded in | +| auto-feed / pushed read surface + deterministic trigger | spec | ○ | proving | later | D60-L *pushed*; nice-to-have | + +### Seam 2 — WRITE / mutate + +DoD: every ● row is `have` or `built`. + +| Capability | Status | Req | Fill | Owner / next | Notes | +| --- | --- | --- | --- | --- | --- | +| `commit_graph` atomic batch **create** | have | ● | — | — | implicit basis | +| `present_review_set`/`request_review` → `acceptReviewSet` | have | ● | — | — | explicit basis | +| auto-capture (synchronous, labeled-text) | built | ● | — | done — `session.submitMessage` capture (5f5e6ac8) | shared explicit-text core reused on ordinary-message path; D66-L | +| generalized graph mutation (create/patch/delete) engine | spec | ○ | proving | card `dev-seed-fixtures--semantic-graph-mutations` | follow-on #4 owns agent patch/delete (Q5) | +| agent-facing `commit_graph` patch/delete | new | ○ | proving | Q5 | default lean: agent stays creation-only | +| spec title/description update tool | new | ○ | earned | later | none exists | +| workspace display-name update | new | ○ | proving | Q-state | unclear elicitor vs product/RPC | +| ~~agent self-switch of posture~~ | — | — | — | RESOLVED Q4 | **dissolved — agent switches nothing**; switches are user/system (D40-L) | +| user/system posture-switch surface (UI affordance reducer) | new | ○ | proving | Q-state (deferred) | derived affordance projection over runtime-policy tables | + +### Seam 3a — KNOW / orient + +DoD: every ● row is `have` or `built`. + +| Capability | Status | Req | Fill | Owner / next | Notes | +| --- | --- | --- | --- | --- | --- | +| goals / strategies / lenses scaffolding + legal-tuple gating | have | ● | — | — | `.pi/agents/state.ts` | +| goal/strategy/lens **content depth** | partial | ● | earned | card `memory/cards/crosscut-know--resource-body-depth.md` | scaffolding present, bodies thin | +| `freestyle` strategy | built | ● | — | done — pin-only strategy (8de7f166) | AUTO-excluded, no added authority; D66-L | +| "what to ask next" driver | spec | ● | proving | PLAN frontier `elicitation-backlog` | substrate tracer promoted; per-turn driver remains a follow-on after the flat-table proof lands | + +### Seam 3b — KNOW / mechanics (methods) + +DoD: every ● row is `have` or `built`. + +| Capability | Status | Req | Fill | Owner / next | Notes | +| --- | --- | --- | --- | --- | --- | +| 6 method resources scaffolding | have | ● | — | — | run-structured-exchange, infer-and-capture, commit-graph, read-context, generate-proposal, review-for-gaps | +| method **content depth** | partial | ● | earned | content pass | bodies thin | +| generalized capture (free text, files, refs; iterative passes) | built | ● | — | done — labeled-text core on `session.submitMessage` (5f5e6ac8) | POC bar = directly-labeled facts; richer free-text/files/refs remain A22-L fitness evidence; D66-L | +| exchange-tool `.description()` / `promptGuidelines` | built | ● | — | done — all 7 exchange tools carry both (drift correction 2026-06-07) | `src/.pi/extensions/exchanges/*` already match the `commit_graph` pattern | +| skill-commands (`gap-review`, `arbitrary-enhance`) | new | ○ | proving | Q6 (deferred) | off critical path | + +### Renderer feedback-loop note + +Several ● rows above produce **LLM-facing rendered text** (graph slices, workspace/session +context, rendered workspace/session text, prompt composition output). Their quality is *eyeball-judged +before it can be a test* — another thing the tracer DoD has no slot for. These rows depend +on the **render preview→lock→formalize harness** (see §Renderer feedback loops below); +treat that harness as a prerequisite oracle, not optional polish. + +## Open design questions (the grill/disambiguate queue) + +Resolve these — ln-grill / ln-disambiguate style — before the items they gate are +built. Ordered by leverage. + +- **Q4 — Agent self-switch of posture. RESOLVED — the agent switches nothing.** + Reframed: switching (a durable `brunch.agent_runtime_state` pin) is a **user/system + authority**; the agent's only in-axis freedom is **AUTO** (per-turn implicit selection + from the manifest, D58-L). So there is **no agent-facing `switch_posture` tool** to + build — Q4 mostly dissolves. + - *Authority rule (durable):* the agent never emits a posture switch. AUTO is its + freedom; the user/system own pins. This collapses the per-axis "agent may switch X + but not Y" question into one line and removes any privilege-escalation surface. + - *Legibility dependency:* the agent's per-turn AUTO choice stays legible downstream + **via per-emission facet stamping** (D25-L: lens/strategy stamped on emitted + exchange payloads; capture/reviewer filter on that) — **not** via runtime-state. + Clean split: **runtime-state = the frame/constraints (user/system-set)**; + **emitted facets = what the agent did this turn (AUTO choice)**. + - *User-mutable posture axes (for now):* `op_mode` (user/system), `strategy`, `lens`. + **`goal` is NOT user-mutable** — too contingent; kept **internal/grade-derived** + (D59-L grade-derived objective) and out of the posture-change command surface for + now. + - *On-parent-switch reducer default → AUTO* for the children it governs (strategy/lens); + goal is grade-derived regardless. + - *`source: 'agent'` reserved:* the enum keeps it, but no current path emits it; parked + for a future execute-mode orchestrator that might legitimately steer sub-postures. + Do not wire an agent switch by default. + - *SPEC touch (open):* the durable authority rule ("agent switches nothing") may merit + a one-line refinement to D40-L, and "goal not user-mutable for now" to D59-L — but + both are load-bearing locked decisions, so promote only on explicit confirmation. + `freestyle` (D66-L: user-pin only, AUTO never selects) is already consistent. +- **Q-state — Workspace/session/runtime state model. DEFERRED — keep current + projection model; enhance later.** Decided to stick with the existing D40-L + transcript-projection posture (truth stays append-only `brunch.agent_runtime_state`, + resolved by pure projection); **no xstate, no persisted machine** for now. + - *Real underlying need = UI affordances, not a truth machine.* The motivation was a + **reducer** for (a) default-assignment when a parent state changes (switch op_mode / + grade advances → reassign now-illegal goal/strategy/lens to their defaults) and + (b) gating which options are available even within a parent state. + - *This logic already exists server-side* as lookup tables in + `projections/session/runtime-policy.ts` (`OPERATIONAL_MODE_DEFINITIONS`, + `AGENT_ROLE_DEFINITIONS`, `default*` fields) and `.pi/agents/state.ts` + (`GRADE_RANK`, `GOAL_MIN_GRADE`, `STRATEGY_MIN_GRADE`). Gating = min-grade tables + + `allowed*` lists; defaults-on-change = the `default*` fields. + - *Future enhancement (when UI pressure is real):* add one Brunch-owned **derived + affordance projection** — `affordances(resolvedState) → { availableOptions per axis, + defaultOnSwitch }` — over those tables; TUI/web/RPC clients **render** it. It is a + pure derivation, so D40-L (projection-as-truth) is untouched. + - *Durable constraint to preserve through the deferral:* the affordance/legality + semantics are **Brunch-owned and shared** (D52-L thin-transport) — never + reimplemented per client. The day the web client hand-rolls "which strategies are + available," the legality rules have forked. + - *Boundary clarified:* `session.runtimeState` munges **steered posture** + (op_mode/strategy/lens/goal — switchable, reducer-governed) with **observed session + facts** (binding/specId, world-watermark LSN, mention slots, lifecycle — derived, + never "switched"). A reducer governs only posture. Genuinely-new runtime state still + homeless: **active review-set state** (D45-L names it but it has no home) and + freestyle-vs-structured turn-mode (D66-L). Park these for the later pass. +- **Q2 — `freestyle` + generalized capture. RESOLVED → SPEC D66-L / R16 refinement.** + `freestyle` is a *strategy* value (not an op_mode, not authority): **structure-optional**, + user-driven turns, structured tools still available, slash/skill-commands ergonomic here. + It grows graph truth only via **generalized capture** (post-exchange capture wired onto + the ordinary-message path `session.submitMessage` over the existing `session exchange` + unit) — so freestyle + generalized capture are **one slice**. R16 refined: offer-first + scoped to structured strategies, not a universal per-turn invariant. **AUTO must never + select freestyle** (user pin only). Remaining scope-level detail: capture quality beyond + labeled facts, per-turn vs on-demand capture, exact slash/skill-command surface (→ Q6). +- **Q3 — `unknown` nodes (the MODELLING PROBLEM). RESOLVED → SPEC D65-L / A24-L.** + De-conflated into two concepts: `elicitation_backlog` (prospective process-agenda / + "prospective memory" — a **flat table**, not a graph node; async + unordered; the + prospective sibling of the retrospective `reconciliation_need`) and a deferred `risk` + intent-node-kind (durable domain-epistemic gap). The `elicitation_backlog` table is + the missing substrate for the "what to ask next" objective and generalized capture. + `basis` generalized to provenance-directness (D63-L). Name locked to `elicitation_backlog` + (over `agenda`/`need`) to signal async/unordered. Remaining scope-level detail: seed + mechanism, mutation path, goal-layer relationship. +- **Q1 — Negative/IS_NOT graph queries. RESOLVED → dedicated `gaps` mode.** Add a fourth + `read_graph` mode `gaps`: a base class filter (`kinds` and/or `readinessBands`) plus a + required `absentEdgeCategory` and optional `direction` (default `both`), returning + class-members that have **no** edge of that category in that direction. Chosen over a + `negate` flag on the list/related modes because a *named observed shape* matches D60-L's + enumeration style and resists "any predicate can be negated" creep, while keeping the + positive list modes pure. Projection-aware: under `active_context` a node whose only + qualifying edge is superseded counts as a gap (the elicitation-relevant reading); under + `graph_truth` it does not. Bounded — single `absentEdgeCategory`, not a query language. + **SPEC touch (RATIFIED 2026-06-07):** D60-L + glossary Agent context entry now enumerate the + fourth observed read shape (gap query). Scoped: `memory/cards/crosscut-read--graph-gaps.md`. + Directly serves the D65-L `elicitation_backlog` "what to ask next" driver (theses w/o + proof, requirements w/o realization, claims w/o support). +- **Q5 — Agent `commit_graph` patch/delete.** Owned by the seed-fixtures card + follow-on #4. Default lean: agent stays creation-only; deletion not silently + exposed to autonomous agents. +- **Q6 — Skill-commands. DEFERRED (off critical path for POC).** Idea recorded: + user-invoked slash/skill-commands (e.g. `gap-review`, `arbitrary-enhance`) for + on-demand operations. **Affordance**: authority-gated by `op_mode` like any tool; + available regardless of strategy but **ergonomic in `freestyle`** (D66-L) because no + pending structured exchange consumes the turn. Open (when revisited): methods-exposed- + as-commands vs a separate primitive. Not blocking POC; no SPEC decision yet. + +## Working order + +Option (b): start with the **no-spec-risk build-out** and let knowledge follow. +Q2/Q3/Q4/Q-state/Q6 are now resolved or deferred (see Open design questions), so the +order is coverage-driven: close ● ledger rows seam by seam. + +1. **Fixtures + render harness** — card `crosscut-render--preview-harness-and-fixtures` + (Card A fixture game plan → Card B preview→lock→formalize harness). Prerequisite oracle + + data substrate for every ● row that emits LLM-facing text; build before the + renderer-bearing READ rows so their output is eyeball-lockable, not test-blind. +2. **Seam 1 READ build-out** (D60-L) — **COMPLETE** (all ● rows built). The agent can now + *see* before it steers or acts: + 1. ~~graph slices~~ — **built** (read_graph `list_by_kind`/`list_by_band` in 67e986b8; + `related` in 62971be7). + 2. ~~session context~~ — **built** (`read_session_context` + `renderRuntimeFrame` in + b2a89e04; R1 native `toMatchFileSnapshot` remediation folded in). + 3. ~~graph gaps~~ — **built** (read_graph `gaps` mode in 79f92bc5; D60-L 4th shape). + 4. ~~workspace context~~ — **built** (`read_workspace_context`: `cwd_inventory` in + 54ae7f86, `workspace_overview` in 3642b777; design fork resolved → agent-context read). + - Deferred READ row (not POC-critical): auto-feed / pushed surface (○). +3. **Seam 2 WRITE** ● rows — generalized capture (D66-L, one slice with `freestyle`). + **COMPLETE** (all ● rows built): `session.submitMessage` reuses the shared explicit-text + capture core (5f5e6ac8), and `freestyle` is a pin-only AUTO-excluded strategy (8de7f166). + This also closed the Seam 3a `freestyle` and Seam 3b generalized-capture ● rows. + No posture-switch tool to build (Q4 dissolved); user/system posture surface is + deferred to the Q-state affordance reducer. +4. **Seam 3a/3b content pass** — `freestyle` strategy (**built**, 8de7f166) + + `elicitation_backlog`-driven "what to ask next" (D65-L); goal/strategy/lens/method body + depth; exchange-tool `.description()` / `promptGuidelines` fix (**built** — drift correction; + all 7 exchange tools already carry both). Skill-commands (Q6) stay deferred. **Scoped:** + `memory/cards/elicitation-backlog--substrate.md` (D65-L substrate tracer; promoted to the active + PLAN frontier `elicitation-backlog`; the per-turn driver + capture-reflection stay an unscoped follow-on) and + `memory/cards/crosscut-know--resource-body-depth.md` (the goal/strategy/lens/method body pass). +5. **Spec reconcile** — promote the D40-L/D59-L one-line refinements (on confirmation), + land Q1 negative-query touch, fold D65-L/D66-L outcomes into SPEC/PLAN. + +State-machine (Q-state) and generalized graph mutation (the card) proceed on their +own tracks and feed Seam 2. Each step closes specific ● rows; the seam is done when no +● row sits in `spec`/`new`/`partial` (the ledger DoD), not when a tracer claim is proven. + +## Renderer feedback loops + +Some ● rows emit **LLM-facing rendered text** whose correctness is *aesthetic before it +is assertable*: graph-slice renderings, workspace/session context blocks, rendered +context strings, prompt-composition output. You cannot write the assertion until you have seen +and approved the shape. The tracer DoD has no slot for "look at it first," so this is a +named prerequisite oracle, not optional polish. + +**Grounding (what already exists, so we extend not invent):** +- Renderer home is real: `src/renderers//` (`graph/`, `workspace/`, `session/`, + `exchanges/`), each with co-located `*.test.ts` (D52-L; see `renderers/README`). + New graph-slice renderers land in `src/renderers/graph/`. +- Seed infra is real: `npm run seed` → `src/graph/seed-fixtures.ts` (`seedFixture(executor, + fixture)`); `src/scripts/` exists as the executables home (D52-L) and may import domain. +- **The lock stage is net-new.** Current render tests are *invariant-only* (`.toContain(...)`, + e.g. `workspace-state.test.ts`) — there is **no `toMatchFileSnapshot` / golden pattern + in the repo yet**. The eyeball-lock stage *is* the missing oracle, not an existing habit. + +**Three-stage loop — sketch → lock → formalize:** + +1. **Sketch (live render-to-file).** A dev script — `src/scripts/render-preview.ts` — + loads a **seed fixture spec** (via `seedFixture` / `npm run seed` infra), runs a chosen + `src/renderers//*` renderer, and writes output to a reviewable, diffable file. + Re-run on edit; eyeball the file. Fast inner-inner loop, **no failing tests during + exploration**. (Add an `npm run render` script; `--watch` is a later nicety.) The script + importing renderers respects the dependency direction (`scripts → renderers`, never back). +2. **Lock.** When the shape is right, the *same file* becomes a golden master via vitest + `expect(rendered).toMatchFileSnapshot(...)` — writes on first run, diffs after. **Artifact + home = co-located with the renderer test** (e.g. `src/renderers//__previews__/ + .txt`), **not** `.fixtures/` — `.fixtures/` is reserved for the probe-first / + transcript-backed convention (golden fixtures parked there; see `.fixtures/README`). + Co-location keeps golden + renderer + test adjacent, matching the existing test layout. +3. **Formalize.** Add targeted **invariant** asserts for what we actually mean (e.g. + "renders projected code G1, never the raw id"; "active-context omits superseded nodes"; + "no dangling edge endpoints") — i.e. extend the existing `.toContain` style. The + file-snapshot catches *unintended* drift; the invariant asserts catch *semantic* + regression. Both live in one test file. + +**Scoped** (standalone, per decision): `memory/cards/crosscut-render--preview-harness-and-fixtures.md`. +That card also owns the **fixture game plan** — the chicken-and-egg upstream of the harness: +renderers need projections, projections need legal+coherent graphs, and we are short on +fixtures everywhere. Build order is inverted from the layer stack — **fixtures → projections +→ renderers → harness** — so the card delivers the two shared enablers (fixtures as +hand-authored explicit-basis SeedFixture JSON seeded through the validated `seedFixture` +path — deterministic, no live agent; and the preview→lock→formalize harness) while the seam +cards own their own projection+renderer pairs. It carries a coverage matrix +(fixtures × projections × renderers) as the audit of what exists vs. what each ● row needs. + +## Canonical pointers (do not duplicate here) + +- Graph mutation engine: `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` +- Read family design: SPEC D60-L. Runtime state: D40-L. Prompt composition: D58-L. + Goals/strategies/lenses: D59-L/D25-L. Graph model lock: D54-L/D56-L/D51-L. + Offer-first contract: R16. Capture distinction: SPEC "Capture analysis" design note. diff --git a/memory/PLAN.md b/memory/PLAN.md index b8ccf7a39..94c23f914 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -27,15 +27,22 @@ All delivery frontiers must also continue materializing the locked source topolo The multi-spec workspace model is now explicit: a workspace is the cwd; multiple specs may coexist under it; each session binds to exactly one spec; each POC spec owns its own intent graph; cross-spec claim sharing/adoption is deferred (D11-L, D21-L, D61-L). Delivery work must target an explicit selected/current spec and must not accidentally recreate a workspace-global graph. +Planning is currently carrying two shapes at once: canonical frontier sequencing in this file, and a temporary elicitor capability ledger in `memory/CROSS_CUT_PLAN.md`. The authority split must stay hard: `PLAN.md` owns frontier ids, ordering, and dependency judgments; `CROSS_CUT_PLAN.md` only inventories the temporary READ/WRITE/KNOW row surface. The current planning move is therefore to promote any cross-cut row that has escaped row-sized work back into a real frontier. `elicitation-backlog` is the first such promotion; the remaining prompt-resource body-depth pass stays temporary cross-cut completion work. + +After the current elicitor work, the strongest follow-on coverage frontier is `graph-observed-shapes`: decide the observed-shape inventory per consumer, then align graph/RPC/web to it. `runtime-affordances-and-legality` remains the next likely coverage frontier behind that. Exchange/capture breadth is explicitly deferred until its surviving inventory is honest enough to enumerate without recreating the deleted stub surface. + ## Sequencing ### Active -1. `poc-live-ship-gate` — P1 final gate; current active slice is live selected-spec mention autocomplete before the fresh-cwd runbook. +1. `elicitation-backlog` — proving frontier promoted out of the temporary elicitor coverage ledger; current build target is the substrate tracer in `memory/cards/elicitation-backlog--substrate.md`, while prompt-resource body depth remains temporary cross-cut completion work. ### Next -1. `minimal-authority-shell` — P1 safety: thin POC authority posture over already-existing command-result seams and `elicit` tool policy. +1. `minimal-authority-shell` — still the next delivery-safety frontier once the current elicitation substrate lands. +2. `poc-live-ship-gate` — final fresh-cwd runbook remains the delivery gate, but its prepared live-mention-autocomplete slice is currently parked off the critical path. +3. `graph-observed-shapes` — next coverage frontier candidate: decide the observed-shape inventory per consumer, then align graph/RPC/web to it. +4. `runtime-affordances-and-legality` — follow-on coverage frontier for shared posture legality/default surfaces once graph observed shapes stop dominating. ### Parallel / Low-conflict @@ -45,6 +52,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple ### Horizon +- `exchanges-and-generalized-capture` — revisit only when the surviving exchange/capture inventory is honest enough to enumerate; not yet a coverage frontier. - `turn-boundary-reconciliation` — M7; graph revisions, `worldUpdate`, mention staleness, side-task/reviewer drains. - `coherence-first-class` — M8; bounded coherence verdicts backed by reconciliation needs. - `compaction-and-conflict-widening` — M9; long-horizon continuity through compaction. @@ -75,12 +83,35 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - Request-changes and reject are transcript-visible outcomes; request-changes can trigger a successor proposal or an explicit deferred path. - Web/TUI can observe the proposal/decision state enough for the POC; full review UX polish may remain thin. - **Verification:** Inner — review-set schema tests, dry-run/real-run differential tests, accept atomicity tests. Middle — structured-exchange review-cycle fixture; no-bypass checks. Outer — targeted probe: `project-graph` proposes, user approves, graph updates and web observer sees it. -- **Topology materialization:** Review payload schemas live under `.pi/extensions/exchanges` as the current structured-exchange schema seam; reusable review payload construction/rendering lives under `projections/structured-exchange/` and `renderers/structured-exchange/`; proposal validation/translation lives in `graph/` review modules; agent strategy resource lives in `.pi/skills/strategies/project-graph.md`; web observes via RPC projections. +- **Topology materialization:** Review payload schemas live under `.pi/extensions/exchanges` as the current structured-exchange schema seam; reusable review payload construction/rendering lives under `projections/exchanges/` and `renderers/exchanges/`; proposal validation/translation lives in `graph/` review modules; agent strategy resource lives in `.pi/skills/strategies/project-graph.md`; web observes via RPC projections. - **Cross-cutting obligations:** Preserve D27-L: review-set proposal is a structured-exchange payload, not a standalone public review-set entity. Reviewer advisory writes remain deferred unless explicitly scoped. Existing-node references and review payloads use projected graph codes at adapter/UI boundaries, not raw DB ids. - **Traceability:** R21, R23 / D4-L, D20-L, D26-L, D27-L, D51-L, D53-L, D62-L, D63-L / I11-L, I15-L, I20-L, I34-L, I40-L / A14-L, A16-L. - **Design docs:** `docs/design/REVIEW_SETS.md`; `docs/design/GRAPH_MODEL.md`; `memory/SPEC.md` D27-L. - **Current execution pointer:** Done 2026-06-06. Structured-exchange schema/emission lock and approval wiring are complete, and `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves the real `project-graph` agent path: selected-spec graph read, dry-run-gated `present_review_set`, public-RPC approval through `session.submitExchangeResponse`, one explicit-basis `acceptReviewSet` graph commit, and graph invalidations with `{specId, lsn}`. The probe also fixed a real policy gap: commitment-grade `generate-proposal` now activates `present_review_set` / `request_review` for the Brunch runtime tool posture. +### elicitation-backlog + +- **Name:** Elicitation backlog substrate and agenda read-back +- **Linear:** unassigned (promoted from the temporary elicitor cross-cut; no dedicated tracker yet) +- **Kind:** structural / bounded feature +- **Status:** active +- **Certainty:** proving +- **Retires:** A24-L — test whether a flat prospective register is sufficient before any plane/pointer promotion. +- **Lights up:** `createSpec` seed → `CommandExecutor` backlog mutation → per-spec read-back on the real graph boundary. +- **Stabilizes:** D65-L's missing "what to ask next" substrate and the rule that prospective agenda state shares the spec-local LSN / change-log boundary. +- **Objective:** Materialize D65-L `elicitation_backlog` as a flat table routed through `CommandExecutor`, seed it at spec creation, and provide per-spec read-back so the current elicitor coverage push has a real substrate instead of a homeless driver row. +- **Why now / unlocks:** This is the remaining required elicitor-coverage row that has escaped row-sized work. Promoting it back into `PLAN.md` keeps PLAN authoritative, gives the temporary cross-cut a named completion target, and unlocks later per-turn "what to ask next" behavior without prematurely inventing either a second planning system or a graph plane. +- **Acceptance:** + - The flat table exists with a generated migration and a reconciliation-need-mirroring shape. + - Create/close operations route through `CommandExecutor`, allocate one spec-local LSN + one `change_log` row each, and return structured failures on malformed input. + - `createSpec` seeds the grounding-band starter agenda for the new spec only. + - A graph-owned read path returns open backlog entries per spec with stable fields. +- **Verification:** Inner — schema/migration and `CommandExecutor` tests for create/close/seed/LSN/change-log behavior. Middle — graph query read-back and sibling-spec isolation. Outer — none yet; the per-turn driver remains a follow-on once the substrate proves useful. +- **Cross-cutting obligations:** Preserve D4-L/D20-L command boundary, D16-L/A4-L one `{specId, lsn}` mutation clock, D63-L basis-as-provenance-directness, D52-L graph-owned table + read, and D65-L flat-table-only modeling — no graph node/plane and no unknown→unknown edges. +- **Traceability:** D4-L, D8-L, D16-L, D20-L, D52-L, D63-L, D64-L, D65-L / A24-L. +- **Design docs:** `memory/SPEC.md` D65-L; `docs/design/GRAPH_MODEL.md`. +- **Current execution pointer:** `memory/cards/elicitation-backlog--substrate.md`; the remaining prompt-resource body pass stays in `memory/CROSS_CUT_PLAN.md` as temporary coverage completion work. + ### minimal-authority-shell - **Name:** Minimal POC authority shell over graph/session actions @@ -107,7 +138,7 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Name:** POC live ship gate and runbook oracle - **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` +- **Branch:** `ln/fe-811-poc-live-ship-blockers` - **Kind:** hardening / release gate - **Status:** next - **Certainty:** proving @@ -127,7 +158,69 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - **Cross-cutting obligations:** Keep the gate small and real. Do not turn it into a generic e2e framework or use it to backfill unrelated polish. - **Traceability:** R4, R7, R10, R11, R12, R16, R19, R24, R28 / D5-L, D11-L, D19-L, D21-L, D33-L, D36-L, D52-L, D61-L, D62-L, D63-L, D64-L / I22-L, I32-L, I35-L, I38-L, I39-L, I40-L / A5-L. - **Design docs:** `docs/architecture/probes-and-transcripts.md`; `docs/architecture/pi-ui-extension-patterns.md`; `memory/SPEC.md` verification stance. -- **Current execution pointer:** A prepared live-mention autocomplete scope exists at `memory/cards/poc-live-ship-gate--live-mention-autocomplete.md`; it is a narrow product-path defect slice inside the ship-gate frontier, not M7 mention-ledger work. +- **Current execution pointer:** A prepared live-mention autocomplete scope exists at `memory/cards/poc-live-ship-gate--live-mention-autocomplete.md`; keep it parked until this frontier returns to the critical path. It remains a narrow product-path defect slice inside the ship-gate frontier, not M7 mention-ledger work. + +### graph-observed-shapes + +- **Name:** Graph observed-shape inventory by consumer +- **Linear:** unassigned +- **Kind:** structural +- **Status:** next +- **Certainty:** proving +- **Lights up:** One canonical observed-shape matrix across graph readers, RPC methods, and web observer surfaces. +- **Stabilizes:** D60-L read-shape ownership, D33-L web read-only observer scope, and the rule that `src/projections/` exists only for reusable multi-consumer DTOs. +- **Objective:** Decide the canonical graph read-shape set per consumer (agent/tooling, RPC, web) and align `graph/`, `rpc/`, and `web/` to that inventory without forcing every agent-oriented shape onto the web. +- **Why now / unlocks:** The read-shape story is currently fragmented across domain queries, Pi adapter helpers, RPC methods, and web features. This is the strongest follow-on coverage frontier because it keeps `projections/` from becoming an indirection grab bag and makes the observed-shape story legible before more surfaces accrete. +- **Acceptance:** + - A closed enumerated coverage ledger exists with required vs deferred shapes per consumer. + - Each required consumer shape has one canonical owner; adapter-local formatting no longer stands in for a durable read shape. + - Web remains a read-only observer; web adoption is deliberate, not accidental bleed-through from agent/RPC needs. + - Any DTOs that survive in `src/projections/` justify multi-consumer reuse; single-owner reads stay in their owning domains. +- **Verification:** Inner — graph query / RPC / web query tests for adopted shapes. Middle — selected-spec observer/read-path smoke over seeded graph data. Outer — manual spot-check only if the web observer UX changes materially. +- **Cross-cutting obligations:** Do not promote all read shapes everywhere. `list_by_kind` / `list_by_band` are plausible web shapes; `related` / `gaps` may remain agent/RPC-only. Keep graph-owned read logic out of `db/`, and keep `src/renderers/` limited to durable LLM/session text rather than arbitrary observer DTOs. +- **Traceability:** D33-L, D51-L, D52-L, D60-L, D64-L. +- **Design docs:** `src/graph/README.md`; `src/rpc/README.md`; `src/web/README.md`. +- **Current execution pointer:** To author via `ln-scope` as a `Mode: coverage` ledger once the active frontier closes. + +### runtime-affordances-and-legality + +- **Name:** Runtime affordances and legality surface +- **Linear:** unassigned +- **Kind:** structural +- **Status:** next +- **Certainty:** proving +- **Lights up:** A shared affordance/default-on-switch projection across TUI, web, and RPC if runtime posture controls widen again. +- **Stabilizes:** D40-L's projection-as-truth model and the shared legality/default semantics over goal/strategy/lens. +- **Objective:** Consolidate what runtime posture options are legal, default-on-switch, and visible across transport boundaries without replacing the append-only runtime-state projection model with a state machine. +- **Why now / unlocks:** The shared legality tables already exist, but the next UI/control pass could fork them client-side if this surface stays implicit. Keeping it queued protects the "Brunch-owned shared affordance logic" rule before another posture pass lands piecemeal. +- **Acceptance:** + - The scoped frontier closes the required affordance rows across user/system switch surfaces, resolved-state read-back, and shared legality/default projections. + - No client reimplements availability/legality rules locally. + - Active review-set state or freestyle-vs-structured turn mode only joins when it becomes real product state, not as speculative scaffolding. +- **Verification:** Inner — shared affordance projection and switch-reducer tests. Middle — TUI/RPC/web parity checks if a new surface lands. Outer — manual only when a user-visible posture control changes. +- **Cross-cutting obligations:** Keep truth append-only in `brunch.agent_runtime_state`; affordances are pure derivations over shared tables. Do not add xstate or a persisted machine without new evidence. +- **Traceability:** D25-L, D40-L, D59-L, D66-L. +- **Design docs:** `memory/SPEC.md` D40-L/D59-L; `src/projections/README.md`; `src/session/README.md`. + +### exchanges-and-generalized-capture + +- **Name:** Exchange surface and generalized capture inventory +- **Linear:** unassigned +- **Kind:** structural +- **Status:** horizon +- **Certainty:** proving +- **Blocked by:** An honest, closeable exchange/capture inventory; do not start while the surface still depends on deleted-stub symmetry or speculative breadth. +- **Stabilizes:** The ownership split between `.pi/extensions/exchanges`, `projections/exchanges`, `renderers/exchanges`, and `session/structured-exchange-loop.ts`. +- **Objective:** Revisit richer exchange payload families and generalized capture breadth only after the surviving surface is clear enough to enumerate. +- **Why now / unlocks:** Recording this frontier here prevents the deleted `capture-*` topology from silently regrowing while preserving the likely future concern once capture breadth becomes honest. +- **Acceptance:** + - Work does not start until the surviving exchange/capture families can be enumerated with required vs deferred marking. + - Reusable exchange details justify `projections/exchanges`; single-owner reads or orchestration state stay in their owning domains. + - Capture beyond directly labeled facts is driven by real evidence, not symmetry with removed stubs. +- **Verification:** Likely probe-backed transcript and capture read-back oracles rather than purely unit tests; define when the frontier is actually scoped. +- **Cross-cutting obligations:** Keep `renderers/exchanges` for durable markdown/text/toon only, keep TUI presenters local, and do not reintroduce `snapshot` as an architecture noun. +- **Traceability:** D27-L, D65-L, D66-L. +- **Design docs:** `memory/SPEC.md` D65-L/D66-L; `src/projections/README.md`; `src/renderers/README.md`. ### probes-and-transcripts-evolution @@ -170,21 +263,21 @@ The multi-spec workspace model is now explicit: a workspace is the cwd; multiple - Seed contract stays loadable: each set's port script self-validates every `.json` through the real loader (same structural checks `commitGraph` enforces) before writing. - `npm run seed` loads every `.fixtures/seeds//.json` into the workspace DB through `CommandExecutor` (never direct row inserts), preserving spec-local graph clock / change log / LSN coherence. - New seed sets follow the established shape: vendored `_originals/`, throwaway `_port-script.ts`, consolidated `.json`, generated `README.md`; derived variant sets may instead document the deterministic filter over an existing seed set and keep mixed-basis product-run output under `.fixtures/runs/`. - - Product curation runs over seeds leave transcript-backed artifacts (`session.jsonl`, `transcript.md`, `report.json`, and graph readback when graph truth is the proof target) and prove real `commit_graph` transcript evidence plus implicit graph rows; mixed-basis snapshots are not registered as reusable seeds. + - Product curation runs over seeds leave transcript-backed artifacts (`session.jsonl`, `transcript.md`, `report.json`, and graph readback when graph truth is the proof target) and prove real `commit_graph` transcript evidence plus implicit graph rows; mixed-basis graph readbacks are not registered as reusable seeds. - **Enhancement backlog (captured, not yet scoped):** 1. Enhance Bilal-port fixtures *through Brunch itself* by feeding the original briefs Bilal authored, to recover `thesis`/`goal` structure the current ported graphs under-express. 2. Port and enhance the earlier product version's fixtures (the legacy walkthrough scenarios in `docs/praxis/manual-testing.md`), raising quality through better semantic definition (kinds, detail) and internal connection (edges). - **Verification:** Inner — `src/graph/seed-fixtures.test.ts` seeds real fixtures into an in-memory DB and asserts spec/node/edge counts plus spec-local change-log/clock coherence independent of seed order, rejects non-`explicit` basis, and covers the `macro-view-grounded-intent` explicit intent-only variant; `src/probes/fixture-curation-loop.test.ts` proves curation report/artifact evidence detection without an LLM. Outer — `npm run seed` smoke against a fresh cwd; real fixture-curation runs under `.fixtures/runs/fixture-curation/`; seeded-dev-rpc smoke proves `dev.graph.commitGraph` advances only the mutated spec's overview LSN. - **Topology materialization:** Seed data and throwaway prep scripts live under `.fixtures/seeds/`; the loader lives in `src/graph/seed-fixtures.ts` (graph/ owns `CommandExecutor` orchestration; db/ is imported only by graph/, never the reverse); no seed-only graph runtime the product launch does not use. - **Cross-cutting obligations:** Seeds commit only through `CommandExecutor`; directly-authored items use `basis: explicit` (the retired `accepted_review_set` value is not a basis). Respect multi-spec discipline — each fixture is one spec's own graph (D61-L). Pre-release posture: regenerate fixtures when the schema moves rather than preserving stale shapes. **Known drift:** `docs/praxis/manual-testing.md` still describes the earlier seed system (scenario-arg `npm run seed`, `.brunch/brunch.db`); reconcile it to the current loader (all-sets `npm run seed`, `.brunch/data.db`) when the legacy port (backlog item 2) lands — coordinate with the doc-reconciliation track rather than double-editing. -- **Current execution pointer:** Active semantic-mutation curation scope exists at `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md`; it is not parallel-safe with FE-809 graph/review work on the same worktree because it touches `CommandExecutor` and review-set graph code. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant, and `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` proves one real `propose-graph`/`commit_graph` run created implicit intent nodes from that base. +- **Current execution pointer:** Active semantic-mutation curation scope exists at `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md`; FE-809 graph/review work has landed, but this future scope still needs coordination because it touches `CommandExecutor` and review-set graph code. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant, and `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` proves one real `propose-graph`/`commit_graph` run created implicit intent nodes from that base. - **Traceability:** D4-L, D16-L, D19-L, D20-L, D52-L, D61-L, D62-L, D63-L / I1-L / A4-L, A14-L. - **Design docs:** `.fixtures/seeds/bilal-port/README.md`; `docs/design/GRAPH_MODEL.md`; `docs/praxis/manual-testing.md`. ## Recently Completed - 2026-06-06 `project-graph-review-cycle` (FE-809) — Done: `project-graph` now has active review tools at commitment readiness, real agent proposal generation reaches `present_review_set`, approval goes through public `session.submitExchangeResponse`, `CommandExecutor.acceptReviewSet` commits the exact reviewed batch with `basis: explicit`, and graph/session invalidations publish with `{specId, lsn}`. Verified: `src/.pi/agents/state.test.ts`, `src/.pi/__tests__/prompting.test.ts`, `src/probes/project-graph-review-cycle-proof.test.ts`, and real run `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/`. -- 2026-06-06 `topology-readmes-and-boundaries` — Done: root product entrypoints moved to `app/`/`workspace/`/`scripts`; reusable graph/session/structured-exchange/workspace projection helpers moved to `projections/`; reusable markdown/text renderers moved to `renderers/`; `src/projections/topology-boundaries.test.ts` now guards the projection/renderer adapter boundary; and D40-L runtime-state policy now shares `elicit-read-only` tool-policy definitions from `projections/session/runtime-policy.ts` while `.pi/extensions/runtime` remains the Pi tool adapter. Verified: targeted topology/runtime tests and `npm run verify`. +- 2026-06-06 `topology-readmes-and-boundaries` — Done: root product entrypoints moved to `app/`/`workspace/`/`scripts`; reusable graph/session/exchanges/workspace projection helpers moved to `projections/`; reusable markdown/text renderers moved to `renderers/`; `src/projections/topology-boundaries.test.ts` now guards the projection/renderer adapter boundary; and D40-L runtime-state policy now shares `elicit-read-only` tool-policy definitions from `projections/session/runtime-policy.ts` while `.pi/extensions/runtime` remains the Pi tool adapter. Verified: targeted topology/runtime tests and `npm run verify`. - 2026-06-05 `capture-response-to-graph` (FE-807) — Done: synchronous response-capture tracer. Added a narrow labeled-text translator for `Goal:`, `Context:`, `Constraint:`, and `Criterion:` facts; wired public `session.submitExchangeResponse` to capture through the transcript binding's spec and `CommandExecutor.commitGraph({basis: explicit})`; returned loud capture outcomes; published graph invalidations; and added a public-RPC proof that activation/trigger/submit/overview exposes captured projected codes. Verified: `src/graph/capture/structured-response.test.ts`, `src/rpc/handlers.test.ts`, `src/probes/capture-response-to-graph-proof.test.ts`. @@ -199,8 +292,11 @@ nodes: graph-tool-resilience [done · P0] materialized graph write contract and broadened A14 proof capture-response-to-graph [done · P0] structured answer -> graph truth -> observer update project-graph-review-cycle [done · P1] real project-graph review-set approval loop + elicitation-backlog [active · proving] materialize D65-L prospective agenda substrate and read-back minimal-authority-shell [next · P1] thin safety posture for current POC paths - poc-live-ship-gate [active · P1] final fresh-cwd composed product runbook + poc-live-ship-gate [next · P1] final fresh-cwd composed product runbook + graph-observed-shapes [next · proving] decide consumer-specific observed-shape inventory, then align graph/RPC/web + runtime-affordances-and-legality [next · proving] keep posture legality/default surfaces shared across transports probes-and-transcripts-evolution [parallel] continuous evidence substrate topology-readmes-and-boundaries [parallel] attach-to-frontier topology hardening dev-seed-fixtures [parallel] rich seed data substrate for dev/observer testing @@ -219,6 +315,7 @@ parallel obligations: dev-seed-fixtures -[data]-> capture-response-to-graph, poc-live-ship-gate (real multi-spec graphs to exercise observer/capture) horizon: + exchanges-and-generalized-capture turn-boundary-reconciliation coherence-first-class compaction-and-conflict-widening @@ -229,7 +326,10 @@ horizon: geolog-and-petri-execution notes: + - `elicitation-backlog` is the promoted D65-L row from `memory/CROSS_CUT_PLAN.md`; the remaining temporary cross-cut work is `memory/cards/crosscut-know--resource-body-depth.md`. - Completed prerequisites: `agents-composition-layer` supplies runtime prompt/resource posture, and `live-graph-observer` supplies the read-only web observer path expected by `capture-response-to-graph` and `poc-live-ship-gate`. + - `graph-observed-shapes` is intentionally consumer-specific: do not assume every agent read shape belongs on the web observer. + - `exchanges-and-generalized-capture` stays deferred until the surviving inventory is honest enough to close; do not regrow deleted `capture-*` symmetry in the meantime. - `project-graph-review-cycle` is complete evidence for the optional batch proposal/review story; keep future review-quality work as follow-up, not FE-809 completion debt. - `topology-readmes-and-boundaries` is not a license for abstract cleanup; it rides with concrete delivery seams. - Multi-spec workspace discipline applies throughout: target the selected/current spec explicitly; no workspace-global graph truth in the POC. diff --git a/memory/SPEC.md b/memory/SPEC.md index 4c97ac750..ea829d757 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -72,7 +72,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c #### Elicitation product shape -16. Brunch must keep sessions elicitation-first and offer-first: at idle, the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. +16. Brunch must keep sessions elicitation-first and offer-first **by default**: under the structured elicitation strategies, at idle the user is responding to a system/assistant-originated elicitation prompt or structured offer rather than initiating ambient free chat. The `freestyle` strategy refines this (D66-L): it permits user-initiated ambient turns while keeping structured-exchange tools available and graph capture flowing, so offer-first is a property of the structured strategies, not a universal per-turn session invariant. Freestyle is at minimum a dev-facing affordance and tentatively a user-facing one. 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 human reference codes (for example `#R3`), resolved internally to graph node 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. @@ -106,7 +106,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A6-L | The graph-native vocabulary can be deferred from explicit per-plane namespacing (`intent.*`, `oracle.*`, etc.) and start unified under `graph.*` without painful rework later. | medium | open | D3-L | M4–M5: if intent-plane plus oracle-plane stubs both fit under one namespace cleanly, the assumption holds. | | A7-L | ~~`framing_as` as an orthogonal modality on existing node kinds is sufficient for product-intent ontology.~~ | — | **retired** | D7-L, D54-L, D56-L | Validated and retired by Phase 2 node lock: `framing_as` is absorbed by first-class `thesis`, `term`, and `constraint` kinds plus `goal`. The modality, allowed matrix (I7-L), and "promote on relation-policy pressure" escape hatch are all retired. See [`docs/design/GRAPH_MODEL.md` §framing_as — retired](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#framing_as--retired). | | A8-L | One reconciliation-need substrate, sharing the same **spec-local** LSN as that spec's 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. | +| A9-L | A session-scoped mention ledger of (`entity_id`, `seen_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 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/`. **Existing-code product-path subclaim validated 2026-06-04** by `.fixtures/runs/propose-graph-commit/2026-06-04-existing-code-ref/`: the default Brunch runtime factory exposed real `read_graph`/`commit_graph`, `gpt-5.5` read projected code `G1`, used `{existingCode: "G1"}` in `commit_graph`, and persisted a requirement plus edge to the pre-existing selected-spec goal without cross-spec writes. **Retry-diagnostics subclaim validated 2026-06-04** by `.fixtures/runs/propose-graph-commit/2026-06-04-retry-diagnostics/`: the default runtime recorded one `structural_illegal` proof-edge attempt with stance diagnostics, then a corrected retry that persisted two nodes and one legal proof edge with no partial state from the failed attempt. **Ambiguity/no-overcommit subclaim validated 2026-06-04** by `.fixtures/runs/propose-graph-commit/2026-06-04-ambiguity-no-overcommit/`: the default runtime read graph context, asked for more concrete accepted facts, and recorded zero `commit_graph` attempts with zero graph nodes/edges written. **Seeded-fixture curation subclaim validated 2026-06-05** by `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/`: the default runtime loaded an explicit Bilal-derived base through `seedFixture`/`CommandExecutor`, `gpt-5.5` used real `read_graph`/`commit_graph`, and graph readback distinguished 70 explicit base nodes from two implicit product-created requirement nodes and six implicit edges. **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. | @@ -117,7 +117,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A19-L | Pi's current settings/resource lifecycle can be made product-safe through a sealed Brunch Pi Profile without forking Pi: ambient discovery remains disabled, Brunch-owned extension factories may inject explicit resources, and remaining settings/keybinding leakage can be eliminated through programmatic policy or a narrow upstream seam. | medium | open | D39-L | FE-744/profile audit: source-backed resource-loader/settings audit, tests proving no ambient `.pi/` skills/prompts/themes/extensions/context files affect Brunch, and product-owned resources still load when intentionally injected. | | A20-L | The chosen Drizzle line and row-schema derivation path can be settled during the prep envelope without forcing later M4 rework: Brunch can prove migrations, SQLite fidelity, monotonic counter allocation, change-log writes, and runtime-schema derivation on one representative persistence slice before CRUD proper starts. | high | **validated** | D16-L, D41-L | **Validated by A20-L spike (2026-06-01).** Stack: `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`. Proved: (1) `drizzle-typebox` derives valid TypeBox insert/select schemas from Drizzle tables; `Value.Check` validates/rejects correctly. (2) Batch `commitGraph`-shaped transaction (multi-node → intra-batch ref resolution → multi-edge → LSN allocation → change-log append) works atomically; full rollback on FK violation or domain-validation throw. (3) `update().returning()` works for atomic monotonic counter increment; `insert().returning()` gives auto-increment IDs for ref resolution; JSON detail column round-trips cleanly. (4) Pi tool parameters (`typebox` v1.x) and Drizzle row schemas (`@sinclair/typebox` v0.34 via `drizzle-typebox`) serve different roles and never cross — shared enum `const` arrays bridge both. | | A21-L | The POC can treat coherence as a bounded product verdict over structural legality plus explicitly detected contradictions, gaps, and unresolved reconciliation needs, without solving a general theory of “spec coherence.” | low | open | D8-L | M8 must sharpen the coherence rubric before implementation: known-bad adversarial briefs should show what counts as incoherent, what is merely immature/underspecified, and what should become a reconciliation need. | -| A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness-grade updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | partially validated | D18-L, D26-L, D45-L, I30-L | 2026-06-05 `capture-response-to-graph` validated the product wiring for narrow labeled text facts (`Goal:`, `Context:`, `Constraint:`, `Criterion:`): `session.submitExchangeResponse` commits through `graph/capture` → `CommandExecutor.commitGraph({basis: explicit})`, returns `captured | no_capture | structural_illegal`, targets the transcript binding's spec, and publishes graph invalidations. Broader LLM capture quality and readiness-grade updates remain fitness evidence. | +| A22-L | The elicitor can perform synchronous post-exchange capture well enough for the POC: high-confidence extractive facts and readiness-grade updates can be committed immediately, while low-confidence implications can be kept out of graph truth and used as disambiguation material. | medium | partially validated | D18-L, D26-L, D45-L, I30-L | 2026-06-05 `capture-response-to-graph` validated the product wiring for narrow labeled text facts (`Goal:`, `Context:`, `Constraint:`, `Criterion:`) on `session.submitExchangeResponse`. 2026-06-07 generalized the same explicit-text capture core onto `session.submitMessage`: ordinary labeled user text now appends to transcript truth, commits through `graph/capture` → `CommandExecutor.commitGraph({basis: explicit})`, targets the transcript binding's spec, and publishes graph invalidations; explicit interruptions are transcript-visible but do not capture or silently answer a pending exchange. Broader LLM capture quality and readiness-grade updates remain fitness evidence. | +| A24-L | A flat `elicitation_backlog` table (prospective memory) is sufficient to drive elicitor questioning and seed grounding without graph structure — no `unknown` plane/node and no unknown→unknown edges; apparent dependency among open questions is mediated by the claims their resolution produces. | medium | open | D65-L | The seeded grounding loop plus capture-reflection across elicitation fixtures; if genuine unknown→unknown dependency or rich traversal emerges, promote the table to a plane (rows→nodes, FK pointers→edges). | ### Active Decisions @@ -127,26 +128,27 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **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 sealed Pi settings plus an explicit Brunch extension bundle around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. `src/.pi/brunch-pi-settings.ts` owns settings policy, resource-loader policy, and offline defaults: it 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, 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. `src/.pi/brunch-pi-extensions.ts` owns the explicit Brunch extension factory: it statically imports product extension registrars and registers them from a fixed ordered list rather than ambient discovery. That explicit list 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 extension bundle 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 settings boundary 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 sealed-Pi 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 extension list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. - Tooling exception: the worktree helper extension now lives outside this repository under the user Pi agent tree (`~/.pi/agent/extensions/worktree/index.ts`) for direct Pi sessions only. It is not a Brunch product extension, is not imported by `src/.pi/brunch-pi-extensions.ts`, and does not weaken the sealed Brunch Pi settings/extensions boundary; Brunch-launched product sessions continue to disable ambient `.pi/` discovery unless deliberately imported. The extension may register direct-Pi `/worktree:switch` / `switch_worktree` and `/worktree:create` / `create_worktree` affordances, but Brunch does not test, package, or document it as a product extension. -- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection and `src/projections/session/runtime-policy.ts` owns operational-mode/role policy definitions. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. +- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection and `src/projections/session/runtime-policy.ts` owns operational-mode/role policy definitions. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). **Posture switches (durable `reason: "switch"` entries) are a user/system authority: the foreground agent never emits a posture switch.** The agent's only in-axis freedom is `AUTO` (per-turn implicit selection from the D58-L manifest); what it actually chose each turn is legible downstream via per-emission facet stamping (D25-L), not via runtime-state — so runtime-state is the *frame/constraints* while emitted facets carry the agent's per-turn choice. User-mutable axes are `op_mode`, `strategy`, and `lens`; `goal` is internal/grade-derived and not part of the user posture-change surface for now (D59-L). On a parent switch that invalidates a child axis, the child defaults to `AUTO`. The `source: "agent"` entry value is reserved — no current path emits it; it is parked for a future execute-mode orchestrator that might legitimately steer sub-postures. `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/commands/policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. -- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state 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 discovered project name, selected spec, and real activated session id/label, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. -- **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, 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 transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. +- **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state value rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the discovered project name, selected spec, and real activated session id/label, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. +- **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, query bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. #### Data model & vocabulary - **D3-L — Graph-native, session-native vocabulary; no generic `records.*` surface.** Commands converge on `graph.*` / `session.*` (with per-plane families `intent.*`, `oracle.*`, `design.*`, `plan.*` available when sharper semantics are useful). Depends on: A6-L. Supersedes: —. - **D7-L — ~~`framing_as` modality, not first-class kinds.~~ Retired.** `framing_as` is absorbed by first-class `thesis`, `term`, `constraint`, and `goal` kinds per the Phase 2 node lock. No node carries a `framing_as` field. See [`docs/design/GRAPH_MODEL.md` §framing_as — retired](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#framing_as--retired). Depends on: A7-L (retired). Superseded by: D54-L, D56-L. -- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and a bounded coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same spec-local LSN as their owning spec's change log and follow the same mutation invariant. Per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge), each need targets exactly one of `{kind: 'edge', edgeId}` or `{kind: 'node_pair', aId, bId}` and is not itself a graph edge. For the POC, coherence is not an unbounded aesthetic or philosophical judgment; it is the product-visible verdict produced from structural legality plus surfaced contradictions/gaps/unresolved needs, with the exact rubric still open under A21-L until M8 and the subtype split deferred per A8-L. Depends on: A8-L, A21-L. Refined by: D51-L. Supersedes: any `concerns`-edge wiring from reconciliation needs to graph nodes. +- **D8-L — Reconciliation needs are a first-class substrate alongside graph truth, change log, and a bounded coherence verdict.** Needs (impasses, gaps, contradictions, process debt) share the same spec-local LSN as their owning spec's change log and follow the same mutation invariant. Per [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#reconciliationneed--separate-substrate-not-a-graph-edge), each need targets exactly one of `{kind: 'edge', edgeId}` or `{kind: 'node_pair', aId, bId}` and is not itself a graph edge. For the POC, coherence is not an unbounded aesthetic or philosophical judgment; it is the product-visible verdict produced from structural legality plus surfaced contradictions/gaps/unresolved needs, with the exact rubric still open under A21-L until M8 and the subtype split deferred per A8-L. It is the *retrospective* coherence register (backward-looking repair after a mutation, worked async by the reviewer, D29-L); the *prospective* elicitation backlog register is a distinct substrate (D65-L). Depends on: A8-L, A21-L. Refined by: D51-L. Supersedes: any `concerns`-edge wiring from reconciliation needs to graph nodes. - **D9-L — Reasoning records split by shape.** `decision` is graph-native; `impasse` is a reconciliation need, not a graph node; `justification` stays compact (rendered text on the decision) until forced otherwise. Phase 2 (per `docs/design/GRAPH_MODEL.md`) keeps `decision` as a plain node rather than a hyper-edge / hub-node for the POC. Depends on: D8-L. Supersedes: —. -- **D54-L — Graph node shape is a common flat interface with `kind_ordinal`, `title`, `body`, `basis`, `source`, and a per-kind `detail` JSON column; canonical contract is [`docs/design/GRAPH_MODEL.md` §GraphNode](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#graphnode--the-single-shape).** All planes and kinds share one `nodes` table. `id` is the internal SQLite integer/FK identity; `kind_ordinal` is the monotonic per-`(spec, plane, kind)` ordinal used with `kind` to project a stable human reference code (D62-L). The rendered code string is not stored in the database. `plane` determines which closed `kind` enum applies; `kind` is structurally validated. `basis ∈ explicit | implicit` records item-level approval strength per D63-L. `source` is a free-form string for epistemic attribution (e.g. "stakeholder", "regulatory", "derived", "agent synthesis") — convention by prompt, not structural validation; it exists for context-snapshot enrichment and will be rendered back into sparse text, not used for policy or filtering. `detail` is an optional JSON column with per-kind validated sub-structures: `decision` requires `{ chosen_option, rejected, rationale }`, `term` requires `{ definition, aliases? }`; all other kinds must omit `detail`. `provenance` is retired from the node shape — `change_log` at `createdAtLsn` owns the audit trail, while `basis` and `source` carry only local interpretation fields. The intent kind rubric (modality of claim + source question per kind) is agent-facing prompting guidance in GRAPH_MODEL.md §"Prompting guidance for kind discrimination", not structural enforcement. Depends on: D4-L, D16-L, D52-L, D56-L, D62-L, D63-L. Supersedes: D7-L (`framing_as` modality), the deferred Phase 2 node placeholder in prior GRAPH_MODEL.md. +- **D54-L — Graph node shape is a common flat interface with `kind_ordinal`, `title`, `body`, `basis`, `source`, and a per-kind `detail` JSON column; canonical contract is [`docs/design/GRAPH_MODEL.md` §GraphNode](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#graphnode--the-single-shape).** All planes and kinds share one `nodes` table. `id` is the internal SQLite integer/FK identity; `kind_ordinal` is the monotonic per-`(spec, plane, kind)` ordinal used with `kind` to project a stable human reference code (D62-L). The rendered code string is not stored in the database. `plane` determines which closed `kind` enum applies; `kind` is structurally validated. `basis ∈ explicit | implicit` records item-level approval strength per D63-L. `source` is a free-form string for epistemic attribution (e.g. "stakeholder", "regulatory", "derived", "agent synthesis") — convention by prompt, not structural validation; it exists for context-render enrichment and will be rendered back into sparse text, not used for policy or filtering. `detail` is an optional JSON column with per-kind validated sub-structures: `decision` requires `{ chosen_option, rejected, rationale }`, `term` requires `{ definition, aliases? }`; all other kinds must omit `detail`. `provenance` is retired from the node shape — `change_log` at `createdAtLsn` owns the audit trail, while `basis` and `source` carry only local interpretation fields. The intent kind rubric (modality of claim + source question per kind) is agent-facing prompting guidance in GRAPH_MODEL.md §"Prompting guidance for kind discrimination", not structural enforcement. Depends on: D4-L, D16-L, D52-L, D56-L, D62-L, D63-L. Supersedes: D7-L (`framing_as` modality), the deferred Phase 2 node placeholder in prior GRAPH_MODEL.md. - **D55-L — `provenance` retired from both edges and nodes; `change_log` owns audit trail and mutation path.** Transcript entry pointers (`sessionId`, `entryId`, `proposalEntryId`) are fragile under compaction and redundant with `change_log` keyed by `createdAtLsn` / `updatedAtLsn`. `basis` does **not** encode the transport or strategy path; per D63-L it records whether the exact graph item was user-approved (`explicit`) or agent-materialized after concept-level approval (`implicit`). `change_log.operation` and payload record the durable mutation context (`create_node`, `commit_graph`, `accept_review_set`, etc.). Edges retain `basis` and `rationale`; nodes retain `basis` and `source` (epistemic attribution). Depends on: D16-L, D51-L, D54-L, D63-L. Supersedes: `EdgeProvenance` from Phase 1 edge lock, the planned node-side `provenance` symmetry with edges, and the former `accepted_review_set` basis-as-path enum. - **D56-L — Intent node kinds: 11 kinds in 3 derived semantic categories (basic / structural / reasoning); canonical contract is [`docs/design/GRAPH_MODEL.md` §Per-plane node kinds](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#per-plane-node-kinds).** `basic` (goal, thesis, term, context) carries grounding material; `structural` (requirement, assumption, constraint, invariant) carries core specification; `reasoning` (decision, criterion, example) carries decisions and evidence. This category is a pure function of intent `kind` — not stored on the node — and remains distinct from the cross-plane readiness-band metadata in D64-L. `thesis` carries "what/who/why/for whom" material (La Carte Blanche style). `term` carries canonical naming commitments (ubiquitous language). `invariant` is first-class (not a constraint subtype) because its operational role differs: invariants get `dependency` and `proof` edges, constraints get `boundary` edges. Each intent kind has a modality-of-claim and source-question rubric for agent prompting (GRAPH_MODEL.md §"Prompting guidance"). Oracle (check, validation_method, evidence, obligation), design (module, interface), and plan (milestone, frontier, slice) kinds are stable from worked examples and receive prefix/readiness-band metadata through D62-L/D64-L. Depends on: D54-L, D62-L, D64-L. Supersedes: D7-L (`framing_as`), A7-L. - **D57-L — Spec-grade grounding gate is LLM-judged satisficiency over readiness-band evidence with a count floor, not a hard kind whitelist.** The gate from `grounding_onboarding` toward `elicitation_ready` is not structurally enforced by rubric coverage checks. The agent judges readiness using prompt-embedded abstract drivers (Walter-style: what is it, who is it for, what problem, what value, when used, how measured) plus D64-L readiness-band evidence. The grounding threshold centers on grounding-band nodes such as `goal`, `thesis`, `term`, and `context`, and may also count grounding-relevant constraints because a constraint anchor can be part of the frame. The agent cannot declare grounding complete with zero grounding-band graph evidence, but obvious lower-grade `requirement`, `criterion`, `check`, or design nodes may still be captured when the user clearly gives them; those nodes simply do not by themselves prove the grounding threshold. Grounding elicitation may establish workspace posture, but posture is not a spec-row field or graph node kind in the POC. Depends on: D45-L, D56-L, D64-L. Supersedes: D30-L grounding-bundle anchor vocabulary as the sole readiness gate description. Refines: D30-L, D45-L. - **D51-L — Graph edge model is a closed structural-category set with a separate ReconciliationNeed substrate; canonical contract is [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md).** Every accepted edge is one of eight closed categories (`dependency`, `proof`, `support`, `realization`, `boundary`, `composition`, `association`, `supersession`); `stance: for | against` is valid only on `proof` and `support`; `basis ∈ explicit | implicit` follows D63-L (no `inferred`, no `accepted_review_set` path value). Accepted edges have no mutable `status` field — `proposed` lives in review-set drafts, `rejected` is absent + change-log audit, `stale` is represented by a `ReconciliationNeed`. Identity fields (`category`, `sourceId`, `targetId`, `stance`) are immutable on an accepted edge; a "category change" is delete + recreate. `supersession` chains are acyclic and the `CommandExecutor` must validate acyclicity against existing same-spec edges plus proposed batch edges. Only `dependency` cascades automatically; other categories surface advisory recon-needs rather than auto-blocking. Cross-plane edges are unrestricted at the POC stage; `realization` subtypes (implementation/establishment/assertion/etc.) may be derived from node-tuple lookup later rather than encoded on the edge. `ReconciliationNeed` is a separate substrate whose target is exactly `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` — it is not itself a graph edge. Depends on: D4-L, D8-L, D16-L, D27-L, A14-L, D63-L. Supersedes: the named-relation catalogue in `docs/architecture/pi-seam-extensions.md` §"Edge types" (`validates`, `instance_of`, `produces`, `discharges`, `depends_on`, `derived_from`, `counterexample_for`, `witnesses`), the per-relation policy registry / lookup, the brainstormed expanded edge taxonomy in `archive/docs/design/GRAPH_EDGE_CATEGORIES.md`, any `concerns`-edge wiring from reconciliation needs to graph nodes, and the former `accepted_review_set` edge-basis value. - **D61-L — A spec is an initiative answering a problem; its truth-bearing units are claims resolved at node level.** A spec's identity is its problem-answering initiative, not the product areas, seams, or domains it touches; it may reach a done-state while those keep evolving. Its truth-bearing units ("claims") are the existing `structural` and `reasoning` intent node kinds (requirement, assumption, constraint, invariant, decision, criterion, example) under D54-L/D56-L — `claim` is a vocabulary umbrella, not a new node kind — so revision, conflict, and supersession resolve at node level (supersession edges per D51-L), not at whole-spec level. POC scope: each spec owns its own intent graph (no cross-spec claim sharing); the `workspace → spec → session` hierarchy (D11-L) is unchanged and `spec.readiness_grade` (D45-L) remains the only persisted spec-state — no initiative-status column is added. The full initiative/claim model (cross-spec claim survival/adoption, initiative-status lifecycle, spec-to-spec relationships, current-truth-as-projection) is deferred to Future Direction §Spec initiative & claim model; rationale: [`docs/design/SPEC_INITIATIVE_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/SPEC_INITIATIVE_MODEL.md). Depends on: D11-L, D45-L, D54-L, D56-L. Supersedes: —. -- **D62-L — Graph nodes have stable spec-scoped human reference codes projected from stored `kind_ordinal`, separate from integer storage IDs.** `NodeId` remains the SQLite integer primary key/FK used internally. The database stores `kind` and `kind_ordinal`; user/agent-facing handles such as `G1`, `CON2`, `R3`, `CR4`, `VM1`, or `SL2` are projection strings formed by a hard-coded presentation lookup from `kind` to a 1–3 capital-letter label plus `kind_ordinal`. The rendered code string is not a graph column. Labels are unique across all node kinds so `#`-mentions can parse by longest-prefix match, then resolve to `(kind, kind_ordinal)` and finally to `NodeId`. `kind_ordinal` is monotonic per `(spec_id, plane, kind)`, allocated by the `CommandExecutor` in the same transaction as node creation from a counter row (`node_kind_counters` or equivalent), not by `MAX(kind_ordinal)+1`; ordinals are never reused after deletion or supersession. DB constraints must make `(spec_id, plane, kind, kind_ordinal)` unique; there is no `(spec_id, code)` uniqueness constraint because `code` is not stored. Snapshots and prompt contexts should render projected codes as primary handles and reserve raw integer IDs for internal diagnostics/adapters. Depends on: D14-L, D16-L, D20-L, D54-L, D56-L, D61-L. Supersedes: the string-`NodeId` examples in earlier GRAPH_MODEL text and the previous app's application-only `MAX(kind_ordinal)+1` allocation pattern. -- **D63-L — Graph `basis` records item-level approval strength, not the mutation pathway.** Accepted nodes and edges use `basis ∈ explicit | implicit`. `explicit` means the user directly stated the graph item or approved the exact node/edge in a review set; `implicit` means the user accepted a concept/proposal and the agent materialized specific graph items to match it without per-item review (the `propose-graph` direct-commit path). The mutation pathway lives in `change_log.operation` and payload (`commit_graph`, `accept_review_set`, post-exchange capture, etc.), while epistemic attribution lives in `Node.source` and proposal UI metadata may still carry `epistemic_status`. Low-confidence inferred material is still not graph truth; it remains in preface/capture analysis/review drafts/reconciliation needs until clarified or accepted. Depends on: D26-L, D27-L, D53-L, D54-L, D55-L. Supersedes: `basis = accepted_review_set` as a persisted graph enum value and any interpretation of `basis` as a provenance/path field. -- **D64-L — Readiness bands are non-exclusive derived node-kind groupings used for elicitor goals, snapshots, and grade rubrics; they are not structural legality gates.** Bands are `grounding`, `elicitation`, and `commitment`. A node kind may belong to multiple bands (for example `constraint` can contribute to grounding when it is the constraint anchor and to elicitation when it bounds solution space). Bands guide what the elicitor is trying to complete at a given `readiness_grade`, what graph filters/snapshots can show, and what evidence a readiness validator considers. The `CommandExecutor` must not reject a clear `requirement`, `criterion`, `check`, design node, or other later-band kind merely because the spec is at an earlier grade; readiness controls objectives and unlocks, not what graph truth may contain. Depends on: D45-L, D56-L, D57-L, D59-L, D60-L. Supersedes: treating the intent `basic | structural | reasoning` category as the readiness taxonomy or treating readiness as a per-kind creation whitelist. +- **D62-L — Graph nodes have stable spec-scoped human reference codes projected from stored `kind_ordinal`, separate from integer storage IDs.** `NodeId` remains the SQLite integer primary key/FK used internally. The database stores `kind` and `kind_ordinal`; user/agent-facing handles such as `G1`, `CON2`, `R3`, `CR4`, `VM1`, or `SL2` are projection strings formed by a hard-coded presentation lookup from `kind` to a 1–3 capital-letter label plus `kind_ordinal`. The rendered code string is not a graph column. Labels are unique across all node kinds so `#`-mentions can parse by longest-prefix match, then resolve to `(kind, kind_ordinal)` and finally to `NodeId`. `kind_ordinal` is monotonic per `(spec_id, plane, kind)`, allocated by the `CommandExecutor` in the same transaction as node creation from a counter row (`node_kind_counters` or equivalent), not by `MAX(kind_ordinal)+1`; ordinals are never reused after deletion or supersession. DB constraints must make `(spec_id, plane, kind, kind_ordinal)` unique; there is no `(spec_id, code)` uniqueness constraint because `code` is not stored. Context renders and prompt contexts should use projected codes as primary handles and reserve raw integer IDs for internal diagnostics/adapters. Depends on: D14-L, D16-L, D20-L, D54-L, D56-L, D61-L. Supersedes: the string-`NodeId` examples in earlier GRAPH_MODEL text and the previous app's application-only `MAX(kind_ordinal)+1` allocation pattern. +- **D63-L — Graph `basis` records item-level approval strength, not the mutation pathway.** Accepted nodes and edges use `basis ∈ explicit | implicit`. `explicit` means the user directly stated the graph item or approved the exact node/edge in a review set; `implicit` means the user accepted a concept/proposal and the agent materialized specific graph items to match it without per-item review (the `propose-graph` direct-commit path). The mutation pathway lives in `change_log.operation` and payload (`commit_graph`, `accept_review_set`, post-exchange capture, etc.), while epistemic attribution lives in `Node.source` and proposal UI metadata may still carry `epistemic_status`. Low-confidence inferred material is still not graph truth; it remains in preface/capture analysis/review drafts/reconciliation needs until clarified or accepted. More abstractly, `basis` is a *provenance-directness* marker — directly from the user (`explicit`) versus agent-materialized from user input (`implicit`) — of which item-level approval strength is the claim-flavored reading; this lets the same `explicit | implicit` distinction apply to non-claim registers such as the elicitation backlog (user-raised vs agent-inferred, D65-L). Depends on: D26-L, D27-L, D53-L, D54-L, D55-L. Supersedes: `basis = accepted_review_set` as a persisted graph enum value and any interpretation of `basis` as a provenance/path field. +- **D64-L — Readiness bands are non-exclusive derived node-kind groupings used for elicitor goals, context filters, and grade rubrics; they are not structural legality gates.** Bands are `grounding`, `elicitation`, and `commitment`. A node kind may belong to multiple bands (for example `constraint` can contribute to grounding when it is the constraint anchor and to elicitation when it bounds solution space). Bands guide what the elicitor is trying to complete at a given `readiness_grade`, what graph filters and rendered context can show, and what evidence a readiness validator considers. The `CommandExecutor` must not reject a clear `requirement`, `criterion`, `check`, design node, or other later-band kind merely because the spec is at an earlier grade; readiness controls objectives and unlocks, not what graph truth may contain. Depends on: D45-L, D56-L, D57-L, D59-L, D60-L. Supersedes: treating the intent `basic | structural | reasoning` category as the readiness taxonomy or treating readiness as a per-kind creation whitelist. +- **D65-L — The elicitation backlog is a prospective process-agenda register (the elicitor's "prospective memory"), distinct from both reconciliation needs and graph truth.** The single term `unknown` conflated two concepts with different ontological status and resolution mechanism: (a) a *process gap* — something the user has not answered yet, knowable now by asking — and (b) a *domain gap* — something nobody knows and cannot economically find out now (the deferred `risk` node, Future Direction §Vocabulary evolution). Only (a) drives elicitation, and it is modelled as an **`elicitation_backlog`** entry, not a graph node. The register is forward-looking but **async and unordered** — the name `elicitation_backlog` (chosen over `agenda`/`need`) signals that entries are logged opportunistically and need not drive the next turn: an entry logged now may only become relevant in a later grade or under a different lens. It is seeded at spec creation with grounding-band questions, read by the elicitor every turn to choose what to ask next, and grown by capture-reflection (each round may spawn new entries). Its resolution produces one of: a **claim** (answered → graph node), a **`risk`** (asked but unknowable → durable spec content, deferred), or **more entries**. It is the *prospective* sibling of the *retrospective* `reconciliation_need` coherence register (D8-L) — two registers, two loops; the elicitation register is the elicitor's per-turn agenda, the reconciliation register is the async reviewer's post-mutation repair queue (D29-L). It is a **flat table, not a graph plane/node**, because its only real relations are filter attributes (plane/lens affinity, D64-L grade-band, `open | closed` status) plus foreign-key pointers (`arose_from`, `resolved_by`); apparent unknown→unknown dependency ("answer B before A") is illusory — it is mediated by the claims that resolving a need produces, which already carry `dependency` edges (D51-L). A table with those FK pointers is a degenerate bipartite graph, forward-compatible with promotion to a plane only if genuine unknown→unknown structure later emerges; this keeps the locked graph-of-claims (D54-L/D56-L/D51-L) untouched and supplies the missing substrate for the "what to ask next" objective and generalized capture. `basis` applies via its provenance-directness reading (D63-L): a user-raised need is `explicit`, an agent-inferred need is `implicit`. Open for scope/slice design: the seed mechanism, whether mutations route through `CommandExecutor` and share the spec-local LSN, and whether the register thins or merely complements the `goal` axis (D59-L). Depends on: D8-L, D45-L, D59-L, D63-L, D64-L. Supersedes: treating `unknown` as a graph node kind or cross-plane node/plane for driving elicitation. #### Authority & mutation @@ -160,8 +162,8 @@ 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`; 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. Dev-only RPC harness methods may exist under `dev.*` when explicitly enabled for local fixture curation or seam testing; they are absent from normal product discovery, absent from read-only sidecars, and still route mutations through the owning command/session layer. They are not public product capabilities and must not occupy `graph.*`, `session.*`, or `workspace.*` names. Retired public names are quarantined in `src/rpc/README.md` §Names absent from current public RPC and must not re-enter product discovery. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model, vague `elicitation.*` / `command.*` public families, and any discovery/dispatch split where a surface describes methods it rejects. -- **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders 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. +- **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes concrete named methods, not vague feature buckets or generic records. The canonical public RPC vocabulary is maintained in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md): `rpc.discover`; `workspace.state`, `workspace.selectionState`, `workspace.activate`; current selected-spec graph reads `graph.overview` and `graph.nodeNeighborhood`; and session methods `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.submitMessage`, `session.exchanges`, and `session.runtimeState`. Reserved future target names such as `graph.changesSince` and graph-adjacent `graph.coherenceSummary` must not appear in discovery until real behavior exists. Each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/workspace.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch-owned command/session layer. `brunch.updated` notifications are first-class JSON-RPC records over WebSocket and stdio, but they are process-local invalidation hints carrying `{topic, specId?, sessionId?, nodeId?, lsn?}`, not canonical truth and not a durable cross-store event spine. Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Dev-only RPC harness methods may exist under `dev.*` when explicitly enabled for local fixture curation or seam testing; they are absent from normal product discovery, absent from read-only sidecars, and still route mutations through the owning command/session layer. They are not public product capabilities and must not occupy `graph.*`, `session.*`, or `workspace.*` names. Retired public names are quarantined in `src/rpc/README.md` §Names absent from current public RPC and must not re-enter product discovery. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model, vague `elicitation.*` / `command.*` public families, and any discovery/dispatch split where a surface describes methods it rejects. +- **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders current product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/workspace.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. Product RPC / Pi relay model: @@ -204,7 +206,7 @@ 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.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. +- **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); `session.submitMessage` handles ordinary non-exchange user text and explicit interruptions, rejecting ordinary text while a structured exchange is pending unless the payload marks an explicit interruption, and never silently answering a pending exchange; 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 @@ -230,7 +232,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **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/exchanges/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, session-triggered present/request emissions, and the intentional RPC/editor relay now construct details through canonical `src/exchanges/project/*` projectors and render durable markdown through `src/exchanges/format/*` renderers where a formatter exists. 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 Zod-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 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 graph-code 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 graph node code from D62-L (`#G1`, `#CON2`, `#R3`, `#CR4`, etc.) 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 these handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. The ledger stores internal `(entity_id, snapshotted_lsn)` pairs, not titles or raw code strings alone, and drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, D62-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata or using raw DB ids as user-facing handles. +- **D14-L — `#`-mentions are stable graph-code 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 graph node code from D62-L (`#G1`, `#CON2`, `#R3`, `#CR4`, etc.) 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 these handles; Brunch-owned parsing/indexing, not Pi autocomplete, creates mention-ledger state. The ledger stores internal `(entity_id, seen_lsn)` pairs, not titles or raw code strings alone, and drives discretionary `brunch.mention_staleness_hint` entries in `prepareNextTurn`. Depends on: A9-L, D62-L, I4-L. Supersedes: assuming Pi autocomplete persists hidden mention metadata or using raw DB ids as user-facing handles. - **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; graph items directly stated by the user are written with `basis: explicit`. (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); accepted exact items are written with `basis: explicit`. (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 — so those materialized nodes/edges are written with `basis: implicit` (D63-L). 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, D63-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. - **D30-L — Grounding advances readiness for main elicitation; strategies remain available with honest epistemic signaling.** A minimum grounding bundle — *domain anchor*, *protagonist anchor*, *pain/pull anchor*, *constraint anchor* — establishes the frame required to move the spec from `grounding_onboarding` toward `elicitation_ready`. Lenses and strategies are not refused merely because grounding is thin, but their output resolution and epistemic load must honestly reflect what grounding supports: speculative outputs are visibly hedged and lower-authority, while grounded outputs may drive capture and later review-set projection. Grounding coverage should be explicit in offers/proposals where it affects confidence or gate transitions. Depends on: D26-L, D45-L. Supersedes: gating-by-refusal as a UX move and over-focusing readiness on generative lenses alone. @@ -246,15 +248,16 @@ 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 under the discovered project name 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 Pi `session_info` presentation metadata, not spec identity.** Brunch-created sessions should be named at creation with neutral workspace-global defaults (`Untitled Session 1`, `Untitled Session 2`, …) so pickers/chrome never show an unnamed Brunch session and unchanged defaults do not collide across specs in the same cwd. These defaults are immediate lifecycle metadata, not LLM-generated summaries and not derived from the selected spec title. Brunch may later use Pi session lifecycle hooks to opportunistically replace a default with a short human-readable name that characterizes what happened in the transcript. The preferred generation 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/user rename command can force or override naming. The generation 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 preserve the existing default/user label rather than blocking session replacement or exit. Session display 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, leaving Brunch-created sessions unnamed, spec-local default numbering, or treating generated session names as canonical spec identity. -- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `.pi/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 `.pi/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/.pi/{agents,skills}/`; the `{name, description, location}` triples are code-owned in `.pi/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. `.pi/agents/` is the keyed agent prompt assembly layer (`definitions/`, `contexts/`); `.pi/skills/` carries goal/strategy/lens/method resources; `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (code), not a manifest resource family or general renderer bucket. Reusable text renderers may migrate to `renderers/` under D52-L. 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; top-level `src/agents/` for Pi-only agents; 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, distinguishes graph-truth from active-context projections, 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/list/query), or `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. Graph pulls must make the projection explicit: `graph_truth` includes accepted truth records, while `active_context` hides superseded predecessors and must also omit edges whose endpoints are hidden so snapshots do not contain dangling references. The graph read family should support the observed query shapes without becoming a generic records API: list nodes by kind(s), list nodes by D64-L readiness band(s), and find nodes related to anchor node(s) by edge category/direction/hop depth. **RENDER** turns the typed value into either an LLM-friendly string or JSON (trivial serialization). Reusable lossy text/markdown rendering belongs in `renderers/`; `.pi/agents/contexts/` owns the agent-context orchestration decision — which typed pull to expose, how much detail to include, and how lens-plane/grade-depth shape the prompt-facing string — and may call reusable renderers. Rendered projected stable node codes (D62-L) remain the primary handles. **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (thin `snapshot-*` / graph-read 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, D62-L, D64-L. Supersedes: pre-rendering snapshots to strings in the pull layer, scattering snapshot build logic across `graph/`, `.pi/agents/contexts/`, and the `snapshot-*` tool stubs, or silently mixing graph-truth and active-context reads. +- **D58-L — Brunch prompt composition is a thin runtime header plus a gated prompt-resource manifest, not eager selection of every objective pack.** `.pi/agents/compose(agentId, sessionState, spec, workspace, context)` 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 `.pi/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/.pi/{agents,skills}/`; the `{name, description, location}` triples are code-owned in `.pi/agents/state.ts`, not filesystem-discovered, honoring D39-L sealing; **(4) compact pushed context** — only the minimal context handles and rendered context needed to orient the turn, with deeper context access 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. `.pi/agents/` is the keyed agent prompt assembly layer (`definitions/`, `contexts/`); `.pi/skills/` carries goal/strategy/lens/method resources; `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (code), not a manifest resource family or general renderer bucket. Reusable text renderers may migrate to `renderers/` under D52-L. 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; top-level `src/agents/` for Pi-only agents; 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. For now `goal` is **internal/grade-derived and not part of the user posture-change surface** (it is too contingent to expose as a user-mutable axis); the pin affordance is reserved for system/internal logic, and unlike `strategy`/`lens` the user does not switch it (D40-L, Q4). `elicit-expand` and `commit-converge` intentionally form the diverge/converge pair for the elicitation diamond; `elicit-I` / `elicit-II` are retired because they were phase-like labels, not objectives. "Advance the grade" is a goal, not a strategy — though the `grounding-advance` goal may carry a dedicated default interaction pattern. Depends on: D45-L, D57-L, D58-L. Supersedes: conflating the elicit-lifecycle objective with strategy selection. +- **D66-L — `freestyle` is a structure-optional elicitation strategy; it and generalized free-text capture are one slice.** `freestyle` joins the strategy axis (D25-L) as a fifth value alongside `step-wise-decision-tree`, `step-wise-disambiguate`, `propose-graph`, and `project-graph`. The four existing strategies impose structured-exchange turn discipline (offer-first `present_*`/`request_*` ritual, D37-L); `freestyle` makes that discipline *optional* — the turn may be ordinary user-driven chat, structured-exchange tools remain available (not prohibited), and user-invoked slash/skill-commands are ergonomic here precisely because no pending structured exchange is consuming the turn. It is **initiative/interaction-style, not authority**: it is not a new `op_mode`, adds no tool authority, and `op_mode`-gated tool policy (D40-L) is unchanged. Because freestyle has no mandatory exchange, the only way it grows graph truth is **generalized capture**, so the two land together: post-exchange capture (D18-L) is now wired onto the ordinary-message path (`session.submitMessage`, D49-L) over the same `session exchange` unit — which already spans plain user text — routing high-confidence directly-stated facts through `CommandExecutor.commitGraph({basis: explicit})` exactly as the structured-response capture tracer does, while low-confidence implications stay in preface / `capture_*` analysis (D47-L, D50-L) and never become graph truth. Freestyle therefore *composes with*, and does not replace, the `goal` (D59-L) and `lens` (D25-L) axes: the user still pursues `grounding-advance` / `elicit-expand` / etc., just through free chat, and freestyle capture can both resolve and spawn `elicitation_backlog` entries (D65-L). **AUTO must not select `freestyle`** — it is an explicit user pin only (a "let me just talk" escape hatch); the runtime manifest now omits it under AUTO while still allowing explicit pins, so spontaneous AUTO entry cannot silently abandon the offer-first product thesis (R16). Remaining open quality questions are limited to capture scope beyond directly-labeled facts (fitness evidence under A22-L, materially harder without a structured prompt), whether capture eventually runs on every freestyle turn or on demand, and the exact slash/skill-command surface (the Q6 method-vs-command question). Depends on: D18-L, D25-L, D26-L, D40-L, D45-L, D49-L, D50-L, D59-L, D63-L, D65-L. Refines: R16. Supersedes: treating offer-first (R16) as a universal per-turn session invariant; treating freestyle as a new operational mode or authority posture. +- **D60-L — Agent context splits into pull / projection / render / surface, distinguishes graph-truth from active-context reads, and keeps `workspace.state` separate.** **Agent context** = content the agent reasons over: `cwd` (filesystem kickoff heuristic — `.brunch?`, session count/length, README/markdown sizes, file counts), `graph` (overview/list/query), or `node` (variable-hop neighborhood). **PULL** is typed, read-only data access owned by the data layer (`graph/queries.ts` for graph/node; `session/` for cwd) and bypasses `CommandExecutor` (reads only); the typed value *is* the JSON form. Graph pulls must make the read projection explicit: `graph_truth` includes accepted truth records, while `active_context` hides superseded predecessors and must also omit edges whose endpoints are hidden so active-context reads do not contain dangling references. The graph read family should support the observed query shapes without becoming a generic records API: list nodes by kind(s), list nodes by D64-L readiness band(s), find nodes related to anchor node(s) by edge category/direction/hop depth, and find class-members lacking an edge of a given category in a given direction (gap query — a single named absence shape, not a generic NOT-predicate language). **PROJECTION** is optional info-preserving shaping for reusable DTOs; when multiple adapters need the same structured view, it belongs in `projections/`, but many callers can consume the typed read directly. **RENDER** turns a typed or projected value into either an LLM-friendly string or JSON (trivial serialization). Reusable lossy text/markdown rendering belongs in `renderers/`; `.pi/agents/contexts/` owns the agent-context orchestration decision — which typed pull to expose, how much detail to include, and how lens-plane/grade-depth shape the prompt-facing string — and may call reusable renderers. Rendered projected stable node codes (D62-L) remain the primary handles. **SURFACE** delivers it: *pushed* (compose injects at turn boundary), *pulled* (`read_graph`, `read_workspace_context`, `read_session_context` wrap the relevant reads/renderers — markdown in `toolResult.content`, typed JSON in `toolResult.details` per I33-L), or *rpc/ui*. The separate **workspace projection** (`workspace.state` — workspace/session/spec/chrome product state) is a different subject and keeps that name. Depends on: D35-L, D52-L, D53-L, D62-L, D64-L. Supersedes: pre-rendering context strings in the pull layer, scattering context-build logic across `graph/`, `.pi/agents/contexts/`, and tool adapters, or silently mixing graph-truth and active-context reads. ### Critical Invariants | # | Invariant | Protected by | Proves | | --- | --- | --- | --- | -| I1-L | One spec-local LSN per selected-spec commit; every persisted spec has exactly one `graph_clock` row; every change-log entry, graph-node version, and reconciliation-need in that spec carries an LSN strictly monotonic with that spec's graph clock. Bare LSNs are not comparable across sibling specs. | partially covered (`CommandExecutor`, migration, `commitGraph`, snapshot, RPC, prompt-context, and seed-fixture tests prove local allocation, one-row clock ownership, sibling isolation, and missing-clock invariant failure) | D4-L, D6-L, D8-L | +| I1-L | One spec-local LSN per selected-spec commit; every persisted spec has exactly one `graph_clock` row; every change-log entry, graph-node version, and reconciliation-need in that spec carries an LSN strictly monotonic with that spec's graph clock. Bare LSNs are not comparable across sibling specs. | partially covered (`CommandExecutor`, migration, `commitGraph`, graph queries, RPC, prompt-context, and seed-fixture tests prove local allocation, one-row clock ownership, sibling isolation, and missing-clock invariant failure) | D4-L, D6-L, D8-L | | I2-L | All durable graph mutations originate from the Brunch command layer; no caller bypasses validation, audit, or coherence triggering. | planned (M5 architectural test + lint rule) | D4-L | | I3-L | Transcript reload reproduces raw assistant/user payloads plus Brunch session binding, structured elicitation entries, and other custom transcript entries byte-equivalently (modulo timestamps). | covered (M2 JSONL viability round-trip tests) | D6-L | | I4-L | For every `worldUpdate` entry, all named graph items have LSNs strictly greater than that session/spec's pre-update `lastSeenLsn`; the comparable watermark is `{specId, lsn}`. | planned (M7 property test) | D6-L, I1-L | @@ -278,23 +281,23 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | 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 structured-exchange tools (registered sequential `present_question`, `present_options`, `present_review_set`, `request_answer`, `request_choice`, `request_choices`, and `request_review`; runtime details are emitted from canonical `schema`/`v`/snake_case Zod shapes; 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, review-set `nodes`/`edges` details parity, invalid review proposal non-recovery, review pending-exchange recovery, public-RPC deterministic permutations, capture response-to-graph proof, 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, drift-rejection, and source-boundary tests for present/request/capture details. `present_candidates` remains a named stub 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 Brunch's sealed Pi settings/extension boundary explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch settings/extension boundary by contract tests: ambient resource flags and explicit extension factories are preserved; hostile ambient global/project settings are ignored by the in-memory Brunch settings policy before and after reload; audited Pi settings getters are tracked in `src/.pi/brunch-pi-settings.ts`. Subagent subprocess inheritance remains future coverage under I29-L. | D2-L, D39-L | -| I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory. | 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, including selected-spec grade activation for commitment-grade `present_review_set` / `request_review` proposal tools). | 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 values, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state, including selected-spec grade activation for commitment-grade `present_review_set` / `request_review` proposal tools). | D17-L, D23-L, D40-L, D58-L, D59-L | | I27-L | Session display names are presentation metadata only: every Brunch-created session gets a neutral workspace-global default `session_info` label (`Untitled Session N`) at creation, unchanged defaults do not collide across specs in one cwd, later user/generated names may replace the default, and no naming path mutates spec identity, session binding, or graph truth. | planned (creation/boundary tests for workspace-global default allocation across specs and replacement sessions; session-lifecycle naming tests with empty transcript/auth failure/success paths; picker/chrome projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear only in D41-L-acknowledged product/protocol schema seams such as `src/.pi/extensions/exchanges/schemas/`; TypeBox remains valid for unrelated Pi tool parameters, small config/frontmatter contracts, and future Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export and assert semantic details contracts stay in `src/.pi/extensions/exchanges/schemas/`; the legacy `shared/model.ts` details interface is retired; structured-exchange TypeBox usage is quarantined to the single Pi `TSchema` cast adapter in `src/.pi/extensions/exchanges/pi-schema.ts`; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor contract) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | -| I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec readiness-grade updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | partially covered (`src/graph/capture/structured-response.test.ts` accepts only directly labeled text facts for the current tracer, rejects implication-only prose as `no_capture`, preserves structural diagnostics, and `src/probes/capture-response-to-graph-proof.test.ts` proves public RPC response capture into selected-spec graph truth; reconciliation-needs and readiness-grade capture remain planned) | D18-L, D47-L; A22-L | +| I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified spec readiness-grade updates; low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | partially covered (`src/graph/capture/structured-response.test.ts` accepts only directly labeled text facts for the current tracer, rejects implication-only prose as `no_capture`, preserves structural diagnostics, `src/probes/capture-response-to-graph-proof.test.ts` proves public RPC response capture into selected-spec graph truth, and `src/probes/submit-message-capture-proof.test.ts` proves the same explicit-text capture path for ordinary `session.submitMessage` turns; reconciliation-needs and readiness-grade capture remain planned) | D18-L, D47-L; A22-L | | I31-L | `readiness_grade` is a forward gate, not a workflow location or kind whitelist: higher grades unlock later strategies/commitments/export paths but do not make earlier gathering/refinement invalid or unavailable, and the `CommandExecutor` must not reject a graph node solely because its kind belongs to a later readiness band. All grade mutations route through `CommandExecutor` and carry audit through the change log. | partially covered (`createSpec` / `getSpec` / `updateReadinessGrade` command tests cover storage and mutation audit; `src/.pi/agents/compose.test.ts` covers prompt-resource grade gates rejecting illegal pinned commitment selections and filtering AUTO availability; kind-vs-grade write permissiveness remains planned with graph-code/readiness-band work) | D20-L, D45-L, D64-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 (`command-executor/commit-graph-batch.test.ts` and graph-tool adapter tests cover dry-run/commit diagnostic parity for invalid basis, missing refs/codes, invalid category/stance, self-loop, invalid node kind/detail shape, rollback of nodes/edges/change_log/counters, transaction-local planning before LSN allocation/writes, and structured adapter diagnostics without thrown projected-code errors or fake endpoint refs) | 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 `.pi/agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | covered for current POC push path (`getGraphOverview` + `getNodeNeighborhood` in `snapshot.ts` with 10 tests; `src/.pi/agents/contexts/{graph,node,cwd}.test.ts` covers lens-shaped overview rendering, bounded node-neighborhood rendering, and selected-spec cwd/session/posture context; `src/.pi/__tests__/prompting.test.ts` proves the explicit shell/product prompt path supplies selected-spec-bound graph snapshots to `composeAgentPrompt()`). Pulled `snapshot-*` tools remain optional future surface. | D52-L, D53-L, D58-L | +| I35-L | Graph context reads support multiple detail levels: a cursory/compact full-graph overview for orientation, and detailed node-neighborhood context with configurable hop depth for focused work. Context builders in `.pi/agents/contexts/` orchestrate which level to inject or advertise based on mode/goal/strategy/lens/grade. | covered for current POC push path (`getGraphOverview` + `getNodeNeighborhood` in `queries.ts` with 10 tests; `src/.pi/agents/contexts/{graph,node,cwd}.test.ts` covers lens-shaped overview rendering, bounded node-neighborhood rendering, and selected-spec cwd/session/posture context; `src/.pi/__tests__/prompting.test.ts` proves the explicit shell/product prompt path supplies selected-spec-bound graph context to `composeAgentPrompt()`). Pulled context tools are part of the live read surface. | D52-L, D53-L, D58-L | | I36-L | Node `kind` is drawn from a per-plane closed enum structurally validated by the `CommandExecutor`; the intent kind category (basic / structural / reasoning) is a pure function of `kind` and is never stored on the node. | covered (CommandExecutor rejects invalid kind-for-plane; `intentKindCategory` is pure derivation with exhaustive switch; tests in `command-executor.test.ts`) | D54-L, D56-L | | I37-L | `detail` is per-kind validated by the `CommandExecutor`: `decision` and `term` nodes REQUIRE `detail` with their respective sub-schemas; all other kinds must omit `detail`; unknown fields in `detail` are rejected. | covered (detail-required/prohibited/shape tests in `command-executor.test.ts`) | D54-L | | I38-L | Every Brunch prompt-resource manifest injected for an agent turn is generated from projected runtime state and spec/workspace gates: listed resources are Brunch-owned, readable under the active tool policy, legal for the current `(op_mode × goal × strategy × lens)` / grade / agent allow-list, and off-list resources are not advertised as available. AUTO axes never list illegal choices; pinned axes point to the pinned resource. | covered for current P0 manifest families (`src/.pi/agents/compose.test.ts` covers default header/context/manifest output, AUTO grade/allow-list filtering, pinned singleton resources, illegal pinned grade rejection, and readable `src/.pi/` locations; `src/.pi/__tests__/prompting.test.ts` covers the explicit shell `before_agent_start` product path appending `agents/compose()` output from transcript-projected runtime state and no legacy composer import/resource discovery. Probe fitness may still track whether the agent reads selected resources before use.) | D39-L, D40-L, D58-L, D59-L | | I39-L | Every graph node in a spec has exactly one stable projected human reference code derived from `kind` + `kind_ordinal`; `(spec_id, plane, kind, kind_ordinal)` is unique; ordinals are monotonic per `(spec_id, plane, kind)` and are not reused after deletion or supersession. | partially covered (`graph-tool-resilience` added `nodes.kind_ordinal`, `node_kind_counters`, DB uniqueness, CommandExecutor allocation for single-node/batch writes, rollback protection, `GraphNode.kindOrdinal` row mapping, globally unique 1–3 letter labels with readiness-band metadata, projected-code parsing, selected-spec adapter resolution before `CommandExecutor`, code-only `commit_graph` / `read_graph` schemas, and code-primary prompt/tool rendering; remaining slice still needs deletion/supersession no-reuse coverage) | D54-L, D62-L; I1-L, I11-L | | I40-L | Accepted graph nodes and edges use only `basis ∈ explicit | implicit`; review-set approval and direct user statements produce `explicit`, `propose-graph` concept-level materialization produces `implicit`, and the mutation path is recoverable from `change_log` rather than from a persisted basis enum value such as `accepted_review_set`. | covered (`graph-tool-resilience` replaced the persisted basis enum with `explicit | implicit`, made `commitGraph` apply one batch approval basis to all created nodes/edges, made single-node `createNode` reject retired basis values before LSN/counter/node/change-log allocation, made `propose-graph` adapter commits implicit, made review-set translation explicit, rejected retired `accepted_review_set`, and records `change_log.operation` independently; `capture-response-to-graph` proves direct structured text responses commit explicit-basis graph nodes through `CommandExecutor`; `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves full review-cycle approval creates explicit-basis graph truth) | D26-L, D27-L, D53-L, D63-L | -| I41-L | Same-spec `supersession` edges form an acyclic directed graph; every edge-creation path validates proposed supersession edges together with existing supersession edges before committing. | covered (`command-executor/commit-graph-batch.test.ts` rejects existing-cycle closure, intra-batch cycles, and mixed existing+batch cycles through the shared dry-run/commit planner before batch writes; rejected cycles roll back or avoid batch nodes/edges/change_log; acyclic supersession commits remain covered by snapshot/CommandExecutor success paths) | D51-L, D53-L; I34-L | +| I41-L | Same-spec `supersession` edges form an acyclic directed graph; every edge-creation path validates proposed supersession edges together with existing supersession edges before committing. | covered (`command-executor/commit-graph-batch.test.ts` rejects existing-cycle closure, intra-batch cycles, and mixed existing+batch cycles through the shared dry-run/commit planner before batch writes; rejected cycles roll back or avoid batch nodes/edges/change_log; acyclic supersession commits remain covered by query/CommandExecutor success paths) | D51-L, D53-L; I34-L | ## Future Direction Register @@ -318,7 +321,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c ### Prompt/runtime profile architecture -- Brunch prompt composition is a **runtime-header + gated prompt-resource manifest** composed per agent by `.pi/agents/compose(agentId, sessionState, spec, workspace, snapshots)` (D58-L). The direct injection is intentionally small: agent control summary, runtime state, legal resource manifests (``, ``, ``, `` with `name`, `description`, `location`), router rules for pinned/AUTO axes, and compact context handles/rendered snapshots. Detailed goal/strategy/lens/method bodies are Brunch-owned markdown resources the agent loads with `read` when needed, matching Pi's skill-loading pattern while letting Brunch filter availability per turn. The old `src/.pi/context/` prompt-pack layout is retired; the old top-level `src/agents/` host-independent appearance is retired because these agents live only inside the Pi harness. +- Brunch prompt composition is a **runtime-header + gated prompt-resource manifest** composed per agent by `.pi/agents/compose(agentId, sessionState, spec, workspace, context)` (D58-L). The direct injection is intentionally small: agent control summary, runtime state, legal resource manifests (``, ``, ``, `` with `name`, `description`, `location`), router rules for pinned/AUTO axes, and compact context handles/rendered context blocks. Detailed goal/strategy/lens/method bodies are Brunch-owned markdown resources the agent loads with `read` when needed, matching Pi's skill-loading pattern while letting Brunch filter availability per turn. The old `src/.pi/context/` prompt-pack layout is retired; the old top-level `src/agents/` host-independent appearance is retired because these agents live only inside the Pi harness. - Concrete `.pi/{agents,skills}` topology (D52-L). The markdown/code boundary falls on the control-plane/behavior split: enforcement and projection are TypeScript under `.pi/agents/`; semantic prompting material is markdown under `.pi/agents/definitions/` and `.pi/skills/`. ```text @@ -335,13 +338,13 @@ src/.pi/ strategies/*.md [md] step-wise-decision-tree, step-wise-disambiguate, propose-graph, project-graph lenses/*.md [md] intent, design, oracle (future execute: plan, sync, scope) methods/*.md [md] run-structured-exchange, infer-and-capture, generate-proposal, - read-snapshot, commit-graph, review-for-gaps + read-context, commit-graph, review-for-gaps ``` - Manifest metadata is code-owned, not filesystem-discovered: `.pi/agents/state.ts` binds each legal axis value to its `{name, description, location}`, and `compose()` emits that binding; the agent `read`s the `.md` body at the listed `location` only when detail matters. This keeps the legal set and its labels in one tested place and honors D39-L sealing (no runtime resource discovery). Frontmatter-sourced manifest metadata is a deferred ergonomics option, not the POC mechanism. -- `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (TypeScript), surfaced as the header's compact pushed context or via the `snapshot-*` tools; reusable text renderers may migrate to `renderers/`, and contexts are not part of the `read`-on-demand resource manifest and carry no `` family. +- `.pi/agents/contexts/` is the D60-L agent-context orchestration layer (TypeScript), surfaced as the header's compact pushed context or via the read tools; reusable text renderers may migrate to `renderers/`, and contexts are not part of the `read`-on-demand resource manifest and carry no `` family. - Workspace **posture** is workspace-scoped product state persisted in `.brunch/workspace.json`, not spec state, session state, or graph truth. D57-L keeps it off the spec row and graph; D58-L composition injects known posture values into the runtime header as an axis of agent influence, and the `capture-posture` goal (D59-L) can confirm or refine those values conversationally. -- Readiness is an internal forward gate, not a user-facing workflow stepper, session-local phase, or graph-node-kind whitelist. `readiness_grade` lives on the spec row per D45-L; D64-L readiness bands describe non-exclusive evidence/rubric groupings for goal selection and snapshot filtering. Validators may warn when graph/transcript evidence and assigned grade diverge. Before readiness drives hard tool/agent authority beyond the POC, Brunch needs explicit rubrics for what evidence advances, blocks, or regresses grade. +- Readiness is an internal forward gate, not a user-facing workflow stepper, session-local phase, or graph-node-kind whitelist. `readiness_grade` lives on the spec row per D45-L; D64-L readiness bands describe non-exclusive evidence/rubric groupings for goal selection and context filtering. Validators may warn when graph/transcript evidence and assigned grade diverge. Before readiness drives hard tool/agent authority beyond the POC, Brunch needs explicit rubrics for what evidence advances, blocks, or regresses grade. - Prompt resources and Pi skills are both progressive-disclosure mechanisms, but they are not authority. Brunch code owns runtime-state projection, legal tuple filtering, grade/allow-list gating, tool activation, and tool-call blocking. Pi-native skills may be used for startup-scoped capabilities; runtime-state-specific objective/method availability is advertised through Brunch's per-turn manifest so ambient user/project resources cannot leak into product behavior. ### Coherence and readiness semantics @@ -354,6 +357,7 @@ src/.pi/ - Whether public graph commands eventually split from one `graph.*` umbrella into `intent.*` / `oracle.*` / `design.*` / `plan.*` namespaces is deferred; current posture is unified `graph.*` for the POC. - ~~Whether `framing_as` values graduate to first-class node kinds~~ — resolved: `framing_as` retired, absorbed by `thesis`, `term`, `constraint`, and `goal` (D54-L, D56-L). - `posture` is a workspace-level POC-stubbed property set for now; whether it earns richer persistence or graph-native representation is deferred until product pressure shows concrete readers beyond startup/prompt context. +- **Durable `risk` / `unknown` node (deferred).** A domain-epistemic gap — something nobody knows and cannot economically find out now — is distinct from the prospective elicitation backlog register (D65-L) and from `assumption` (which proceeds on a believed-but-unprovable value; a risk is upstream and must be structurally accommodated via the assumptions, decisions, design, verification, and planning it spawns). Because it carries real cross-plane edges, a `risk` — if adopted — is a first-class intent-plane node kind, not a table; deferred because it reopens the locked kind set (D54-L/D56-L), and the sub-question (own kind vs an `assumption` variant) needs its own pass. Modelled on the prior prototype's `risk` node. ### Thin transport/read posture @@ -378,7 +382,7 @@ src/.pi/ ### Chrome surface evolution -- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch now uses `setTitle()` narrowly as part of the D35-L chrome wrapper: the title is a stateless project-first projection from the activated product snapshot (`brunch — ` with selected-spec context when space/surface allows), and must not synthesize working-state it does not have. Richer title states tied to active role/lens/workflow remain deferred until stable producers exist. Hidden-thinking-label remains deferred: candidate labels vary by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…") and depend on the relevant subsystems (agent-role dispatcher, lens registry) landing first. +- **Title and hidden-thinking-label as state-indicative chrome.** Pi exposes `ctx.ui.setTitle()` and `ctx.ui.setHiddenThinkingLabel()` as small dynamic chrome surfaces. Brunch now uses `setTitle()` narrowly as part of the D35-L chrome wrapper: the title is a stateless project-first projection from the activated product state (`brunch — ` with selected-spec context when space/surface allows), and must not synthesize working-state it does not have. Richer title states tied to active role/lens/workflow remain deferred until stable producers exist. Hidden-thinking-label remains deferred: candidate labels vary by agent role or lens (e.g. "Eliciting…", "Reviewing batch…", "Reconciling…") and depend on the relevant subsystems (agent-role dispatcher, lens registry) landing first. - **Status keys as the dynamic contribution channel.** `ctx.ui.setStatus(key, text)` remains the multi-extension-friendly seam for other Brunch extensions and future dynamic Brunch state to surface in the footer's status row. Brunch's chrome wrapper does not contribute its own status key by default; it merges all foreign status entries via `footerData.getExtensionStatuses()` into the footer's right column so contributions surface without anyone owning the whole footer. ### Planning persistence evolution @@ -399,21 +403,21 @@ src/.pi/ | **Agent role** | A worker identity. The **foreground session-agent role** (`elicitor` now, future `executor`) drives the main turn and is *derived* from `op_mode`, not stored as session state. **Side/sub-agent roles** (`reviewer`, `reconciler`, future `scout`/`researcher`) run async/advisory or delegated work out-of-band and are never part of the session state machine. | | **Agent definition** | Composition control unit (D58-L): a keyed agent's identity/system prompt, model/thinking preset, mode-gated tool authority summary, and applicability allow-lists (`goals`/`strategies`/`lenses`/`methods`). A keyed registry covers the foreground session agent plus side/sub-agents. Replaces the prior "runtime bundle / role preset" framing. | | **Session agent** | The main-thread agent that drives the session forward — `elicitor` now, future `executor` — resolved from `op_mode`. It is the only agent represented in session state (D40-L); side/sub-agents are out-of-band. | -| **Strategy** | An optional, AUTO-able session-agent axis (D25-L) describing interaction shape: `step-wise-decision-tree` (single-exchange Q&A), `step-wise-disambiguate` (contrastive examples), `propose-graph` (novel coherent subgraph via direct commit), `project-graph` (derived nodes/edges via review-set). Strategy determines the commitment mechanism (D26-L). Detailed strategy behavior lives in a Brunch prompt resource advertised through D58-L manifests. | +| **Strategy** | An optional, AUTO-able session-agent axis (D25-L) describing interaction shape: `step-wise-decision-tree` (single-exchange Q&A), `step-wise-disambiguate` (contrastive examples), `propose-graph` (novel coherent subgraph via direct commit), `project-graph` (derived nodes/edges via review-set), `freestyle` (structure-optional, user-driven turns; D66-L). Strategy determines the commitment mechanism (D26-L). Detailed strategy behavior lives in a Brunch prompt resource advertised through D58-L manifests. | | **Lens** | An optional, AUTO-able session-agent axis (D25-L) describing topical focus: `intent`, `design`, `oracle` for elicit mode; future execute-mode `plan`, `sync`, `scope`. Orthogonal to strategy; stamped onto elicitor-emitted entries as provenance (I18-L). Detailed lens behavior lives in a Brunch prompt resource advertised through D58-L manifests. | | **Goal** | An optional, AUTO-able session-agent objective axis (D59-L): what the agent currently pursues, derived/gated by `spec.readiness_grade` — `grounding-advance`, `elicit-expand`, `commit-converge`, plus always-on `capture-posture`. Distinct from strategy (the *how*) and lens (the topical focus); `elicit-expand` / `commit-converge` are the diverge/converge pair in the elicitation diamond. | | **AUTO** | The unpinned state of an objective axis (`goal` / `strategy` / `lens`): composition advertises the legal choices in the current prompt-resource manifest and instructs the agent to self-select from that manifest only, reading the selected resource when detail matters (D58-L). | | **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/.pi/` 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 `.pi/agents/state.ts` (not filesystem-discovered), honoring D39-L sealing; `.pi/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 (`.pi/skills/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 `.pi/agents/contexts/`) or JSON, surfaced pushed (compose) or pulled (`snapshot-*` / graph-read tools). Graph snapshots explicitly choose graph-truth vs active-context projection and may filter by node kind, readiness band, or edge category/direction. Distinct from the **workspace projection** (`workspace.snapshot`), which is product/UI state, not agent content. | +| **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 `.pi/agents/state.ts` (not filesystem-discovered), honoring D39-L sealing; `.pi/agents/contexts/` context 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 (`.pi/skills/methods/*.md`): run structured exchanges, infer-and-capture (D50-L), generate proposals/projections, read context, 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 ``. | +| **Agent context** | The content the agent reasons over — `cwd`, `graph`, or `node` (D60-L): pulled (typed, read-only) from `graph/`/`session/`, optionally projected when a reusable DTO helps, rendered to LLM-string or JSON, surfaced pushed (compose) or pulled (`read_graph` / `read_workspace_context` / `read_session_context`). Graph context explicitly chooses graph-truth vs active-context reads and may filter by node kind, readiness band, edge category/direction, or absence of an edge category (gap query). Distinct from the **workspace projection** (`workspace.state`), 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, refinement, or capture of clear later-band node kinds. | | **Elicitation posture** | Retired as persisted spec state. Use readiness grade plus active strategy/lens/review-set state to explain elicit behavior. | | **Commitment focus** | Retired as persisted spec state. Future commitment projection should derive from active review-set state and graph evidence if needed. | | **Coherence** | Bounded product-visible verdict over whether the current spec graph is structurally legal and free of known unresolved contradictions/gaps at the current maturity. It is backed by reconciliation needs and remains intentionally narrower than a general judgment that the whole idea is good or complete. | | **Structural legality** | Synchronous schema/ontology validity of graph mutations: edge categories from the closed set in `docs/design/GRAPH_MODEL.md`, per-category stance/cardinality/acyclicity rules (including supersession cycles), immutable accepted-edge identity (`category`, `sourceId`, `targetId`, `stance`), per-plane closed node `kind` enums, stable kind-ordinal uniqueness/counter allocation, approval-basis enum validity, required `detail` sub-schemas for `decision`/`term`, and transaction invariants. Structural legality can fail even before semantic coherence is evaluated. | -| **Print snapshot** | The M1 meaning of the print transport mode: boot the Brunch host, resolve workspace/spec/session state through the coordinator, render product-shaped state, and exit without running an agent turn. | +| **Print render** | The M1 meaning of the print transport mode: boot the Brunch host, resolve workspace/spec/session state through the coordinator, render product-shaped state, and exit without running an agent turn. | | **Workspace** | The current working directory where the Brunch CLI was invoked. It scopes `.brunch/` state for the launch context. It is not user-created, not selectable within the dialog, and there is only one active workspace per Brunch process. The UI may display a project identity/name derived from cwd-local manifests or directory basename, but that name labels the cwd; it does not create a separate workspace object. | | **Spec / specification** | A user-created **initiative that exists to answer a problem** well enough to guide coordinated work, and that can reach a done-state even though the product, domains, and architecture keep evolving (D61-L). Concretely it is a container within a workspace, identified by its intent-graph root, holding sessions and the truth-bearing graph data (claims) gathered through them; the areas, seams, and domains it touches are not its identity. Multiple specs may coexist under one workspace; future plan-execution mode operates on a selected spec. | | **Claim** | Umbrella term for a truth-bearing graph node — the `structural` and `reasoning` intent kinds (requirement, assumption, constraint, invariant, decision, criterion, example) under D54-L/D56-L. Not a separate node kind: revision, conflict, supersession, and current-truth resolution happen at claim (node) level via supersession edges (D51-L), not at whole-spec level (D61-L). A claim is created within a spec; cross-spec claim survival/adoption is deferred (Future Direction §Spec initiative & claim model). | @@ -429,7 +433,7 @@ src/.pi/ | **Oracle graph** | Verification-strategy plane accountable to intent. Houses Checks, Validation Methods, Evidence, Obligations. | | **Design graph** | Modules, interfaces, seams, and adapters accountable to intent. Stubbed in POC. | | **Plan graph** | Milestone/frontier/slice delivery claims accountable to intent, oracle, and design. Stubbed in POC. | -| **Graph node code** | Stable spec-scoped human handle projected for a graph node from a hard-coded kind label plus stored monotonic per-kind ordinal (for example `G1`, `CON2`, `R3`, `CR4`). The code string is not stored in graph tables; internal lookup resolves it to `kind` + `kind_ordinal` and then to integer `NodeId`. Primary handle for `#`-mentions, snapshots, and agent prompts (D62-L). | +| **Graph node code** | Stable spec-scoped human handle projected for a graph node from a hard-coded kind label plus stored monotonic per-kind ordinal (for example `G1`, `CON2`, `R3`, `CR4`). The code string is not stored in graph tables; internal lookup resolves it to `kind` + `kind_ordinal` and then to integer `NodeId`. Primary handle for `#`-mentions, rendered context, and agent prompts (D62-L). | | **LSN** | Log Sequence Number. A spec-local monotonic counter, one-LSN-per-selected-spec-commit, shared inside that spec by the change log, graph-node versions, and reconciliation needs. Compare as `{specId, lsn}`, never as a bare workspace-global number. | | **Change log** | The audit trail of graph mutations, keyed by `(spec_id, lsn)`. Authoritative for selected-spec replay, `worldUpdate` synthesis, and reconciliation-need ordering. | | **Reconciliation need** | First-class record of an open impasse, gap, contradiction, or process debt; carries `created_at_lsn`, optional `resolved_at_lsn`, and a target that is exactly one of `{kind:'edge', edgeId}` or `{kind:'node_pair', aId, bId}` per `docs/design/GRAPH_MODEL.md`. Recon-needs are spec-owned and use their owning spec's local graph clock. They are a separate substrate, not graph edges (no `concerns`-edge wiring). Routine async jobs are not reconciliation needs unless they surface semantic work to resolve. | @@ -439,11 +443,12 @@ src/.pi/ | **commitGraph** | Single-tool atomic batch mutation accepting one approval basis plus `{ nodes, edges }` with intra-batch and existing-node references. One tool call, one selected-spec LSN, stable kind-ordinal allocation, all-or-nothing (I34-L). The load-bearing tool for the `propose-graph` strategy's direct-commit path (D53-L), where concept-level materialization is `basis: implicit` (D63-L). | | **propose-graph** | Elicitor strategy for generative lenses where the agent proposes a novel coherent subgraph. The concept is presented to the user with rubric axes, choices, and recommendation via structured exchange; upon acceptance the agent generates and persists the full subgraph through `commitGraph` without intermediate entity-level review (D26-L, D53-L). The hardest thing to get structurally legal and the primary proof target for A14-L. | | **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. | +| **freestyle** | Structure-optional elicitor strategy (D66-L): the turn may be ordinary user-driven chat rather than an offer-first structured exchange, structured-exchange tools stay available, and user-invoked slash/skill-commands are ergonomic here. It grows graph truth only through generalized capture (post-exchange capture wired onto `session.submitMessage`), so it and generalized capture are one slice. Initiative/interaction-style, not authority; never AUTO-selected — user pin only. Refines R16. | | **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. 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. | +| **Subscription** | A long-lived RPC operation that delivers live updates, often with an initial state payload, 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. | | **Pi RPC adapter** | A private Brunch adapter that speaks Pi's RPC protocol for agent-loop mechanics and extension UI requests, translating Pi events/dialogs into Brunch product-shaped events or method results for public clients. | | **Canonical store** | The persistence surface that owns a fact: Pi JSONL for session transcript truth, `.brunch/workspace.json` for lightweight workspace binding state, SQLite graph/change log for graph truth and coherence substrates. | @@ -476,18 +481,20 @@ src/.pi/ | **Preserved anchor set** | The configured list of transcript entry kinds and selection rules that must survive compaction byte-stable. Canonical source is [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts); each rule is `{ kind, select, rationale }` where `select ∈ first | latest | active-leaves | all-unresolved`. Externalized so it can be reviewed and updated for correctness without SPEC churn. | | **Anchor contract** | The data inside the preserved-anchor TypeScript contract — distinct from the rendering policy (which lives in code) and the LLM summarization (which is bundle-resolved). | | **World update** | `worldUpdate` custom message synthesised in `prepareNextTurn` summarising relevant graph changes since the session's `lastSeenLsn`. | -| **Mention ledger** | Per-session `(entity_id, snapshotted_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | +| **Mention ledger** | Per-session `(entity_id, seen_lsn)` record driving discretionary staleness hints when an entity has changed since the agent last saw it. | | **Authority** | Source of a node's claim: `stakeholder | technical | external | derived`. | | **Epistemic status** | Confidence basis: `observed | asserted | assumed | inferred`. Like `authority`, this is a context-shaping label for attention, grouping, and compression rather than a complete theory of truth. | | **Framing-as** | ~~Orthogonal modality classifying a node's product role.~~ **Retired.** Absorbed by `thesis`, `term`, `constraint`, and `goal` (D54-L, D56-L). | | **Thesis** | A first-class intent node kind (`kind: "thesis"`). A chosen position or bet about the product — falsifiable, carries "what/who/why/for whom" material (La Carte Blanche style). Not a requirement (it's a bet, not a need), not a goal (it's falsifiable, not aspirational), not an assumption (it's a chosen position, not a dependency). Natural edge relationships: criteria and evidence witness for/against a thesis via `proof` edges. | | **Term** | A first-class intent node kind (`kind: "term"`). A canonical naming commitment for ubiquitous language and conceptual consistency. Requires `detail: { definition, aliases? }`. Participates in graph edges: downstream nodes may `dependency`-depend on the term's definition; a term may `boundary`-scope what counts as X; a newer term may `supersession`-replace a prior term. | -| **Graph basis** | Approval-strength field on accepted graph nodes and edges: `explicit` when the exact item was directly stated or user-reviewed; `implicit` when the agent materialized specific items after concept-level acceptance. Mutation path lives in `change_log`, not in `basis` (D63-L). | -| **Node source** | Free-form string on `GraphNode.source` for epistemic attribution (e.g. "stakeholder", "regulatory", "derived", "agent synthesis"). Convention by prompt, not structural validation. Exists for context-snapshot enrichment — rendered back into sparse text in prompt snapshots, not used for policy or filtering. Not applicable to edges. | +| **Graph basis** | Provenance-directness field (`explicit | implicit`) on accepted graph nodes and edges: `explicit` when the item came directly from the user (stated or user-reviewed); `implicit` when the agent materialized it from user input after concept-level acceptance. Approval strength is the claim-flavored reading of this axis; the same `explicit | implicit` distinction also applies to non-claim registers such as the elicitation backlog (user-raised vs agent-inferred, D65-L). Mutation path lives in `change_log`, not in `basis` (D63-L). | +| **Node source** | Free-form string on `GraphNode.source` for epistemic attribution (e.g. "stakeholder", "regulatory", "derived", "agent synthesis"). Convention by prompt, not structural validation. Exists for context-render enrichment — rendered back into sparse text in prompt context, not used for policy or filtering. Not applicable to edges. | +| **Elicitation backlog** | A prospective process-agenda register — the elicitor's "prospective memory" of open questions the user has not answered yet (knowable now by asking). Seeded at spec creation, read every turn to choose what to ask next, grown by capture-reflection; resolution yields a claim, a `risk`, or more entries. Async and unordered (named `backlog` over `agenda`/`need`): entries may be logged now but only matter under a later grade or different lens. A flat `elicitation_backlog` table (not a graph node); the *prospective* sibling of the *retrospective* `reconciliation_need` register. See D65-L. | +| **Risk** *(deferred)* | A durable domain-epistemic gap: something nobody knows and cannot economically find out now, requiring strategic accommodation (assumptions, decisions, design/verification/planning) rather than elicitation. Distinct from `assumption` (which proceeds on a believed-but-unprovable value); a risk is upstream and cannot yet pick a value. If adopted it is a first-class intent-plane node kind (it carries real cross-plane edges), not a table; deferred because it reopens the locked kind set (D54-L/D56-L). Future Direction §Vocabulary evolution; D65-L. | | **Node detail** | Optional JSON column on `GraphNode.detail` with per-kind validated sub-structures. `decision` requires `{ chosen_option, rejected, rationale }`; `term` requires `{ definition, aliases? }`. All other kinds omit `detail`. | | **Context (node kind)** | A first-class intent node kind (`kind: "context"`). A descriptive claim about the environment — observed facts that color interpretation without driving decisions directly. Last-resort basic bucket: before filing as context, check the promotion heuristic (must be true for success → requirement/invariant; limits solutions → constraint; may be false → assumption; chooses among alternatives → decision; bet about users/market → thesis). | | **Intent kind category** | Derived semantic grouping of intent node kinds: `basic` (goal, thesis, term, context), `structural` (requirement, assumption, constraint, invariant), `reasoning` (decision, criterion, example). A pure function of `kind`, not stored. Distinct from readiness bands. | -| **Readiness band** | Non-exclusive derived grouping over node kinds — `grounding`, `elicitation`, `commitment` — used by elicitor goals, graph snapshot filters, and grade-advancement rubrics. A band is not a validation gate; clear later-band nodes may be captured at earlier grades (D64-L). | +| **Readiness band** | Non-exclusive derived grouping over node kinds — `grounding`, `elicitation`, `commitment` — used by elicitor goals, graph context filters, and grade-advancement rubrics. A band is not a validation gate; clear later-band nodes may be captured at earlier grades (D64-L). | | **Posture** | A workspace-level POC-stubbed property set declaring project epistemic/strategic stance (certainty, stakes, audience, horizon, migration, sourcing). Not a graph node kind or spec-row field in the POC. Grounding elicitation may help establish it, but startup persists only the workspace stub. | | **Kernel** | A behavioural elicitation pattern from `docs/design/BEHAVIORAL_KERNELS.md` (state/lifecycle, containment, concurrency, etc.). | | **Probe run** | A scripted or executable check of a Brunch seam that drives the public product surface and persists reviewable artifacts under `.fixtures/runs///`. | @@ -570,7 +577,7 @@ Infrastructure is not yet fully laid (Phase 3 of POC bootstrapping). Commands fo | 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, 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 | Spec-local LSN monotonicity, change-log replay, reconciliation-need invariants, stable kind-ordinal allocation/no-reuse, mention staleness, interest-set recomputation, side-task delivery ordering, **batch-acceptance atomicity (one selected-spec LSN / one change-log entry, partial-batch impossible under mid-batch validation failure)**, **`supersedes` / `supersession` 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, I39-L, I41-L. | -| 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 | 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 state payload 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. | @@ -594,7 +601,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | Invariant | Assigned oracle(s) | | --- | --- | -| I1-L | `CommandExecutor`/migration/snapshot/RPC/seed-fixture tests now cover spec-local LSN allocation, exactly one `graph_clock` row per persisted spec, `(spec_id, lsn)` change-log shape, sibling isolation, missing-clock invariant failure, and rollback no-bump behavior; M4/M7 replay/property tests still extend this to generated traces. | +| I1-L | `CommandExecutor`/migration/queries/RPC/seed-fixture tests now cover spec-local LSN allocation, exactly one `graph_clock` row per persisted spec, `(spec_id, lsn)` change-log shape, sibling isolation, missing-clock invariant failure, and rollback no-bump behavior; M4/M7 replay/property tests still extend this to generated traces. | | I2-L | M5 architectural boundary test plus `CommandExecutor` contract tests. | | I3-L | M2 JSONL round-trip tests and fixture replay parity. | | I4-L | M7 generated `{specId, lsn}` change traces and paired-session fixture assertions. | @@ -641,7 +648,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` - **Capture analysis stays distinct from response-capture writes.** `capture_*` ANALYSIS is the transcript-native bridge for reviewing likely graph changes before persistence or before comparing later graph mutations against transcript evidence; it is not the FE-807 synchronous response-capture command path. 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. -- **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial snapshot plus ordered live updates by invalidating/refetching canonical projection handlers rather than introducing a view store. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. +- **Subscriptions are scoped for the POC.** Initial subscription oracles should prove initial state payload plus ordered live updates by invalidating/refetching canonical projection handlers rather than introducing a view store. Reconnect/resume semantics are acknowledged but deferred unless a frontier explicitly depends on them. ### Acknowledged Blind Spots @@ -649,7 +656,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | --- | --- | --- | --- | | Full TUI automation | Cost exceeds value before the product state seams are proven, but startup-switcher regressions need a stronger visual signal than store-only checks. | Manual checklist plus artifact/query probe oracle; for FE-744 startup, add pty/ANSI-stripped capture assertions for the pre-Pi decision surface and absence of stale transcript before explicit resume. | Manual TUI steps become frequent/flaky or block CI confidence. | | LLM elicitation quality and interaction flow | No stable deterministic ground truth for “good interview” early in the POC, and retired M1 scripted exchanges encoded only a thin obsolete exchange model. | Transcript-backed probe runs, human-reviewed probe reports, adversarial probe scenarios, expected structural coverage, and later review of knowledge flow through real elicitation loops. | Repeated probe failures where structure passes but elicitation is judged poor, or later runs reveal that prompt/response markers, offer envelopes, or knowledge-flow assumptions need sharper transcript semantics. | -| Subscription reconnect/resume | POC can prove snapshot + live update without hardening network recovery yet. | Contract tests for initial snapshot and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | +| Subscription reconnect/resume | POC can prove initial state payload + live update without hardening network recovery yet. | Contract tests for initial state payload and ordered update sequence. | Web/RPC clients need robust reconnect semantics or long-running fixture runs expose drift. | | Performance and scale | Local POC graph/session sizes are small; premature budgets may distort design. | Keep exports/checkers text-native and simple; add budgets when slow tests appear. | `npm run verify` or fixture runs exceed acceptable local iteration time. | | Cross-platform terminal rendering | TUI chrome visuals may differ by terminal. | Test state derivation and keep manual smoke on primary dev environment. | Distribution target broadens or terminal rendering bugs recur. | | Lens-recommendation appropriateness | No deterministic ground truth for "did the agent offer the right strategy at the right time" given temperament + grounding density inputs. | Probe-driven outer-loop walkthrough; small targeted scenarios where recommended lens is judged by reviewer; tracked as fitness, not gated. | Repeated user complaints that the offered strategies feel wrong, or fixture review reveals systematic mis-offers. | diff --git a/memory/cards/crosscut-know--resource-body-depth.md b/memory/cards/crosscut-know--resource-body-depth.md new file mode 100644 index 000000000..36cd4cc03 --- /dev/null +++ b/memory/cards/crosscut-know--resource-body-depth.md @@ -0,0 +1,112 @@ +# Prompt-resource body depth (Seam 3a/3b content pass) + +Frontier: n/a (cross-cut Seam 3a/3b; D58-L) | tracker/branch = the active cross-cut push +Status: active +Mode: single +Created: 2026-06-07 + +## Orientation + +- **Containing seam:** the KNOW layer's Brunch-owned prompt resources under + `src/.pi/skills/{goals,strategies,lenses,methods}` — the markdown bodies the agent loads with + `read` when an axis is active (D58-L manifest mechanism). `CROSS_CUT_PLAN.md` Seam 3a/3b both + carry a *content depth* ● row: "scaffolding present, bodies thin." +- **Relevant frontier item:** none in `memory/PLAN.md`; this stays the remaining temporary + cross-cut completion work after D65-L `elicitation_backlog` was promoted back into PLAN. + It is the earned content half of cross-cut working-order step 4. +- **Volatile state:** the bodies are genuinely thin — every resource is ~5 lines + (`goals/*`, `lenses/*`, `methods/{commit-graph,read-context,review-for-gaps}`, all four + non-freestyle `strategies/*`); only `methods/{infer-and-capture,generate-proposal,run-structured-exchange}` + reach 12–15 lines. The contracts for what each body should contain already exist in the + family READMEs ([strategies/README.md](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/skills/strategies/README.md) + lists the required facets; [lenses/README.md](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/skills/lenses/README.md)). +- **Drift note (handled in reconciliation, not here):** the Seam 3b *exchange-tool + `.description()` / `promptGuidelines`* ● row is **already done** — all 7 exchange tools under + `src/.pi/extensions/exchanges/` carry `description` + `promptGuidelines`. That row is reclassified + `built` in the ledger; it is **out of scope** for this card. +- **Main open risk:** prose quality is eyeball-judged — verification is review-based, not a test. + +Posture: **earned** (inherited from cross-cut Seam 3a/3b — Fill=`earned`; settled scaffolding, +just unbuilt bodies). This is content materialization into existing topology, not a new seam. + +Frontier-level cross-cutting obligations: + +- **D58-L:** bodies stay Brunch-owned markdown loaded on demand; the manifest advertises + `{name, description, location}`, the body carries detail. Do not move detail into code or descriptions. +- **D39-L:** resource location stays code-owned in `.pi/agents/state.ts`; this card edits bodies + only, not the manifest registry. +- Keep each body scoped to its own axis; do not duplicate cross-axis content (goal vs strategy vs + lens vs method are orthogonal, D59-L/D25-L). + +### Objective + +Deepen the thin `.pi/skills/{goals,strategies,lenses,methods}` resource bodies so each carries the +real per-axis instruction its README contract requires, without changing the manifest registry. + +### Acceptance Criteria + +```pseudo tree +resource body depth +├── goals (4) +│ └── ✓ each goal body states the objective, what evidence advances it, and what NOT to claim/do +├── strategies (4 remaining; freestyle already deepened) +│ └── ✓ each body covers the strategies/README facets: what the agent does, turn structure, +│ commitment mechanism, available graph ops, and category-selection rubric where applicable +├── lenses (3) +│ └── ✓ each lens body states its topical focus, what kinds/edges it favors, and how it shapes interpretation +├── methods (6) +│ └── ✓ each method body gives concrete tool-routing/sequencing guidance (the D58-L method role), +│ not a restatement of the tool description +└── consistency + ├── ✓ no body contradicts its README contract or another axis's responsibility + └── ✓ manifest descriptions in state.ts still match each deepened body's intent +``` + +### Verification Approach + +``` +- Inner: review-based — each body read against its family README contract; build/lint proves resources still load. +- Inner (light, if cheap): a structural test asserting each resource exceeds a trivial threshold + and the manifest location resolves to a readable file (extends existing compose/readability tests). +``` + +### Cross-cutting obligations + +``` +- Bodies are prompt resources, not code: keep instruction in markdown, not in descriptions/manifest. +- Preserve orthogonality (D59-L/D25-L): a strategy body must not absorb goal/lens content. +- Do not touch the exchange-tool description row (already built) or the manifest registry (D39-L). +``` + +### Assumption dependency + +`None` — this slice's correctness does not hinge on a live `memory/SPEC.md` §Assumption; the +axis scaffolding and the D58-L manifest mechanism are settled and built. + +### Expected touched paths (tentative) + +```pseudo tree +src/.pi/skills/ +├── goals/{grounding-advance,elicit-expand,commit-converge,capture-posture}.md ~ +├── strategies/{step-wise-decision-tree,step-wise-disambiguate,propose-graph,project-graph}.md ~ +├── lenses/{intent,design,oracle}.md ~ +└── methods/{run-structured-exchange,infer-and-capture,commit-graph,read-context,generate-proposal,review-for-gaps}.md ~ +src/.pi/agents/state.ts ? (only if a manifest description needs to match a deepened body) +src/.pi/agents/compose.test.ts ? (only if a light structural/readability assertion is added) +``` + +### Promotion checklist + +All **no** — stays a light/earned content card: + +- Changes a requirement? No. — Creates/retires an assumption? No. — Depends on unvalidated + high-impact assumption? No. — Makes/reverses a design decision? No. — New seam invariant? No. +- Changes a cross-cutting verification layer? No. — Crosses >2 seams? No (one resource tree). +- First touch in an unfamiliar seam? No. — Can't name the seam/rationale? No (D58-L, the READMEs). + +### Traceability + +- **SPEC:** D58-L (resource-manifest mechanism), D59-L/D25-L (axis orthogonality). +- **Cross-cut:** closes `CROSS_CUT_PLAN.md` Seam 3a *goal/strategy/lens content depth* ● and + Seam 3b *method content depth* ●. The Seam 3b *exchange-tool description* ● is reclassified + `built` (drift) during reconciliation, not by this card. diff --git a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md index a181f7545..6ccf6f7fc 100644 --- a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md +++ b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md @@ -9,7 +9,7 @@ Created: 2026-06-05 - Containing seam: `graph/CommandExecutor` as the single graph-truth mutation boundary. The current creation-only `commitGraph({nodes, edges})` shape is sufficient for `propose-graph` creation, but not for manual curation of persisted seed specs where humans must patch or remove existing graph items. - Relevant frontier item: `dev-seed-fixtures` because the immediate product need is curated Bilal/reference seed data that can be edited in a local DB and exported back to `.fixtures/seeds/**`. This slice also touches the cross-frontier graph mutation contract (`D4-L`, `D20-L`, `D53-L`), so it must reconcile SPEC/GRAPH_MODEL when built. -- Volatile handoff state: a clean curation workspace exists at `.fixtures/workbenches/bilal-curation`; DB→fixture export and the one-shot RPC helper are already in place (`src/graph/export-fixtures.ts`, `src/dev/workspace-rpc.ts`). Active FE-809 review-cycle work currently touches `src/graph/command-executor.ts` and review-set graph code, so this scope is **not parallel-safe** on the same worktree until that work lands or the builder moves to an isolated worktree. +- Volatile handoff state: a clean curation workspace exists at `.fixtures/workbenches/bilal-curation`; DB→fixture export and the one-shot RPC helper are already in place (`src/graph/export-fixtures.ts`, `src/dev/workspace-rpc.ts`). FE-809 review-cycle work has landed, but this scope still touches fresh `src/graph/command-executor.ts` and review-set graph code; coordinate before building it in a shared worktree. - Main open risk: edit/delete semantics can accidentally become a second mutation model. The implementation must preserve one transaction, one spec-local LSN, one change-log row, all-or-nothing structural validation, and no direct DB writes outside `CommandExecutor`. Posture: proving (inherited from `dev-seed-fixtures`). @@ -40,7 +40,7 @@ Weight: full → semantic mutation planner / structural validation → CommandExecutor transaction boundary → SQLite graph rows + spec-local graph_clock/change_log -→ graph snapshots / existing product callers +→ graph readers / existing product callers → graph topology docs + SPEC/GRAPH_MODEL reconciliation ``` @@ -64,7 +64,7 @@ Weight: full This proving slice is a tracer bullet on two axes: - **Invariants:** it stabilizes the command-layer shape required to edit seed truth without bypassing `CommandExecutor`. -- **Proof of life:** a mixed create/update/delete batch must be visible through normal graph snapshots and later exportable as seed JSON. +- **Proof of life:** a mixed create/update/delete batch must be visible through normal graph readers and later exportable as seed JSON. It deliberately does not attempt a full UI curation workflow or write leases. Those are adjacent surfaces, not required to prove the mutation seam. @@ -102,7 +102,7 @@ semantic graph mutation command ### Verification Approach - Inner: `CommandExecutor` unit/regression tests — prove validation, all-or-nothing writes, spec scoping, LSN/change-log behavior, and immutable-field rules. -- Inner: snapshot/export cross-check — after a mixed mutation, `getGraphOverview(..., graph_truth)` and `exportSeedFixture` reflect the post-mutation graph. +- Inner: graph-read/export cross-check — after a mixed mutation, `getGraphOverview(..., graph_truth)` and `exportSeedFixture` reflect the post-mutation graph. - Middle: compile/import repair over existing graph callers — proves the old creation path did not keep an unmaintained validation fork. ### Cross-cutting obligations diff --git a/memory/cards/elicitation-backlog--substrate.md b/memory/cards/elicitation-backlog--substrate.md new file mode 100644 index 000000000..939be556e --- /dev/null +++ b/memory/cards/elicitation-backlog--substrate.md @@ -0,0 +1,184 @@ +# Elicitation-backlog substrate (Seam 3a — "what to ask next" driver) + +Frontier: elicitation-backlog +Status: active +Mode: single +Created: 2026-06-07 + +## Orientation + +- **Containing seam:** the KNOW/orient layer's missing substrate. `CROSS_CUT_PLAN.md` + Seam 3a's open ● *"what to ask next" driver* row points at D65-L `elicitation_backlog` — + a **flat table** (prospective process-agenda), the prospective sibling of the retrospective + `reconciliation_need` register (D8-L). Today the elicitor has no per-turn agenda store. +- **Relevant frontier item:** `elicitation-backlog` in `memory/PLAN.md`. This tracer was + promoted out of the temporary elicitor cross-cut because the D65-L substrate now carries + real frontier weight; the row it closes remains tracked in `memory/CROSS_CUT_PLAN.md`. +- **Volatile state:** the sibling `reconciliation_need` is currently **type-only** — see + [src/graph/schema/reconciliation-need.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/graph/schema/reconciliation-need.ts) + ("Phase 1 lock-and-materialize: type definitions only; Drizzle table + CommandExecutor write + paths land with subsequent M4 slices"). So this slice **materializes the prospective register + first**, justified by its direct POC value (it drives "what to ask next"); mirror the + `reconciliation_need` shape so the retrospective register can later materialize symmetrically. + `createSpec` ([command-executor.ts#L449](file:///Users/lunelson/Code/hashintel/brunch-next/src/graph/command-executor.ts#L449)) + is the seed point — it already runs a transaction allocating a spec-local LSN + `change_log` row. +- **Topology note (post-35eff395):** the `snapshot` architecture noun is retired. The `read | project + | render` split now governs: **domain read/query logic stays in the owning domain** + ([src/graph/queries.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/graph/queries.ts), formerly + `snapshot.ts`); `src/projections/` is reserved for **reusable multi-consumer/multi-source DTOs**. + So the backlog read-back lives in `graph/` (single-owner domain read), **not** `src/projections/` + — add a projection only if a later consumer (RPC/web) actually reuses the shape. +- **Main open risk:** D65-L lists three open scope-design items. This card resolves two and + defers one (below). The load-bearing assumption A24-L (a flat table suffices, no graph plane) + is exactly what landing this frontier tracer tests. + +Posture: **proving** (inherited from frontier `elicitation-backlog`; this is still the former cross-cut Seam 3a D65-L tracer). + +Slice-design decisions made here (resolving D65-L "open for scope/slice design"): + +1. **Mutations route through `CommandExecutor`, sharing the spec-local LSN + `change_log`** — + mirrors D8-L reconciliation needs and preserves the single mutation boundary (D4-L/D20-L). + This is the card's recommended resolution of D65-L's open routing fork; building it ratifies it. +2. **Seed at spec creation** (`createSpec`) with a small grounding-band question set. +3. **Goal-axis relationship (complement vs thin `goal`, D59-L) is DEFERRED** — not decided here; + this slice only stands up the substrate and a read-back, not the goal-layer interaction. + +Frontier-level cross-cutting obligations this slice carries: + +- **D4-L/D20-L:** all backlog mutations route through the command layer and return structured results. +- **D16-L/A4-L:** each mutation allocates exactly one `{specId, lsn}` through the spec's `graph_clock`. +- **D63-L:** `basis` is provenance-directness — a user-raised need is `explicit`, an + agent-inferred need is `implicit`; do not overload it as a mutation-path field. +- **D52-L:** `graph/` owns the table + mutation **and the read** (domain query in `queries.ts`); + `db/` is imported only by `graph/`. A `src/projections/` DTO is added only if a consumer reuses it. +- **D65-L shape lock:** flat table only — FK pointers (`arose_from`, `resolved_by`), filter + attributes (plane/lens affinity, grade band, `open|closed`); **no** graph node/plane, no + unknown→unknown edges. Keep it forward-compatible with promotion to a plane, do not pre-build one. + +### Target Behavior + +A flat `elicitation_backlog` table is materialized through `CommandExecutor`, seeded with +grounding-band questions at spec creation, and read back per spec through the command and domain-read boundary. + +### Boundary Crossings + +```pseudo +→ elicitation_backlog Drizzle table (db/schema.ts) + generated migration +→ graph/schema/elicitation-backlog.ts domain types (mirror reconciliation-need shape) +→ CommandExecutor: create-entry / close-entry mutations (one spec-local LSN + change_log each) +→ createSpec seed hook (grounding-band questions on new spec) +→ domain read (graph/queries.ts): list open backlog entries for a spec +→ SPEC reconciliation (A24-L progress; D65-L routing/seed forks resolved) +``` + +### Risks and Assumptions + +``` +- RISK: materializing the prospective register before the retrospective sibling creates schema asymmetry. + → MITIGATION: mirror the reconciliation_need type/column shape (id, specId, kind/affinity, + target/FK pointers, rationale, createdAtLsn, resolvedAtLsn) so the sibling materializes symmetrically. +- RISK: the seed question set hard-codes content that should be data/config. + → MITIGATION: keep the seed list a single small named constant in graph/ (not scattered); + it is a starting agenda, not a closed vocabulary — entries are mutable through the command path. +- RISK: backlog mutation drifts into a second mutation engine separate from commitGraph. + → MITIGATION: reuse the CommandExecutor transaction/clock/change_log helpers; backlog ops are + new operations on the same boundary, not a parallel writer. +- ASSUMPTION: a flat table (FK pointers + filter attrs) is sufficient to drive elicitor questioning + without a graph plane or unknown→unknown edges. + → IMPACT IF FALSE: if genuine unknown→unknown dependency or rich traversal emerges, the table + promotes to a plane (rows→nodes, FK→edges) — a larger reshape touching the locked graph model. + → VALIDATE: seed→store→read tracer plus later capture-reflection across fixtures; rich + dependency that the FK pointers cannot express is the falsifier. + → [→ memory/SPEC.md §Assumptions A24-L] +- ASSUMPTION: routing backlog mutations through CommandExecutor (sharing the spec-local LSN) is the + right home, not a separate store. + → IMPACT IF FALSE: backlog gets its own clock/audit; rework of the mutation surface. + → VALIDATE: the tracer's change_log + LSN assertions; mirrors the settled D8-L need register. +``` + +### Posture check + +Proving tracer scoring on two axes: + +- **Proof of life:** stands up an entirely new substrate end-to-end — seed at spec creation → + command-layer store → read-back — that no current store provides. +- **Uncertainty:** retires the load-bearing half of A24-L (flat table suffices). The tracer breaks + if the flat shape cannot carry seeded grounding-band agenda items with their FK pointers. + +It deliberately does **not** build the per-turn "what to ask next" prompt injection or +capture-reflection spawning/closing — those depend on what the seeded substrate reveals and are +held back by the anti-speculation gate (see follow-on). + +### Acceptance Criteria + +```pseudo tree +elicitation_backlog substrate +├── table + types +│ ├── ✓ elicitation_backlog table exists with a generated migration and mirrors the reconciliation_need shape +│ └── ✓ domain types enumerate status (open|closed), basis (explicit|implicit), grade band, and FK pointers +├── command-layer mutation +│ ├── ✓ creating an entry allocates one spec-local LSN and one change_log row +│ ├── ✓ closing an entry sets resolved_by / closed_at_lsn and writes one change_log row +│ └── ✓ a malformed entry returns structural_illegal and writes no rows +├── seed at spec creation +│ ├── ✓ createSpec seeds the grounding-band question set for the new spec +│ └── ✓ seeded entries are open, explicit, and scoped to that spec only (sibling specs unaffected) +└── read-back + └── ✓ listing open backlog entries for a spec returns the seeded set with stable fields +``` + +### Verification Approach + +``` +- Inner: CommandExecutor unit tests — create/close mutation, LSN/change_log, structural_illegal, spec scoping. +- Inner: migration/schema test — table present; seed-on-createSpec count and field assertions. +- Middle: domain-read test (graph/queries) — seeded entries read back per spec; sibling-spec isolation. +``` + +### Cross-cutting obligations + +``` +- Reuse the CommandExecutor boundary; no direct db/ writes outside graph/; no second clock/audit. +- Flat table only — no graph node/plane, no unknown→unknown edges (D65-L). +- basis stays provenance-directness (D63-L); seeded grounding questions are explicit. +- Mirror reconciliation_need shape for forward-symmetric materialization. +``` + +### Expected touched paths (tentative) + +```pseudo tree +src/db/ +├── schema.ts ~ (elicitation_backlog table + enum arrays) +└── row-schemas.ts ? +drizzle/ +└── 0003_*.sql + (generated migration) +src/graph/ +├── schema/elicitation-backlog.ts + (domain types; mirror reconciliation-need.ts) +├── command-executor.ts ~ (create/close entry + seed hook in createSpec) +├── command-executor.test.ts ~ +├── command-executor/ +│ └── elicitation-backlog-types.ts +? +├── queries.ts ~ (domain read: list backlog entries per spec) +├── queries.test.ts ~ +└── index.ts ~ +src/projections/ ? (only if a consumer reuses the read shape — not by default) +memory/SPEC.md ~ (A24-L progress; D65-L routing/seed forks resolved) +docs/design/GRAPH_MODEL.md ? (if the need-register section gains a prospective sibling note) +``` + +### Foreseeable follow-on (NOT scoped — anti-speculation gate) + +The per-turn **"what to ask next" driver** — compose-time injection of open backlog entries +into the elicitor turn (D58-L), plus **capture-reflection** that spawns new entries and closes +resolved ones on each exchange/message — is intentionally **not pre-scoped**. Its exact read +shape and capture-reflection wiring would shift based on what the seeded substrate reveals +(entry volume, field usefulness, goal-axis relationship). Scope it after this tracer lands. + +### Traceability + +- **SPEC:** D65-L (the register), A24-L (flat-table assumption — the falsifier), D8-L + (retrospective sibling template), D4-L/D20-L/D16-L (command boundary + LSN), D63-L (basis), + D64-L (grade bands), D52-L (topology). On build, reconcile A24-L progress and resolve the + D65-L routing/seed open items; defer the goal-axis fork. +- **Cross-cut:** advances `CROSS_CUT_PLAN.md` Seam 3a *"what to ask next" driver* ● (substrate + half; behavioral driver remains a follow-on). diff --git a/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md index 2279ff206..ed9b5dc79 100644 --- a/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md +++ b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md @@ -1,7 +1,7 @@ # Live selected-spec mention autocomplete Frontier: poc-live-ship-gate -Status: active +Status: next Mode: single Created: 2026-06-05 @@ -9,6 +9,7 @@ Created: 2026-06-05 - Containing seam: Brunch Pi product shell `#` autocomplete over the selected-spec graph; this is the adapter edge where Pi autocomplete inserts visible stable graph-code text, not hidden mention metadata. - Relevant frontier item: `poc-live-ship-gate` because this is a composed-product-path defect visible in a live seeded TUI session. It does **not** advance M7 mention ledger/staleness; it only fixes the current autocomplete source. +- Planning note: the slice is prepared but intentionally parked while `elicitation-backlog` and the remaining temporary elicitor cross-cut work have priority. Return when FE-811 is back on the critical path. - Volatile handoff state: no `HANDOFF.md`; the projection/rendering topology work has since moved the Pi shell to `src/.pi/brunch-pi-extensions.ts` and mention code to `src/.pi/extensions/mentions/index.ts`. Diagnosis still proves the live TUI menu shows `#D12/#I9/#A10` from `FIXTURE_GRAPH_MENTION_SOURCE` while the selected spec has real graph nodes. - Main open risk: the build path must delete production fixture-backing without accidentally inventing a broader graph projection layer or coupling autocomplete to DB access. @@ -47,12 +48,12 @@ Typing `#` in a Brunch TUI session lists graph nodes from the currently selected ### Cross-cutting obligations - Keep autocomplete as presentation/handle insertion only; ledger/staleness remains M7. -- Keep selected-spec authority explicit through already-bound `graphDeps.snapshots.getGraphOverview()`. +- Keep selected-spec authority explicit through already-bound `graphDeps.reads.getGraphOverview()`. - Keep projection trivial and local unless another surface needs the same structured candidate shape. ### Assumption dependency -None — this slice builds against already-landed selected-spec graph snapshots and Pi autocomplete provider seams. +None — this slice builds against already-landed selected-spec graph readers and Pi autocomplete provider seams. ### Expected touched paths (tentative) diff --git a/memory/cards/project-graph-review-cycle--approval-wiring.md b/memory/cards/project-graph-review-cycle--approval-wiring.md deleted file mode 100644 index ebd4a03f5..000000000 --- a/memory/cards/project-graph-review-cycle--approval-wiring.md +++ /dev/null @@ -1,152 +0,0 @@ -# Review-set approval wiring - -Frontier: project-graph-review-cycle -Status: done -Mode: single -Created: 2026-06-06 - -## Orientation - -- Containing seam: FE-809 `project-graph-review-cycle`, specifically the product path from a transcript-backed `present_review_set` / `request_review` structured exchange to graph truth through `CommandExecutor.acceptReviewSet`. -- Relevant frontier item: `project-graph-review-cycle` ([FE-809](https://linear.app/hash/issue/FE-809/project-graph-review-set-proposal-and-atomic-acceptance)); the schema/emission lock is complete, and PLAN names approval-to-`acceptReviewSet` product wiring plus the real `project-graph` probe as remaining FE-809 work. -- Volatile handoff state: the topology cleanup moved reusable exchange helpers to `src/projections/structured-exchange/` and `src/renderers/structured-exchange/`, Pi exchange tools to `src/.pi/extensions/exchanges/`, and app entrypoints to `src/app/`. Two unrelated active cards remain: live mention autocomplete and dev semantic graph mutations. The semantic mutation card should wait because this slice may touch `CommandExecutor` / review-set graph code. -- Main open risk: approval could accidentally commit a stale or reconstructed payload that differs from the reviewed `present_review_set`. This slice must recover the exact pending review-set details from transcript truth, translate them once, and commit atomically only for `decision: "approve"`. - -Posture: proving (inherited from `project-graph-review-cycle`). - -Frontier-level cross-cutting obligations this slice carries: - -- Preserve D27-L/I15-L: review-set approval is one `acceptReviewSet` command, one spec-local LSN, one change-log row, and no partial acceptance. -- Preserve D28-L: request-changes and reject are transcript-visible terminal outcomes; they do not mutate graph truth in this slice. -- Preserve D4-L/D20-L: graph mutation routes only through `CommandExecutor`; RPC/session code must not write graph rows or call DB directly. -- Preserve D61-L/D62-L: existing graph references in review payloads use selected-spec projected codes at adapter/UI boundaries, then resolve inside graph translation. -- Preserve D63-L/I40-L: accepted review-set graph rows are `basis: explicit`; the review payload does not carry per-item basis. -- Preserve D37-L/D41-L: request-review details remain Zod-authored structured-exchange transcript payloads; TypeBox is only the RPC/Pi parameter adapter where needed. -- Preserve harness-as-false-proof guard: tests should exercise the public session/RPC path or the same projection helpers it uses, not private graph calls masquerading as product wiring. - -## Card 1 — Approve review-set exchange into graph truth - -Status: done -Weight: full - -Completed: 2026-06-06 — `session.submitExchangeResponse` now accepts review decisions, appends canonical `request_review` terminal results, routes approve through `CommandExecutor.acceptReviewSet`, publishes graph invalidations on approval, and leaves request-changes/reject non-mutating. Verified with `npm run verify`. - -### Target Behavior - -Submitting an approved pending review exchange commits the exact presented review set into the selected spec graph through `CommandExecutor.acceptReviewSet`. - -### Boundary Crossings - -```pseudo -→ transcript-backed pending review exchange -→ session.submitExchangeResponse public RPC params -→ request_review toolResult projection / append -→ reviewed present_review_set payload recovery -→ CommandExecutor.acceptReviewSet -→ graph/change_log/product update projections -→ docs/PLAN reconciliation for FE-809 remaining work -``` - -### Risks and Assumptions - -- RISK: Pending review response may be accepted without a matching open `present_review_set`. - → MITIGATION: recover the pending exchange via `pendingExchangeFromEnvelope`; require `pending.mode === "review"`, matching `exchangeId`, and a recoverable `reviewSet` payload before accepting review responses. -- RISK: The pending projection currently exposes `review_set` in snake_case details shape, while `CommandExecutor.acceptReviewSet` consumes the graph-domain camelCase `ReviewSetProposalPayload`. - → MITIGATION: add an explicit, tested adapter from canonical present-review-set details back to graph-domain payload, preserving exact node/edge semantics and selected-spec existing-code refs. -- RISK: Approval graph mutation may happen before the request-review toolResult is appended, making transcript audit look out of order. - → MITIGATION: define and test the order; prefer append+flush terminal `request_review` first, then commit with `proposalEntryId` pointing at the persisted request or reviewed present entry, unless implementation proves Pi session manager cannot expose the appended id cheaply. Whatever order is chosen must be documented in result/change-log tests. -- RISK: Reusing `session.submitExchangeResponse` for review decisions could break text/choice capture semantics. - → MITIGATION: extend the params schema as an additional answer branch (`{review:{decision, comment?}}`) and keep existing text/choice/multi-choice tests green. -- RISK: Request-changes could require immediate successor proposal generation. - → MITIGATION: this slice records the terminal request-changes outcome and returns a non-mutating result; successor generation remains a later FE-809/probe behavior unless already provided by the agent loop. -- ASSUMPTION: Approve/reject/request-changes can share `session.submitExchangeResponse` rather than adding a new public method. - → IMPACT IF FALSE: web/RPC clients would need a separate review response method, and PLAN/RPC docs would need a larger public-surface change. - → VALIDATE: handler tests prove `session.pendingExchange` returns `mode: "review"`, `session.submitExchangeResponse` accepts the review decision, and product updates/refetches match existing session mutation patterns. - → memory/SPEC.md: D49-L already lists `request_review` as a terminal response supported by `session.submitExchangeResponse`. - -### Posture check - -This proving slice lights up the FE-809 product path that is currently only graph-ready in isolation: transcript review approval → exact graph acceptance → observable graph update. It also stabilizes the invariant that review approval cannot be a caller-side patch/commit sequence. - -### Acceptance Criteria - -```pseudo tree -review approval product path -├── pending review projection -│ ├── ✓ session.pendingExchange returns `mode: "review"` with the canonical reviewed nodes/edges from a matching `present_review_set` -│ └── ✓ malformed or unsupported review-set details do not become an approvable pending review -├── response params and transcript append -│ ├── ✓ session.submitExchangeResponse accepts `{answer:{review:{decision:"approve", comment?}}}` only for pending review exchanges -│ ├── ✓ request_changes requires a non-empty comment and appends a canonical `request_review` toolResult without graph mutation -│ ├── ✓ reject appends a canonical `request_review` toolResult without graph mutation -│ └── ✓ text/choice/multi-choice response behavior and synchronous capture stay unchanged -├── approve-to-graph -│ ├── ✓ approve translates the exact reviewed `review_set` payload to `ReviewSetProposalPayload` and calls `CommandExecutor.acceptReviewSet` -│ ├── ✓ accepted nodes/edges are written with `basis: explicit`, one selected-spec LSN, and one `accept_review_set` change-log row -│ ├── ✓ existing-code endpoints resolve only inside the selected spec -│ ├── ✓ structural-illegal review payload returns a loud `structural_illegal` acceptance result and does not append misleading success state -│ └── ✓ success publishes selected-session updates and graph mutation updates with `{specId, lsn}` -├── public/result shape -│ ├── ✓ submit result distinguishes `review: {status:"approved", lsn,...}` from ordinary `capture` results or deliberately extends the existing result without overloading capture -│ └── ✓ rpc.discover schema/examples describe review submission without creating `reviewSet.*` methods -└── reconciliation - ├── ✓ src/rpc/README.md reflects `request_review` support if the public response schema changes - ├── ✓ memory/PLAN.md FE-809 execution pointer advances to the real `project-graph` proposal probe - └── ✓ memory/SPEC.md / docs/design/REVIEW_SETS.md are updated only if implementation changes D27-L/D28-L/D49-L semantics -``` - -### Verification Approach - -- Inner: session projection tests — prove review pending exchange recovery from Pi-like `present_review_set` toolResult details. -- Inner: RPC handler tests — drive `session.submitExchangeResponse` against a selected session containing a pending review exchange and assert transcript append, graph rows, change-log, and product updates. -- Inner: graph translation tests — prove details-to-review-payload adapter preserves selected-spec projected-code refs and rejects drifted fields. -- Middle: small product-path fixture/probe (deterministic, no LLM) — activate a workspace/spec/session, append/present a review set through the same structured-exchange projection helpers, submit approve over public RPC, read `graph.overview` back. -- Outer: real `project-graph` LLM proposal probe is not part of this card unless the implementation turns out already wired enough; scope/run it after this card lands. - -### Cross-cutting obligations - -- Do not add standalone `reviewSet.*` public RPC methods or DB-backed review-set entities. -- Do not expose partial acceptance or accept-with-edits. -- Do not add reviewer async jobs in this slice; D29-L reviewer remains deferred unless explicitly scoped. -- Do not give the web/browser direct graph mutation authority for review drafts; browser submits the review decision, not graph nodes/edges. -- Do not widen `commit_graph` tool semantics while wiring review approval. - -### Expected touched paths (tentative) - -```pseudo tree -src/session/ -├── structured-exchange-loop.ts ~ -├── structured-exchange-loop.test.ts ~ -├── exchange-projection.ts ? -└── exchange-projection.test.ts ? - -src/projections/structured-exchange/ -├── present-review-set.ts ~ -├── request-review.ts ~ -├── review-set-payload.ts +? -└── *.test.ts +? - -src/renderers/structured-exchange/ -└── request-review.ts ? - -src/rpc/ -├── methods/session.ts ~ -├── handlers.test.ts ~ -└── README.md ~? - -src/graph/ -├── review-set.ts ? -├── review-set.test.ts ? -└── command-executor/accept-review-set.test.ts ? - -src/probes/ -├── review-set-approval-proof.ts +? -└── review-set-approval-proof.test.ts +? - -memory/ -├── PLAN.md ~ -└── SPEC.md ? - -docs/design/ -└── REVIEW_SETS.md ? -``` diff --git a/memory/cards/project-graph-review-cycle--real-probe.md b/memory/cards/project-graph-review-cycle--real-probe.md deleted file mode 100644 index 291d5bb5a..000000000 --- a/memory/cards/project-graph-review-cycle--real-probe.md +++ /dev/null @@ -1,101 +0,0 @@ -# Project-graph review-cycle real probe - -Frontier: project-graph-review-cycle -Status: done -Mode: single -Created: 2026-06-06 - -## Orientation - -- Containing seam: FE-809 `project-graph-review-cycle`; product approval wiring is landed, and the remaining risk is whether the real agent strategy emits a reviewable `present_review_set` that can be approved through public RPC. -- Relevant frontier item: `project-graph-review-cycle` on branch `ln/fe-809-project-graph-review-cycle`. -- Volatile state: reuse the clean explicit-basis fixture `.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json`; do not mutate `.fixtures/workbenches/bilal-curation`. -- Main open risk: a synthetic probe could accidentally prove only the adapter/RPC path, not the real `project-graph` agent proposal path. - -Posture: proving (inherited from `project-graph-review-cycle`). - -## Card 1 — Real project-graph review-cycle probe - -Status: done -Weight: light - -Completed: 2026-06-06 — added `src/probes/project-graph-review-cycle-proof.ts`, fixed the commitment-grade active-tool policy so `project-graph` can call `present_review_set` / `request_review`, and persisted successful evidence at `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/`. The real run recorded two non-reviewable `structural_illegal` dry-run attempts before one successful reviewable `present_review_set`; public RPC approval then committed 2 explicit nodes and 4 explicit edges at selected-spec LSN 4. - -### Objective - -Add and run a reusable probe that proves a real `project-graph` agent turn can produce a dry-run-valid review set, expose it as a pending review exchange, approve it through `session.submitExchangeResponse`, and read back the explicit-basis graph commit. - -### Acceptance Criteria - -```pseudo tree -project-graph review-cycle proof -├── reusable probe script -│ ├── ✓ seeds a temp workspace from `.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json` -│ ├── ✓ switches the selected session to `agentStrategy: "project-graph"` -│ ├── ✓ prompts the real agent to read graph context and produce one successful reviewable review set -│ ├── ✓ approves the pending review through public Brunch RPC, not by calling `acceptReviewSet` directly -│ └── ✓ writes `session.jsonl`, `transcript.md`, `report.json`, and `graph-snapshot.json` under `.fixtures/runs/project-graph-review-cycle//` -├── report oracle -│ ├── ✓ records whether `present_review_set` and `request_review` transcript results were observed -│ ├── ✓ records approve result status, LSN, created node/edge counts, and explicit-basis readback -│ ├── ✓ flags friction when no pending review exchange appears, approval fails, or graph truth is unchanged -│ └── ✓ fails success unless the graph LSN advances for the selected spec through review approval -└── frontier reconciliation - ├── ✓ `memory/PLAN.md` marks FE-809 complete if the real probe succeeds - └── ✓ residual agent/prompt drift becomes a narrow follow-up if the probe fails for non-harness reasons -``` - -### Verification Approach - -- Inner: probe unit tests for transcript parsing, report summarization, and artifact path/report writing. -- Middle: targeted probe command with the real agent runtime. -- Gate: `npm run verify` if implementation changes source/tests beyond generated run artifacts. - -### Cross-cutting obligations - -- No direct SQLite graph writes. -- No direct `CommandExecutor.acceptReviewSet` call from the probe; approval must go through public RPC so the proof covers transcript recovery and response wiring. -- Preserve projected-code graph references at the review-payload boundary. -- Keep generated evidence in `.fixtures/runs/project-graph-review-cycle/`; do not use or mutate `.fixtures/workbenches/bilal-curation`. - -### Assumption dependency - -None — the probe is the proof for the remaining FE-809 assumption. - -### Expected touched paths (tentative) - -```pseudo tree -src/probes/ -├── project-graph-review-cycle-proof.ts + -└── project-graph-review-cycle-proof.test.ts + - -src/.pi/ -├── agents/ -│ ├── state.ts ~ -│ └── state.test.ts ~ -└── __tests__/ - └── prompting.test.ts ~ - -.fixtures/runs/project-graph-review-cycle/ -└── / + - ├── session.jsonl - ├── transcript.md - ├── report.json - └── graph-snapshot.json - -memory/ -├── PLAN.md ~ -└── cards/project-graph-review-cycle--real-probe.md ~ -``` - -### Promotion checklist - -- [ ] Changes a requirement -- [ ] Creates, retires, or invalidates an assumption -- [ ] Depends on an unvalidated high-impact assumption -- [ ] Makes or reverses a non-trivial design decision -- [ ] Establishes a new seam-level invariant -- [ ] Changes a frontier-level cross-cutting obligation or verification architecture layer -- [ ] Crosses more than two major implementation seams rather than observing them through the probe -- [ ] First touch in an unfamiliar seam -- [ ] Cannot name the containing seam or rationale diff --git a/package.json b/package.json index d53afdddc..06988e0b1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "tsc -p tsconfig.build.json && npm run build:pi-assets && npm run build:web", "build:pi-assets": "mkdir -p dist/.pi/components/workspace-dialog dist/.pi/agents dist/.pi/skills && cp -R src/.pi/components/workspace-dialog/assets dist/.pi/components/workspace-dialog/ && cp -R src/.pi/agents/definitions dist/.pi/agents/ && cp -R src/.pi/skills/goals src/.pi/skills/strategies src/.pi/skills/lenses src/.pi/skills/methods dist/.pi/skills/", "build:web": "vite build", + "render": "tsx src/scripts/render-preview.ts", "seed": "tsx src/graph/seed-fixtures.ts", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", diff --git a/src/.pi/__tests__/context-tools.test.ts b/src/.pi/__tests__/context-tools.test.ts new file mode 100644 index 000000000..3c9bae92f --- /dev/null +++ b/src/.pi/__tests__/context-tools.test.ts @@ -0,0 +1,229 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { openWorkspaceCommandExecutor } from '../../graph/index.js'; +import { seedFixture, type SeedFixture } from '../../graph/seed-fixtures.js'; +import { createSessionBindingData } from '../../session/session-binding.js'; +import { registerBrunchContext } from '../extensions/context/index.js'; + +describe('context tools', () => { + it('read_workspace_context returns a gitignore-aware cwd inventory', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-context-tool-')); + await mkdir(join(cwd, '.brunch', 'sessions'), { recursive: true }); + await mkdir(join(cwd, 'visible'), { recursive: true }); + await mkdir(join(cwd, 'ignored-dir'), { recursive: true }); + await writeFile(join(cwd, '.gitignore'), ['ignored-dir/', 'ignored.md'].join('\n')); + await writeFile(join(cwd, 'README.md'), '# Context\n'); + await writeFile(join(cwd, 'ignored.md'), '# Hidden\n'); + await writeFile(join(cwd, 'visible', 'guide.md'), 'Guide\n'); + await writeFile( + join(cwd, '.brunch', 'sessions', 'session-1.jsonl'), + [ + JSON.stringify({ type: 'session', id: 'session-1', cwd }), + JSON.stringify({ + id: 'binding-1', + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId: 1 }), + }), + ].join('\n') + '\n', + ); + + const tools = new Map Promise }>(); + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + + const result = (await tools + .get('read_workspace_context')! + .execute('context-cwd', { mode: 'cwd_inventory' }, undefined, undefined, { + sessionManager: { + getEntries: () => [{ type: 'session', id: 'session-1', cwd }], + }, + })) as { + content: Array<{ type: 'text'; text: string }>; + details: { mode: 'cwd_inventory'; data: { markdownFiles: Array<{ path: string }> } }; + }; + + expect(result.content[0]?.text).toContain('[Workspace cwd inventory]'); + expect(result.content[0]?.text).toContain('existing .brunch state detected'); + expect(result.content[0]?.text).toContain('session-1.jsonl'); + expect(result.details.mode).toBe('cwd_inventory'); + expect(result.details.data.markdownFiles.map((file) => file.path)).toEqual([ + 'README.md', + 'visible/guide.md', + ]); + }); + + it('read_session_context returns runtime-frame markdown plus typed details', async () => { + const tools = new Map Promise }>(); + + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + + const result = (await tools.get('read_session_context')!.execute('context-1', {}, undefined, undefined, { + sessionManager: { + getEntries: () => [ + { type: 'session', id: 'session-1', cwd: '/tmp/brunch' }, + { + id: 'binding-1', + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId: 1 }), + }, + { + id: 'runtime-1', + type: 'custom', + parentId: 'binding-1', + customType: 'brunch.agent_runtime_state', + data: { + schemaVersion: 1, + reason: 'switch', + source: 'user', + state: { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'project-graph', + agentLens: 'oracle', + agentGoal: 'commit-converge', + }, + }, + }, + { + id: 'mention-1', + type: 'custom', + parentId: 'runtime-1', + customType: 'brunch.mention', + data: { entityId: 'node-1', handle: 'D12', title: 'Decision seam', snapshottedLsn: 7 }, + }, + ], + }, + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + + expect(result.content[0]?.text).toContain('[Selected session runtime frame]'); + expect(result.content[0]?.text).toContain('#D12'); + expect(result.content[0]?.text).not.toContain('node-1'); + expect(result.details).toMatchObject({ + status: 'ready', + specId: 1, + sessionId: 'session-1', + agent: { + strategy: 'project-graph', + lens: 'oracle', + goal: 'commit-converge', + }, + }); + }); + + it('read_session_context reports missing binding as not_ready instead of throwing', async () => { + const tools = new Map Promise }>(); + + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + + const result = (await tools.get('read_session_context')!.execute('context-2', {}, undefined, undefined, { + sessionManager: { + getEntries: () => [{ type: 'session', id: 'session-1', cwd: '/tmp/brunch' }], + }, + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + + expect(result.content[0]?.text).toContain('status: not_ready'); + expect(result.details).toEqual({ + status: 'not_ready', + reason: 'missing_binding', + sessionId: 'session-1', + }); + }); + + it('read_workspace_context returns a workspace overview for bound specs and sessions', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-context-overview-')); + const executor = await openWorkspaceCommandExecutor(cwd); + const alpha = seedFixture(executor, await loadFixture('alpha-grounding', 'workspace-spread')); + const beta = seedFixture(executor, await loadFixture('beta-commitments', 'workspace-spread')); + + await mkdir(join(cwd, '.brunch', 'sessions'), { recursive: true }); + await writeBoundSession(cwd, 'alpha-session', alpha.specId, [{ type: 'user', id: 'u1' }]); + await writeBoundSession(cwd, 'beta-session', beta.specId, [ + { type: 'user', id: 'u1' }, + { type: 'assistant', id: 'a1' }, + ]); + + const tools = new Map Promise }>(); + registerBrunchContext({ + registerTool(tool: { name: string; execute: (...args: any[]) => Promise }) { + tools.set(tool.name, tool); + }, + } as never); + + const result = (await tools + .get('read_workspace_context')! + .execute('context-overview', { mode: 'workspace_overview' }, undefined, undefined, { + sessionManager: { + getEntries: () => [{ type: 'session', id: 'session-1', cwd }], + }, + })) as { + content: Array<{ type: 'text'; text: string }>; + details: { + mode: 'workspace_overview'; + data: { specs: Array<{ title: string }>; sessions: Array<{ turnCount: number }> }; + }; + }; + + expect(result.content[0]?.text).toContain('[Workspace overview]'); + expect(result.content[0]?.text).toContain('Alpha Grounding'); + expect(result.content[0]?.text).toContain('Beta Commitments'); + expect(result.details.mode).toBe('workspace_overview'); + expect(result.details.data.specs.map((spec) => spec.title)).toEqual([ + 'Alpha Grounding', + 'Beta Commitments', + ]); + expect(result.details.data.sessions.map((session) => session.turnCount)).toEqual([1, 2]); + }); +}); + +async function loadFixture(slug: string, set = 'bilal-port'): Promise { + const fixturePath = fileURLToPath(new URL(`../../../.fixtures/seeds/${set}/${slug}.json`, import.meta.url)); + return JSON.parse(await import('node:fs/promises').then(({ readFile }) => readFile(fixturePath, 'utf8'))); +} + +async function writeBoundSession( + cwd: string, + sessionId: string, + specId: number, + entries: Array<{ type: 'user' | 'assistant'; id: string }>, +): Promise { + await writeFile( + join(cwd, '.brunch', 'sessions', `${sessionId}.jsonl`), + [ + JSON.stringify({ type: 'session', id: sessionId, cwd }), + JSON.stringify({ + id: `${sessionId}-binding`, + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId }), + }), + ...entries.map((entry) => JSON.stringify(entry)), + ].join('\n') + '\n', + ); +} diff --git a/src/.pi/__tests__/extension-registry.test.ts b/src/.pi/__tests__/extension-registry.test.ts index 3f167c530..9d6850805 100644 --- a/src/.pi/__tests__/extension-registry.test.ts +++ b/src/.pi/__tests__/extension-registry.test.ts @@ -15,6 +15,7 @@ import commands, { BRUNCH_SWITCH_COMMAND, } from '../extensions/commands/index.js'; import commandPolicy from '../extensions/commands/policy.js'; +import context from '../extensions/context/index.js'; import structuredExchange, { PRESENT_OPTIONS_TOOL, PRESENT_QUESTION_TOOL, @@ -34,6 +35,7 @@ const extensionDefaults = { 'chrome/index.ts': chrome, 'commands/policy.ts': commandPolicy, 'commands/index.ts': commands, + 'context/index.ts': context, 'mentions/index.ts': mentionAutocomplete, 'runtime/index.ts': operationalMode, 'system-prompts/index.ts': prompting, @@ -61,6 +63,8 @@ describe('Brunch explicit Pi extension registry', () => { 'grep', 'find', 'ls', + 'read_workspace_context', + 'read_session_context', 'present_alternatives', PRESENT_QUESTION_TOOL, PRESENT_OPTIONS_TOOL, diff --git a/src/.pi/__tests__/graph-tools.test.ts b/src/.pi/__tests__/graph-tools.test.ts index 14b556cbc..636a78e3b 100644 --- a/src/.pi/__tests__/graph-tools.test.ts +++ b/src/.pi/__tests__/graph-tools.test.ts @@ -2,7 +2,7 @@ * Graph tool integration tests. * * Tests the commit_graph and read_graph tools end-to-end through - * the command adapter → CommandExecutor → snapshot reader chain. + * the command adapter → CommandExecutor → graph read chain. * * SPEC: D4-L, D20-L, D52-L, D53-L, I26-L, I34-L, A14-L */ @@ -14,15 +14,24 @@ import { createDb } from '../../db/connection.js'; import type { BrunchDb } from '../../db/connection.js'; import { edges } from '../../db/schema.js'; import { CommandExecutor } from '../../graph/command-executor.js'; -import { getGraphOverview, getNodeNeighborhood, resolveGraphNodeCode } from '../../graph/snapshot.js'; +import { + getGraphGaps, + getGraphOverview, + getGraphSliceByKinds, + getGraphSliceByReadinessBands, + getNodeNeighborhood, + getRelatedNodes, + resolveGraphNodeCode, +} from '../../graph/queries.js'; import { createProductUpdatePublisher } from '../../rpc/product-updates.js'; import { translateCommitGraph, formatCommitGraphResult, formatGraphOverview, formatNeighborhoodResult, + formatRelatedNodesResult, } from '../extensions/graph/command-adapter.js'; -import { registerBrunchGraph, type GraphSnapshotReaders } from '../extensions/graph/index.js'; +import { registerBrunchGraph, type GraphReaders } from '../extensions/graph/index.js'; import { CommitGraphParams, ReadGraphParams } from '../extensions/graph/tool-schemas.js'; // --------------------------------------------------------------------------- @@ -42,9 +51,13 @@ function seedSpec(db: BrunchDb): number { return result.specId; } -function createSnapshots(db: BrunchDb, specId: number): GraphSnapshotReaders { +function createGraphReads(db: BrunchDb, specId: number): GraphReaders { return { - getGraphOverview: () => getGraphOverview(db, specId), + getGraphOverview: (options) => getGraphOverview(db, specId, options), + getGraphSliceByKinds: (options) => getGraphSliceByKinds(db, specId, options), + getGraphSliceByReadinessBands: (options) => getGraphSliceByReadinessBands(db, specId, options), + getGraphGaps: (options) => getGraphGaps(db, specId, options), + getRelatedNodes: (options) => getRelatedNodes(db, specId, options), getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, specId, nodeId, options), resolveNodeCode: (code) => resolveGraphNodeCode(db, specId, code), }; @@ -144,6 +157,45 @@ describe('graph tool schemas', () => { expect(Value.Check(ReadGraphParams, { mode: 'neighborhood', nodeCode: 'G1' })).toBe(true); expect(Value.Check(ReadGraphParams, { mode: 'neighborhood', node_id: 1 })).toBe(false); }); + + it('accepts list read modes with projection-aware kind or readiness filters', () => { + expect( + Value.Check(ReadGraphParams, { + mode: 'list_by_kind', + kinds: ['goal', 'requirement'], + projection: 'graph_truth', + }), + ).toBe(true); + expect( + Value.Check(ReadGraphParams, { + mode: 'list_by_band', + readinessBands: ['grounding', 'elicitation'], + }), + ).toBe(true); + }); + + it('accepts related mode with anchor codes, category, direction, and hops', () => { + expect( + Value.Check(ReadGraphParams, { + mode: 'related', + anchorCodes: ['R1'], + edgeCategory: 'dependency', + direction: 'outgoing', + hops: 2, + }), + ).toBe(true); + }); + + it('accepts gaps mode with a base filter and absent edge category', () => { + expect( + Value.Check(ReadGraphParams, { + mode: 'gaps', + kinds: ['thesis'], + absentEdgeCategory: 'proof', + direction: 'incoming', + }), + ).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -206,14 +258,14 @@ describe('formatGraphOverview', () => { describe('graph tools end-to-end', () => { let db: BrunchDb; let executor: CommandExecutor; - let snapshots: GraphSnapshotReaders; + let reads: GraphReaders; let specId: number; beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); specId = seedSpec(db); - snapshots = createSnapshots(db, specId); + reads = createGraphReads(db, specId); }); it('commit_graph creates nodes and edges readable by read_graph', () => { @@ -239,7 +291,7 @@ describe('graph tools end-to-end', () => { expect(result.status).toBe('success'); // Read the graph - const overview = snapshots.getGraphOverview(); + const overview = reads.getGraphOverview(); const text = formatGraphOverview(overview); expect(overview.nodeCount).toBe(2); @@ -260,7 +312,7 @@ describe('graph tools end-to-end', () => { tools.set(tool.name, tool); }, } as never, - { specId, commandExecutor: executor, snapshots, productUpdates }, + { specId, commandExecutor: executor, reads, productUpdates }, ); await tools.get('commit_graph')!.execute('call-1', { @@ -286,7 +338,7 @@ describe('graph tools end-to-end', () => { tools.set(tool.name, tool); }, } as never, - { specId, commandExecutor: executor, snapshots }, + { specId, commandExecutor: executor, reads }, ); const result = (await tools.get('commit_graph')!.execute('commit-1', { @@ -321,7 +373,7 @@ describe('graph tools end-to-end', () => { tools.set(tool.name, tool); }, } as never, - { specId, commandExecutor: executor, snapshots }, + { specId, commandExecutor: executor, reads }, ); const result = (await tools.get('commit_graph')!.execute('commit-1', { @@ -348,7 +400,7 @@ describe('graph tools end-to-end', () => { registered.push(tool); }, } as never, - { specId, commandExecutor: executor, snapshots }, + { specId, commandExecutor: executor, reads }, ); const text = registered @@ -398,7 +450,7 @@ describe('graph tools end-to-end', () => { expect(result.status).toBe('structural_illegal'); // Node should NOT have been created (all-or-nothing) - const overview = snapshots.getGraphOverview(); + const overview = reads.getGraphOverview(); expect(overview.nodeCount).toBe(0); }); @@ -426,7 +478,7 @@ describe('graph tools end-to-end', () => { if (commitResult.status === 'success') { const nodeId = commitResult.createdNodes['n1']!.id; - const result = snapshots.getNodeNeighborhood(nodeId); + const result = reads.getNodeNeighborhood(nodeId); const text = formatNeighborhoodResult(result); expect(text).toContain('Main goal'); @@ -457,7 +509,7 @@ describe('graph tools end-to-end', () => { tools.set(tool.name, tool); }, } as never, - { specId, commandExecutor: executor, snapshots }, + { specId, commandExecutor: executor, reads }, ); const result = (await tools.get('read_graph')!.execute('read-1', { @@ -483,10 +535,303 @@ describe('graph tools end-to-end', () => { }); }); + it('read_graph list modes return projection-aware slices with projected node codes', async () => { + const oldRequirement = executor.createNode({ + specId, + plane: 'intent', + kind: 'requirement', + title: 'Legacy requirement', + }); + expect(oldRequirement.status).toBe('success'); + if (oldRequirement.status !== 'success') return; + + const commitResult = executor.commitGraph({ + specId, + basis: 'implicit', + nodes: [ + { ref: 'g1', plane: 'intent', kind: 'goal', title: 'Grounding goal' }, + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Current requirement' }, + ], + edges: [{ category: 'supersession', source: 'r1', target: { existing: oldRequirement.nodeId } }], + }); + expect(commitResult.status).toBe('success'); + if (commitResult.status !== 'success') return; + + const tools = new Map }>(); + registerBrunchGraph( + { + registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { + tools.set(tool.name, tool); + }, + } as never, + { specId, commandExecutor: executor, reads }, + ); + + const kindResult = (await tools.get('read_graph')!.execute('read-kind', { + mode: 'list_by_kind', + kinds: ['requirement'], + projection: 'graph_truth', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(kindResult.content[0]?.text).toContain('Graph slice by kind'); + expect(kindResult.content[0]?.text).toContain('[R1]'); + expect(kindResult.content[0]?.text).toContain('[R2]'); + expect(kindResult.details).toMatchObject({ + nodeCount: 2, + nodes: [{ title: 'Legacy requirement' }, { title: 'Current requirement' }], + }); + + const bandResult = (await tools.get('read_graph')!.execute('read-band', { + mode: 'list_by_band', + readinessBands: ['grounding'], + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(bandResult.content[0]?.text).toContain('Graph slice by readiness band'); + expect(bandResult.content[0]?.text).toContain('[G1]'); + expect(bandResult.details).toMatchObject({ + nodeCount: 1, + nodes: [{ title: 'Grounding goal' }], + }); + }); + + it('read_graph list modes return an empty slice for unknown filters instead of diagnostics', async () => { + const tools = new Map }>(); + registerBrunchGraph( + { + registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { + tools.set(tool.name, tool); + }, + } as never, + { specId, commandExecutor: executor, reads }, + ); + + const result = (await tools.get('read_graph')!.execute('read-empty', { + mode: 'list_by_band', + readinessBands: ['unknown-band'], + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + + expect(result.content[0]?.text).toContain('empty'); + expect(result.details).toMatchObject({ nodeCount: 0, edgeCount: 0, nodes: [], edges: [] }); + }); + + it('read_graph gaps mode returns projection-aware gaps and structural diagnostics', async () => { + const commitResult = executor.commitGraph({ + specId, + basis: 'implicit', + nodes: [ + { ref: 'thesis-gap', plane: 'intent', kind: 'thesis', title: 'Unproven thesis' }, + { ref: 'thesis-supported', plane: 'intent', kind: 'thesis', title: 'Supported thesis' }, + { + ref: 'term-gap', + plane: 'intent', + kind: 'term', + title: 'Unproved term', + detail: { definition: 'Gap' }, + }, + { + ref: 'term-target', + plane: 'intent', + kind: 'term', + title: 'Supported term', + detail: { definition: 'Covered' }, + }, + { ref: 'evidence-live', plane: 'oracle', kind: 'evidence', title: 'Active evidence' }, + { ref: 'evidence-old', plane: 'oracle', kind: 'evidence', title: 'Superseded evidence' }, + { ref: 'evidence-new', plane: 'oracle', kind: 'evidence', title: 'Replacement evidence' }, + ], + edges: [ + { category: 'proof', source: 'evidence-live', target: 'thesis-supported', stance: 'for' }, + { category: 'proof', source: 'evidence-old', target: 'term-target', stance: 'for' }, + { category: 'supersession', source: 'evidence-new', target: 'evidence-old' }, + ], + }); + expect(commitResult.status).toBe('success'); + if (commitResult.status !== 'success') return; + + const tools = new Map }>(); + registerBrunchGraph( + { + registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { + tools.set(tool.name, tool); + }, + } as never, + { specId, commandExecutor: executor, reads }, + ); + + const activeGaps = (await tools.get('read_graph')!.execute('read-gaps', { + mode: 'gaps', + kinds: ['term'], + absentEdgeCategory: 'proof', + direction: 'incoming', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(activeGaps.content[0]?.text).toContain('Graph gaps'); + expect(activeGaps.content[0]?.text).toContain('[T1]'); + expect(activeGaps.content[0]?.text).toContain('[T2]'); + expect(activeGaps.details).toMatchObject({ + nodeCount: 2, + nodes: [{ title: 'Unproved term' }, { title: 'Supported term' }], + }); + + const truthGaps = (await tools.get('read_graph')!.execute('read-gaps-truth', { + mode: 'gaps', + kinds: ['term'], + absentEdgeCategory: 'proof', + direction: 'incoming', + projection: 'graph_truth', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(truthGaps.details).toMatchObject({ + nodeCount: 1, + nodes: [{ title: 'Unproved term' }], + }); + + const missingBase = (await tools.get('read_graph')!.execute('read-gaps-missing-base', { + mode: 'gaps', + absentEdgeCategory: 'proof', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(missingBase.content[0]?.text).toContain('STRUCTURAL_ILLEGAL'); + expect(missingBase.details).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'kinds|readinessBands' }], + }); + + const missingCategory = (await tools.get('read_graph')!.execute('read-gaps-missing-category', { + mode: 'gaps', + kinds: ['term'], + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(missingCategory.content[0]?.text).toContain('STRUCTURAL_ILLEGAL'); + expect(missingCategory.details).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'absentEdgeCategory' }], + }); + }); + + it('read_graph related mode returns related nodes and structural_illegal for unknown anchors', async () => { + const commitResult = executor.commitGraph({ + specId, + basis: 'implicit', + nodes: [ + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Anchor requirement' }, + { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'Direct assumption' }, + ], + edges: [{ category: 'dependency', source: 'r1', target: 'a1' }], + }); + expect(commitResult.status).toBe('success'); + if (commitResult.status !== 'success') return; + + const tools = new Map }>(); + registerBrunchGraph( + { + registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { + tools.set(tool.name, tool); + }, + } as never, + { specId, commandExecutor: executor, reads }, + ); + + const related = (await tools.get('read_graph')!.execute('read-related', { + mode: 'related', + anchorCodes: ['R1'], + edgeCategory: 'dependency', + direction: 'outgoing', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(related.content[0]?.text).toContain('Related nodes'); + expect(related.content[0]?.text).toContain('dependency/outgoing'); + expect(related.content[0]?.text).toContain('[A1]'); + expect(related.details).toMatchObject({ + status: 'success', + anchors: [{ title: 'Anchor requirement' }], + relatedNodes: [{ title: 'Direct assumption' }], + }); + + const missingAnchor = (await tools.get('read_graph')!.execute('read-related-missing', { + mode: 'related', + anchorCodes: ['R99'], + edgeCategory: 'dependency', + })) as { + content: Array<{ type: 'text'; text: string }>; + details: unknown; + }; + expect(missingAnchor.content[0]?.text).toContain('STRUCTURAL_ILLEGAL'); + expect(missingAnchor.details).toMatchObject({ + status: 'structural_illegal', + diagnostics: [{ field: 'anchorCodes' }], + }); + }); + it('read_graph neighborhood for missing node returns not_found', () => { - const result = snapshots.getNodeNeighborhood(999); + const result = reads.getNodeNeighborhood(999); const text = formatNeighborhoodResult(result); expect(text).toContain('not found'); }); + + it('formats related-node results with projected codes and directions', () => { + const text = formatRelatedNodesResult({ + status: 'success', + anchors: [ + { + id: 1, + specId: 1, + plane: 'intent', + kind: 'requirement', + kindOrdinal: 1, + title: 'Anchor requirement', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + relatedNodes: [ + { + id: 2, + specId: 1, + plane: 'intent', + kind: 'assumption', + kindOrdinal: 1, + title: 'Related assumption', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + edges: [ + { + id: 1, + specId: 1, + category: 'dependency', + sourceId: 1, + targetId: 2, + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + }); + + expect(text).toContain('Anchors: [R1] Anchor requirement'); + expect(text).toContain('[A1] intent/assumption'); + expect(text).toContain('R1 -[dependency/outgoing]-> A1'); + }); }); diff --git a/src/.pi/__tests__/operational-mode.test.ts b/src/.pi/__tests__/operational-mode.test.ts index 85292439f..fc4b6ac12 100644 --- a/src/.pi/__tests__/operational-mode.test.ts +++ b/src/.pi/__tests__/operational-mode.test.ts @@ -142,6 +142,7 @@ describe('Brunch agent runtime-state projection', () => { 'request_choice', 'request_choices', 'read_graph', + 'read_session_context', 'commit_graph', 'bash', 'edit', @@ -178,6 +179,7 @@ describe('Brunch agent runtime-state projection', () => { 'request_choice', 'request_choices', 'read_graph', + 'read_session_context', ], ]); expect(promptResult).toBeUndefined(); diff --git a/src/.pi/__tests__/prompting.test.ts b/src/.pi/__tests__/prompting.test.ts index 96837b364..a7803cdb5 100644 --- a/src/.pi/__tests__/prompting.test.ts +++ b/src/.pi/__tests__/prompting.test.ts @@ -19,6 +19,16 @@ import { } from '../extensions/runtime/index.js'; import { registerBrunchPrompting } from '../extensions/system-prompts/index.js'; +function emptyGraphSlice() { + return { + lsn: 0, + nodeCount: 0, + edgeCount: 0, + nodes: [], + edges: [], + }; +} + function runtimeEntry(state: BrunchAgentState) { return { type: 'custom', @@ -63,7 +73,7 @@ const promptContext = { }), }, session: { id: 'session-1', label: 'Session' }, - graphSnapshots: { + graphReads: { getGraphOverview: () => ({ lsn: 4, nodeCount: 2, @@ -105,6 +115,10 @@ const promptContext = { }, ], }), + getGraphSliceByKinds: () => emptyGraphSlice(), + getGraphSliceByReadinessBands: () => emptyGraphSlice(), + getGraphGaps: () => emptyGraphSlice(), + getRelatedNodes: () => ({ status: 'not_found' as const }), getNodeNeighborhood: () => ({ status: 'not_found' as const }), resolveNodeCode: () => undefined, }, @@ -225,7 +239,7 @@ describe('Brunch prompt-pack topology', () => { spec: selected.spec, workspace: promptContext.workspace, session: selected.session, - graphSnapshots: { + graphReads: { getGraphOverview: () => ({ lsn: 1, nodeCount: selected.nodeTitles.length, @@ -243,6 +257,10 @@ describe('Brunch prompt-pack topology', () => { })), edges: [], }), + getGraphSliceByKinds: () => emptyGraphSlice(), + getGraphSliceByReadinessBands: () => emptyGraphSlice(), + getGraphGaps: () => emptyGraphSlice(), + getRelatedNodes: () => ({ status: 'not_found' as const }), getNodeNeighborhood: () => ({ status: 'not_found' as const }), resolveNodeCode: () => undefined, }, @@ -305,6 +323,7 @@ describe('Brunch prompt-pack topology', () => { 'present_review_set', 'request_review', 'read_graph', + 'read_session_context', 'commit_graph', ].map((name) => ({ name })), setActiveTools: (tools: string[]) => activeTools.push(tools), @@ -359,6 +378,7 @@ describe('Brunch prompt-pack topology', () => { 'request_choice', 'request_choices', 'read_graph', + 'read_session_context', ], [ 'read', @@ -368,6 +388,7 @@ describe('Brunch prompt-pack topology', () => { 'request_choice', 'request_choices', 'read_graph', + 'read_session_context', ], [ 'read', @@ -379,6 +400,7 @@ describe('Brunch prompt-pack topology', () => { 'present_review_set', 'request_review', 'read_graph', + 'read_session_context', 'commit_graph', ], [ @@ -389,6 +411,7 @@ describe('Brunch prompt-pack topology', () => { 'request_choice', 'request_choices', 'read_graph', + 'read_session_context', ], [ 'read', @@ -400,6 +423,7 @@ describe('Brunch prompt-pack topology', () => { 'present_review_set', 'request_review', 'read_graph', + 'read_session_context', 'commit_graph', ], ]); @@ -411,7 +435,7 @@ describe('Brunch prompt-pack topology', () => { }); expect(defaultPrompt).toMatchObject({ systemPrompt: expect.stringContaining( - '- active tools: read, grep, present_options, request_answer, request_choice, request_choices, present_review_set, request_review, read_graph, commit_graph', + '- active tools: read, grep, present_options, request_answer, request_choice, request_choices, present_review_set, request_review, read_graph, read_session_context, commit_graph', ), }); expect(defaultPrompt).toMatchObject({ @@ -432,9 +456,15 @@ describe('Brunch prompt-pack topology', () => { events[event] = handler; }, getAllTools: () => - ['read', 'grep', 'read_graph', 'commit_graph', 'present_review_set', 'request_review'].map( - (name) => ({ name }), - ), + [ + 'read', + 'grep', + 'read_graph', + 'read_session_context', + 'commit_graph', + 'present_review_set', + 'request_review', + ].map((name) => ({ name })), setActiveTools: (tools: string[]) => activeTools.push(tools), } as never, { diff --git a/src/.pi/__tests__/structured-exchange-boundaries.test.ts b/src/.pi/__tests__/structured-exchange-boundaries.test.ts index 034b3b998..f59810a07 100644 --- a/src/.pi/__tests__/structured-exchange-boundaries.test.ts +++ b/src/.pi/__tests__/structured-exchange-boundaries.test.ts @@ -5,20 +5,20 @@ import { describe, expect, it } from 'vitest'; const ROOT = process.cwd(); const STRUCTURED_EXCHANGE_EXTENSION = 'src/.pi/extensions/exchanges'; -const STRUCTURED_EXCHANGE_PROJECT = 'src/projections/structured-exchange'; +const STRUCTURED_EXCHANGE_PROJECT = 'src/projections/exchanges'; const STRUCTURED_EXCHANGE_SCHEMAS = 'src/.pi/extensions/exchanges/schemas'; const STRUCTURED_EXCHANGE_EMISSION_BOUNDARIES = [ STRUCTURED_EXCHANGE_EXTENSION, 'src/session/structured-exchange-loop.ts', ]; const ACTIVE_PROJECTORS = new Set([ - 'src/projections/structured-exchange/present-options.ts', - 'src/projections/structured-exchange/present-question.ts', - 'src/projections/structured-exchange/present-review-set.ts', - 'src/projections/structured-exchange/request-answer.ts', - 'src/projections/structured-exchange/request-choice.ts', - 'src/projections/structured-exchange/request-choices.ts', - 'src/projections/structured-exchange/request-review.ts', + 'src/projections/exchanges/present-options.ts', + 'src/projections/exchanges/present-question.ts', + 'src/projections/exchanges/present-review-set.ts', + 'src/projections/exchanges/request-answer.ts', + 'src/projections/exchanges/request-choice.ts', + 'src/projections/exchanges/request-choices.ts', + 'src/projections/exchanges/request-review.ts', ]); const ALLOWED_TYPEBOX_FILES = new Set(['src/.pi/extensions/exchanges/pi-schema.ts']); diff --git a/src/.pi/agents/README.md b/src/.pi/agents/README.md index 8d9ff6b00..9dc9736d1 100644 --- a/src/.pi/agents/README.md +++ b/src/.pi/agents/README.md @@ -21,7 +21,7 @@ The markdown resources the agent reads on demand live beside this layer but are - Pi extension hook registration — `.pi/extensions/system-prompts/`. - Pi tool definitions and UI collection — `.pi/extensions/*`. - Reusable product DTO projection or markdown rendering — target `projections/` and `renderers/` seams. -- Graph domain logic or snapshot PULL — `graph/`. +- Graph domain logic or read/query PULL — `graph/`. - Session transcript/workspace semantics — `session/`. ## Layout @@ -44,21 +44,21 @@ agents/ ## Composition model -`composeAgentPrompt(agentId, sessionState, spec, workspace, snapshots)` emits: +`composeAgentPrompt(agentId, sessionState, spec, workspace, context)` emits: 1. agent control header — identity, model/thinking expectation, role derived from `op_mode`, tool authority; 2. runtime-state header — current pinned/AUTO `goal`/`strategy`/`lens`, readiness grade, posture; 3. resource manifests — ``, ``, ``, `` entries, filtered by tuple/grade/`op_mode`/allow-list; -4. compact pushed context — minimal snapshot summary/handles. +4. compact pushed context — minimal context handles and rendered context blocks. Detailed goal/strategy/lens/method bodies are markdown resources under `.pi/skills/` and are loaded with `read` when detail matters. Manifest metadata is code-owned in `state.ts`, not filesystem-discovered. -## Snapshot/context split +## Context split ```pseudo PULL -> graph/, session/ [typed, read-only] RENDER -> reusable renderers eventually; .pi/agents/contexts chooses audience/detail -SURFACE -> extensions/system-prompts/ or snapshot/read_graph tools +SURFACE -> extensions/system-prompts/ or read_graph / context read tools ``` `contexts/` is not a `` manifest resource family. It chooses which typed pull to expose, how much detail to include, and how lens/grade/mode shape the prompt-facing string. diff --git a/src/.pi/agents/compose.test.ts b/src/.pi/agents/compose.test.ts index 124529bf2..2f520504d 100644 --- a/src/.pi/agents/compose.test.ts +++ b/src/.pi/agents/compose.test.ts @@ -41,8 +41,8 @@ function workspacePosture(posture: WorkspacePostureState): WorkspacePostureState return posture; } -const snapshots = { - contextHandles: ['graph-overview: compact selected-spec graph summary available via snapshot tools'], +const context = { + contextHandles: ['graph-overview: compact selected-spec graph summary available via read tools'], renderedContexts: [ '[Selected-spec graph context · intent lens]\n- selected-spec lsn: 7; nodes: 1; edges: 0', ], @@ -55,7 +55,7 @@ describe('composeAgentPrompt', () => { sessionState: projectBrunchAgentState([]), spec: groundingSpec, workspace, - snapshots, + context, activeTools: ['read', 'grep', 'present_options'], }); @@ -78,7 +78,7 @@ describe('composeAgentPrompt', () => { expect(result.prompt).not.toContain('name="commit-converge"'); }); - it('surfaces rendered snapshot text and preserves manifest legality when lens changes', () => { + it('surfaces rendered context text and preserves manifest legality when lens changes', () => { const intent = composeAgentPrompt({ agentId: 'elicitor', sessionState: projectBrunchAgentState([ @@ -98,7 +98,7 @@ describe('composeAgentPrompt', () => { ]), spec: elicitationSpec, workspace, - snapshots: { + context: { renderedContexts: ['[Selected-spec graph context · intent lens]\n- emphasis: intent claims'], }, activeTools: ['read'], @@ -122,7 +122,7 @@ describe('composeAgentPrompt', () => { ]), spec: elicitationSpec, workspace, - snapshots: { + context: { renderedContexts: ['[Selected-spec graph context · design lens]\n- emphasis: design modules'], }, activeTools: ['read'], @@ -199,6 +199,32 @@ describe('composeAgentPrompt', () => { expect(pinned.manifests.goals.map((entry) => entry.name)).toEqual(['elicit-expand']); expect(pinned.manifests.strategies.map((entry) => entry.name)).toEqual(['step-wise-disambiguate']); expect(pinned.manifests.lenses.map((entry) => entry.name)).toEqual(['design']); + + const pinnedFreestyle = composeAgentPrompt({ + agentId: 'elicitor', + sessionState: projectBrunchAgentState([ + { + type: 'custom', + customType: 'brunch.agent_runtime_state', + data: { + schemaVersion: 1, + reason: 'switch', + source: 'user', + state: { + ...DEFAULT_BRUNCH_AGENT_STATE, + agentStrategy: 'freestyle', + }, + }, + }, + ]), + spec: groundingSpec, + workspace, + activeTools: ['read'], + }); + + expect(pinnedFreestyle.manifests.strategies.map((entry) => entry.name)).toEqual(['freestyle']); + expect(auto.prompt).not.toContain('name="freestyle"'); + expect(pinnedFreestyle.prompt).toContain('name="freestyle"'); }); it('rejects illegal pinned grade-gated selections loudly', () => { diff --git a/src/.pi/agents/compose.ts b/src/.pi/agents/compose.ts index 232cd2eab..797d91e62 100644 --- a/src/.pi/agents/compose.ts +++ b/src/.pi/agents/compose.ts @@ -18,7 +18,7 @@ export interface AgentPromptWorkspaceContext { posture?: Partial; } -export interface AgentPromptSnapshotContext { +export interface AgentPromptContextBundle { contextHandles?: readonly string[]; renderedContexts?: readonly string[]; } @@ -28,7 +28,7 @@ export interface ComposeAgentPromptInput { sessionState: ResolvedBrunchAgentState; spec: AgentPromptSpecContext; workspace: AgentPromptWorkspaceContext; - snapshots?: AgentPromptSnapshotContext; + context?: AgentPromptContextBundle; activeTools?: readonly string[]; } @@ -49,7 +49,7 @@ export function composeAgentPrompt(input: ComposeAgentPromptInput): ComposeAgent const prompt = joinSections([ renderAgentControl(input, definition), renderRuntimeState(input), - renderPushedContext(input.snapshots), + renderPushedContext(input.context), renderManifestFamily('available_goals', manifests.goals), renderManifestFamily('available_strategies', manifests.strategies), renderManifestFamily('available_lenses', manifests.lenses), @@ -96,15 +96,15 @@ function renderPosture(posture: AgentPromptWorkspaceContext['posture']): string return entries.length > 0 ? entries.map(([key, value]) => `${key}=${value}`).join('; ') : 'unrecorded'; } -function renderPushedContext(snapshots: AgentPromptSnapshotContext | undefined): string { - const handles = snapshots?.contextHandles ?? []; - const renderedContexts = snapshots?.renderedContexts ?? []; +function renderPushedContext(context: AgentPromptContextBundle | undefined): string { + const handles = context?.contextHandles ?? []; + const renderedContexts = context?.renderedContexts ?? []; return [ '[Brunch pushed context]', ...(handles.length ? handles.map((handle) => `- handle: ${handle}`) : ['- handles: none pushed']), ...(renderedContexts.length - ? ['- rendered snapshots:', ...renderedContexts.map(indentBlock)] - : ['- rendered snapshots: none pushed']), + ? ['- rendered context blocks:', ...renderedContexts.map(indentBlock)] + : ['- rendered context blocks: none pushed']), ].join('\n'); } diff --git a/src/.pi/agents/contexts/graph.test.ts b/src/.pi/agents/contexts/graph.test.ts index 322cd8c98..8cfe374e5 100644 --- a/src/.pi/agents/contexts/graph.test.ts +++ b/src/.pi/agents/contexts/graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../../../graph/snapshot.js'; +import type { GraphOverview } from '../../../graph/queries.js'; import { renderGraphContext } from './graph.js'; const overview: GraphOverview = { diff --git a/src/.pi/agents/contexts/graph.ts b/src/.pi/agents/contexts/graph.ts index fc3c09abe..aac60a0e1 100644 --- a/src/.pi/agents/contexts/graph.ts +++ b/src/.pi/agents/contexts/graph.ts @@ -1,5 +1,5 @@ +import type { GraphOverview } from '../../../graph/queries.js'; import { formatGraphNodeCode, type GraphNode } from '../../../graph/schema/nodes.js'; -import type { GraphOverview } from '../../../graph/snapshot.js'; import type { AgentLensSelection } from '../../../session/runtime-state.js'; export interface RenderGraphContextOptions { diff --git a/src/.pi/agents/contexts/node.test.ts b/src/.pi/agents/contexts/node.test.ts index fdbc0f44d..9353dcb35 100644 --- a/src/.pi/agents/contexts/node.test.ts +++ b/src/.pi/agents/contexts/node.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; +import type { NeighborhoodResult } from '../../../graph/queries.js'; import type { GraphNode } from '../../../graph/schema/nodes.js'; -import type { NeighborhoodResult } from '../../../graph/snapshot.js'; import { renderNodeContext } from './node.js'; const neighborhood: NeighborhoodResult = { @@ -41,7 +41,7 @@ describe('renderNodeContext', () => { expect(rendered).toContain('- anchor body: A long body explains the requirement.'); expect(rendered).toContain('[M2] design/module: Graph snapshot reader'); expect(rendered).toContain('…1 more neighbor(s) omitted'); - expect(rendered).toContain('#5: M2 -[realization]-> R1'); + expect(rendered).toContain('M2 -[realization]-> R1'); }); it('renders a clear selected-spec missing-node result', () => { diff --git a/src/.pi/agents/contexts/node.ts b/src/.pi/agents/contexts/node.ts index 6431cf0ae..314a8c0a2 100644 --- a/src/.pi/agents/contexts/node.ts +++ b/src/.pi/agents/contexts/node.ts @@ -1,4 +1,4 @@ -import type { NeighborhoodResult } from '../../../graph/snapshot.js'; +import type { NeighborhoodResult } from '../../../graph/queries.js'; import { projectNeighborhood } from '../../../projections/graph/neighborhood.js'; import { formatNeighborhood } from '../../../renderers/graph/neighborhood.js'; diff --git a/src/.pi/agents/index.ts b/src/.pi/agents/index.ts index 1d5ce9b18..503f0bbff 100644 --- a/src/.pi/agents/index.ts +++ b/src/.pi/agents/index.ts @@ -1,7 +1,7 @@ export { composeAgentPrompt, type AgentPromptSpecContext, - type AgentPromptSnapshotContext, + type AgentPromptContextBundle, type AgentPromptWorkspaceContext, type ComposeAgentPromptInput, type ComposeAgentPromptResult, diff --git a/src/.pi/agents/state.test.ts b/src/.pi/agents/state.test.ts index 5d0c26ca4..e4c2d6a79 100644 --- a/src/.pi/agents/state.test.ts +++ b/src/.pi/agents/state.test.ts @@ -19,6 +19,7 @@ const registeredToolNames = [ 'present_review_set', 'request_review', 'read_graph', + 'read_session_context', 'commit_graph', ]; @@ -54,6 +55,7 @@ describe('agent posture policy', () => { expect(groundingMethods).not.toContain('commit-graph'); expect(groundingTools).not.toContain('commit_graph'); expect(groundingTools).toContain('read_graph'); + expect(groundingTools).toContain('read_session_context'); expect(groundingTools).not.toContain('bash'); expect(groundingTools).toEqual( expect.arrayContaining(['present_question', 'present_options', 'request_answer']), @@ -66,4 +68,48 @@ describe('agent posture policy', () => { expect(commitmentsTools).toEqual(expect.arrayContaining(['present_review_set', 'request_review'])); expect(elicitationTools).not.toContain('present_review_set'); }); + + it('keeps freestyle pin-only while leaving elicit tool authority unchanged', () => { + const autoState = projectBrunchAgentState([]); + const pinnedFreestyle = projectBrunchAgentState([ + { + type: 'custom', + customType: 'brunch.agent_runtime_state', + data: { + schemaVersion: 1, + reason: 'switch', + source: 'user', + state: { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'freestyle', + agentLens: 'auto', + agentGoal: 'grounding-advance', + }, + }, + }, + ]); + + expect(manifestsForState(autoState, 'elicitation_ready').strategies.map((entry) => entry.name)).toEqual([ + 'step-wise-decision-tree', + 'step-wise-disambiguate', + 'propose-graph', + ]); + expect( + manifestsForState(pinnedFreestyle, 'grounding_onboarding').strategies.map((entry) => entry.name), + ).toEqual(['freestyle']); + expect( + activeToolNamesForPosture({ + registeredToolNames, + state: pinnedFreestyle, + readinessGrade: 'elicitation_ready', + }), + ).toEqual( + activeToolNamesForPosture({ + registeredToolNames, + state: autoState, + readinessGrade: 'elicitation_ready', + }), + ); + }); }); diff --git a/src/.pi/agents/state.ts b/src/.pi/agents/state.ts index b1f274233..40e6e8018 100644 --- a/src/.pi/agents/state.ts +++ b/src/.pi/agents/state.ts @@ -13,7 +13,7 @@ export type MethodId = | 'run-structured-exchange' | 'infer-and-capture' | 'commit-graph' - | 'read-snapshot' + | 'read-context' | 'generate-proposal' | 'review-for-gaps'; @@ -63,12 +63,15 @@ const GOAL_MIN_GRADE: Record = { }; const STRATEGY_MIN_GRADE: Record = { + freestyle: 'grounding_onboarding', 'step-wise-decision-tree': 'grounding_onboarding', 'step-wise-disambiguate': 'grounding_onboarding', 'propose-graph': 'elicitation_ready', 'project-graph': 'commitments_ready', }; +const AUTO_EXCLUDED_STRATEGIES = new Set(['freestyle']); + const LENS_MIN_GRADE: Record = { intent: 'grounding_onboarding', design: 'elicitation_ready', @@ -78,7 +81,7 @@ const LENS_MIN_GRADE: Record = { const METHOD_MIN_GRADE: Record = { 'run-structured-exchange': 'grounding_onboarding', 'infer-and-capture': 'grounding_onboarding', - 'read-snapshot': 'grounding_onboarding', + 'read-context': 'grounding_onboarding', 'commit-graph': 'elicitation_ready', 'generate-proposal': 'commitments_ready', 'review-for-gaps': 'commitments_ready', @@ -92,7 +95,7 @@ const METHOD_TOOL_NAMES: Partial> = { 'request_choice', 'request_choices', ], - 'read-snapshot': ['read_graph'], + 'read-context': ['read_graph', 'read_session_context'], 'commit-graph': ['commit_graph'], 'generate-proposal': ['present_review_set', 'request_review'], }; @@ -108,6 +111,7 @@ export const AGENT_PROMPT_DEFINITIONS: Record = }; export const STRATEGY_RESOURCES: Record = { + freestyle: resource( + 'strategies', + 'freestyle', + 'Let the user drive with ordinary turns while keeping structured exchanges available as needed.', + ), 'step-wise-decision-tree': resource( 'strategies', 'step-wise-decision-tree', @@ -201,10 +210,10 @@ export const METHOD_RESOURCES: Record = { 'commit-graph', 'Commit graph truth only through Brunch graph tools and CommandExecutor-backed results.', ), - 'read-snapshot': resource( + 'read-context': resource( 'methods', - 'read-snapshot', - 'Use pushed context handles and snapshot tools for selected-spec context.', + 'read-context', + 'Use pushed context handles and read-only context tools for selected-spec context.', ), 'generate-proposal': resource( 'methods', @@ -250,6 +259,7 @@ export function manifestsForState( minGrades: STRATEGY_MIN_GRADE, readinessGrade, state, + autoExcluded: AUTO_EXCLUDED_STRATEGIES, }), lenses: selectAxisResources({ label: 'lens', @@ -299,6 +309,7 @@ function selectAxisResources({ minGrades, readinessGrade, state, + autoExcluded, }: { label: 'goal' | 'strategy' | 'lens'; selection: 'auto' | TId; @@ -307,9 +318,12 @@ function selectAxisResources({ minGrades: Record; readinessGrade: ReadinessGrade; state: ResolvedBrunchAgentState; + autoExcluded?: ReadonlySet; }): readonly PromptResourceManifestEntry[] { const legal = allowed.filter((id) => isGradeLegal(id, readinessGrade, minGrades)); - if (selection === 'auto') return legal.map((id) => resources[id]); + if (selection === 'auto') { + return legal.filter((id) => !autoExcluded?.has(id)).map((id) => resources[id]); + } if (!legal.includes(selection)) { throw new Error( `Pinned ${label} "${selection}" is not legal for ${state.agentRole} in ${state.operationalMode} at readiness grade ${readinessGrade}.`, diff --git a/src/.pi/brunch-pi-extensions.ts b/src/.pi/brunch-pi-extensions.ts index e490ca258..c90bfe585 100644 --- a/src/.pi/brunch-pi-extensions.ts +++ b/src/.pi/brunch-pi-extensions.ts @@ -5,6 +5,7 @@ import { registerBrunchChrome } from './extensions/chrome/index.js'; import { type BrunchChromeState } from './extensions/chrome/index.js'; import { registerBrunchCommands, type BrunchCommandsOptions } from './extensions/commands/index.js'; import { registerBrunchBranchPolicyHandlers } from './extensions/commands/policy.js'; +import { registerBrunchContext } from './extensions/context/index.js'; import { registerStructuredExchange } from './extensions/exchanges/index.js'; import { registerBrunchGraph, type BrunchGraphDeps } from './extensions/graph/index.js'; import { type GraphMentionSource } from './extensions/mentions/index.js'; @@ -52,6 +53,7 @@ export { type ResolvedBrunchAgentState, } from './extensions/runtime/index.js'; export { registerBrunchPrompting } from './extensions/system-prompts/index.js'; +export { registerBrunchContext } from './extensions/context/index.js'; export { chromeStateForWorkspace, projectBrunchChromeFooterLines, @@ -87,11 +89,7 @@ export { type BrunchSpecSessionPickerOptions, } from './extensions/workspace/index.js'; -export { - registerBrunchGraph, - type BrunchGraphDeps, - type GraphSnapshotReaders, -} from './extensions/graph/index.js'; +export { registerBrunchGraph, type BrunchGraphDeps, type GraphReaders } from './extensions/graph/index.js'; export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions { graphMentionSource?: GraphMentionSource; @@ -108,11 +106,17 @@ export function createBrunchPiExtensions( ): ExtensionFactory { return async (pi) => { const graphMentionSource = options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE; + const promptContext = options.promptContext; const extensions: BrunchProductExtensionRegistrar[] = [ (api) => registerBrunchSessionBoundary(api, onSessionBoundary), (api) => registerBrunchChrome(api, chrome), registerBrunchBranchPolicyHandlers, registerBrunchOperationalModePolicy, + registerBrunchContext, + // Prompting registers immediately after operational-mode policy and + // before mention autocomplete when prompt context is provided; its + // position in this list is the registration order, not a splice index. + ...(promptContext ? [(api: ExtensionAPI) => registerBrunchPrompting(api, promptContext)] : []), (api) => registerBrunchMentionAutocomplete(api, graphMentionSource), registerBrunchAlternatives, (api) => @@ -124,9 +128,6 @@ export function createBrunchPiExtensions( (api) => registerBrunchCommands(api, options), ...(options.graph ? [(api: ExtensionAPI) => registerBrunchGraph(api, options.graph!)] : []), ]; - if (options.promptContext) { - extensions.splice(4, 0, (api) => registerBrunchPrompting(api, options.promptContext!)); - } for (const registerExtension of extensions) { await registerExtension(pi); diff --git a/src/.pi/extensions/AUDIT.md b/src/.pi/extensions/AUDIT.md deleted file mode 100644 index 6d872c53d..000000000 --- a/src/.pi/extensions/AUDIT.md +++ /dev/null @@ -1,71 +0,0 @@ -Audited `src/.pi/extensions/` read-only. Family: `matrix`. - -## Responsibility rendering - -```text -legend: - R = runtime hook - T = agent tool - UI = interactive Pi UI - C = config/topology only - . = no direct Pi API - -| extension | kind | owns | Pi/API surface | -| -------------------------------- | ---- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| session-lifecycle.ts | R | session-boundary refresh | pi.on(session_start,before_agent_start,message_start); ctx.sessionManager | -| chrome.ts | R/UI | TUI title/footer chrome | pi.on(session_start,model_select,thinking_level_select,turn_end); pi.getThinkingLevel; ctx.getContextUsage; ctx.ui.setFooter/setTitle | -| command-policy.ts | R/UI | block Pi branch/tree flows | pi.on(session_before_tree,session_before_fork); ctx.ui.notify | -| operational-mode.ts | R/T | read-only tool posture + hard blocks | pi.registerTool(read,grep,find,ls); pi.getAllTools; pi.setActiveTools; pi.on(session_start,before_agent_start,tool_call,user_bash) | -| prompting.ts | R | Brunch prompt injection/tool posture | pi.on(before_agent_start); pi.getAllTools; pi.setActiveTools | -| mention-autocomplete.ts | R/UI | #graph mention prompt + autocomplete | pi.on(before_agent_start,session_start); ctx.ui.addAutocompleteProvider | -| alternatives.ts | T/UI | durable alternatives card transcript | pi.registerMessageRenderer; pi.registerTool(present_alternatives); pi.sendMessage | -| structured-exchange/index.ts | T | present/request exchange tool bundle | pi.registerTool(...) | -| structured-exchange/request-*.ts | T/UI | collect answer/choice/review | defineTool; ctx.hasUI; ctx.ui.editor/select/input | -| structured-exchange/present-*.ts | T | persist displayable offers | defineTool; renderCall/renderResult | -| graph/index.ts | T | commit_graph/read_graph registrar | pi.registerTool(commit_graph,read_graph) | -| graph/command-adapter.ts | . | Pi params -> CommandExecutor adapter | . | -| graph/tool-schemas.ts | . | Pi-facing TypeBox schemas | pi-ai Type/StringEnum only | -| commands.ts | R/UI | /brunch:* commands + shortcut | pi.registerCommand; pi.registerShortcut; ctx.ui.notify | -| workspace-dialog.ts | UI | spec/session picker + switching | ctx.waitForIdle; ctx.ui.custom/notify; ctx.switchSession; ctx.sessionManager.getSessionFile | -| snapshot-cwd.ts | C | future snapshot-tool concept note | . | -| auto-compaction-anchors.json | C | compaction preservation contract | . | -| subagents/config.json | C | future subagent config | . | -| present-candidates.ts | C | named but unregistered stub | . | -``` - -## Pi API caller index - -```text -| Pi API / context API | callers | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| pi.registerTool | operational-mode.ts; alternatives.ts; structured-exchange/index.ts; graph/index.ts | -| defineTool | structured-exchange/present-question.ts; present-options.ts; present-review-set.ts; request-answer.ts; request-choice.ts; request-choices.ts; request-review.ts | -| pi.registerMessageRenderer | alternatives.ts | -| pi.sendMessage | alternatives.ts | -| pi.registerCommand | commands.ts | -| pi.registerShortcut | commands.ts | -| pi.on | session-lifecycle.ts; chrome.ts; command-policy.ts; operational-mode.ts; prompting.ts; mention-autocomplete.ts | -| pi.getAllTools | operational-mode.ts; prompting.ts | -| pi.setActiveTools | operational-mode.ts; prompting.ts | -| pi.getThinkingLevel | chrome.ts | -| ctx.sessionManager | session-lifecycle.ts; chrome.ts; operational-mode.ts; workspace-dialog.ts | -| ctx.getContextUsage | chrome.ts | -| ctx.waitForIdle | workspace-dialog.ts | -| ctx.switchSession | workspace-dialog.ts | -| ctx.ui.setFooter/setTitle | chrome.ts | -| ctx.ui.notify | command-policy.ts; commands.ts; workspace-dialog.ts | -| ctx.ui.custom | workspace-dialog.ts | -| ctx.ui.addAutocompleteProvider | mention-autocomplete.ts | -| ctx.hasUI | structured-exchange/request-answer.ts; request-choice.ts; request-choices.ts; request-review.ts | -| ctx.ui.editor | structured-exchange/request-answer.ts; request-choices.ts | -| ctx.ui.select | structured-exchange/request-choice.ts; request-review.ts | -| ctx.ui.input | structured-exchange/request-choice.ts; request-review.ts | -``` - -## Audit notes - -- `graph/*` keeps the intended boundary: no `db/` imports found under `src/.pi/extensions/`; graph access is injected through `CommandExecutor` / snapshot readers. -- `structured-exchange` is the largest Pi tool surface. `present_*` tools produce durable transcript content; `request_*` tools are the only ones that require interactive UI. -- `commands.ts` owns registration; `workspace-dialog.ts` owns command implementation. That split is clean. -- `snapshot-cwd.ts`, `auto-compaction-anchors.json`, `subagents/config.json`, and `present-candidates.ts` are topology/config/stub surfaces, not active Pi registrations. -- `.DS_Store` is non-project noise in the extension directory. diff --git a/src/.pi/extensions/context/get-cwd.ts b/src/.pi/extensions/context/get-cwd.ts index 8c60e55f1..44003f992 100644 --- a/src/.pi/extensions/context/get-cwd.ts +++ b/src/.pi/extensions/context/get-cwd.ts @@ -1,16 +1,44 @@ -/** @file snapshot-cwd.ts - * - * CONCEPT of this and other snapshot-* extensions: - * - * ...for initial framing or situations we could have the assistant always run a deterministic tool that does a bit of a scan of the workspace. For example to see if there are any existing sessions. Let's assume the tool is going to create a.branch folder when it starts up, but it may not. Maybe the check is: does a.branch folder exist? Are there sessions in the sessions subfolder? - * - * Even should we do a preliminary scan of those sessions or at least evaluate initially how long they are? We could count the lines in those sessions and get a sense of, "Oh there are sessions here with significant content or not." That would already tell you something. - * - * Another thing might be a quick scan of things like README files. Again not for the content but for the length of the files. That could be done deterministically and could be injected as a very quick kind of initial context injection to say, "Okay in the project where I've been launched there is or isn't pre-existing branch work. There is or isn't pre-existing documentation work of some kind." - * - * Initial signals could be just any markdown files gathered out of the space fed to the LLM as a table with a quick scan of how many lines they are or maybe just how many bytes they are. Maybe we don't even count the lines. Something to give a heuristic for either there is substantial documentation or not and otherwise what other kinds of top-level heuristics? Count the number of files that should be a fairly quick run so basically it's like a kick-off heuristic snapshot tool. - * - * So, to run with that idea for the moment, I guess there's a series of tools for snapshotting. Snapshot CWD could be the one that is used for that heuristic, and I suppose it could be invocable at different times, discretionally, or it can be automatically invoked following certain deterministic heuristics like: - * Whether this is the first session in a new specification. In that case, this snapshot will be necessary. Otherwise, other snapshotting tools are going to be Snapshot Graph and Snapshot Nodes at least. The Snapshot Nodes thing is maybe kind of a flexible way to get a range of different snapshots from one single node with only its direct dependencies, or two or more nodes, and/or a variable number of hops to build up a neighborhood - * - */ +import { resolve } from 'node:path'; + +import type { FileEntry } from '@earendil-works/pi-coding-agent'; + +import { + projectWorkspaceCwdContext, + projectWorkspaceOverviewContext, + type WorkspaceContextProjection, +} from '../../../projections/workspace/workspace-context.js'; +import { renderWorkspaceContext } from '../../../renderers/workspace/workspace-context.js'; +import { + inspectWorkspaceCwdInventory, + inspectWorkspaceOverview, +} from '../../../session/workspace-context.js'; + +interface SessionManagerLike { + getEntries(): readonly FileEntry[]; +} + +export async function readWorkspaceContext( + mode: 'cwd_inventory' | 'workspace_overview', + sessionManager?: SessionManagerLike, +): Promise<{ readonly text: string; readonly details: WorkspaceContextProjection }> { + const cwd = resolveWorkspaceCwd(sessionManager); + const details = + mode === 'workspace_overview' + ? projectWorkspaceOverviewContext(await inspectWorkspaceOverview(cwd)) + : projectWorkspaceCwdContext(await inspectWorkspaceCwdInventory(cwd)); + return { + text: renderWorkspaceContext(details), + details, + }; +} + +function resolveWorkspaceCwd(sessionManager?: SessionManagerLike): string { + const header = sessionManager?.getEntries().find(isSessionHeaderEntry); + return typeof header?.cwd === 'string' ? resolve(header.cwd) : process.cwd(); +} + +function isSessionHeaderEntry( + entry: FileEntry, +): entry is FileEntry & { readonly type: 'session'; readonly cwd: string } { + return entry.type === 'session' && typeof (entry as { cwd?: unknown }).cwd === 'string'; +} diff --git a/src/.pi/extensions/context/index.ts b/src/.pi/extensions/context/index.ts new file mode 100644 index 000000000..8a0db62fa --- /dev/null +++ b/src/.pi/extensions/context/index.ts @@ -0,0 +1,128 @@ +import type { ExtensionAPI, FileEntry } from '@earendil-works/pi-coding-agent'; + +import { + projectSessionRuntimeState, + type RuntimeStateProjection, +} from '../../../projections/session/runtime-state.js'; +import { + renderRuntimeFrame, + type SessionRuntimeFrameRenderInput, +} from '../../../renderers/session/runtime-frame.js'; +import { + NonLinearTranscriptError, + type BrunchSessionEnvelope, +} from '../../../session/brunch-session-envelope.js'; +import { isSessionBindingEntry } from '../../../session/session-binding.js'; +import { readWorkspaceContext } from './get-cwd.js'; + +interface SessionManagerLike { + getEntries(): readonly FileEntry[]; +} + +export function registerBrunchContext(pi: ExtensionAPI): void { + pi.registerTool({ + name: 'read_workspace_context', + label: 'Read Workspace Context', + description: + 'Read a deterministic kickoff inventory of the current workspace cwd: .brunch presence, session-file sizes, visible top-level tree, and markdown sizes.', + promptSnippet: 'Read the current workspace cwd kickoff inventory', + promptGuidelines: [ + 'Use read_workspace_context when you need filesystem kickoff context rather than graph or session state.', + 'This is a deterministic workspace inventory: .brunch presence, session-file sizes, visible top-level tree, and markdown sizes.', + 'The tree is gitignore-aware and read-only; ignored paths are excluded from counts and listings.', + ], + parameters: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['cwd_inventory', 'workspace_overview'], + }, + }, + required: ['mode'], + additionalProperties: false, + }, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (params.mode !== 'cwd_inventory' && params.mode !== 'workspace_overview') { + const details = { + status: 'structural_illegal' as const, + diagnostics: [ + { field: 'mode', message: `unsupported workspace context mode: ${String(params.mode)}` }, + ], + }; + return { + content: [ + { type: 'text' as const, text: `STRUCTURAL_ILLEGAL\n- mode: ${details.diagnostics[0]!.message}` }, + ], + details, + }; + } + + const result = await readWorkspaceContext(params.mode, ctx?.sessionManager); + return { + content: [{ type: 'text' as const, text: result.text }], + details: result.details, + }; + }, + }); + + pi.registerTool({ + name: 'read_session_context', + label: 'Read Session Context', + description: + 'Read the selected session runtime frame: binding, current agent posture, mention handles, world watermarks, and lifecycle facts.', + promptSnippet: 'Read the selected session runtime frame and binding', + promptGuidelines: [ + 'Use read_session_context when you need the current selected session frame rather than a graph slice.', + 'This reads the runtime frame only: binding, posture, mention handles, world watermarks, and lifecycle facts.', + 'Do not treat this as the per-turn AUTO choice surface; it reports the durable runtime frame the session is operating under.', + 'Graph-node mentions render as projected handles such as #D12 when available, not raw ids.', + ], + parameters: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const details = projectSessionContext(ctx?.sessionManager); + return { + content: [{ type: 'text' as const, text: renderRuntimeFrame(details) }], + details, + }; + }, + }); +} + +export default registerBrunchContext; + +function projectSessionContext( + sessionManager: SessionManagerLike | undefined, +): RuntimeStateProjection | SessionRuntimeFrameRenderInput { + const entries = sessionManager?.getEntries() ?? []; + const header = entries.find(isSessionHeaderEntry); + if (!header) { + return { status: 'not_ready', reason: 'missing_session_header', sessionId: null }; + } + + const binding = entries.find(isSessionBindingEntry); + if (!binding) { + return { status: 'not_ready', reason: 'missing_binding', sessionId: header.id }; + } + + try { + return projectSessionRuntimeState({ + header, + binding: binding.data, + entries: [...entries], + }); + } catch (error) { + if (error instanceof NonLinearTranscriptError) { + return { status: 'not_ready', reason: 'non_linear', sessionId: header.id }; + } + throw error; + } +} + +function isSessionHeaderEntry(entry: FileEntry): entry is BrunchSessionEnvelope['header'] { + return entry.type === 'session' && typeof entry.id === 'string'; +} diff --git a/src/.pi/extensions/exchanges/index.ts b/src/.pi/extensions/exchanges/index.ts index 79fd2abf5..3250fc484 100644 --- a/src/.pi/extensions/exchanges/index.ts +++ b/src/.pi/extensions/exchanges/index.ts @@ -30,7 +30,7 @@ export { type PresentDetails as StructuredExchangePresentDetails, type PresentToolName, type RequestDetails as StructuredExchangeRequestDetails, - type RequestChoiceDetails as StructuredExchangeToolResultDetails, + type RequestDetails as StructuredExchangeToolResultDetails, type RequestToolName, } from './schemas/index.js'; export { diff --git a/src/.pi/extensions/exchanges/present-options.ts b/src/.pi/extensions/exchanges/present-options.ts index dbc985bec..fe17a468b 100644 --- a/src/.pi/extensions/exchanges/present-options.ts +++ b/src/.pi/extensions/exchanges/present-options.ts @@ -1,7 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { projectPresentOptions } from '../../../projections/structured-exchange/present-options.js'; -import { formatPresentOptions } from '../../../renderers/structured-exchange/present-options.js'; +import { projectPresentOptions } from '../../../projections/exchanges/present-options.js'; +import { formatPresentOptions } from '../../../renderers/exchanges/present-options.js'; import { piSchema } from './pi-schema.js'; import { zPresentOptionsParams, type PresentOptionsParams } from './schemas/index.js'; import { renderMarkdownResult } from './shared/markdown.js'; diff --git a/src/.pi/extensions/exchanges/present-question.ts b/src/.pi/extensions/exchanges/present-question.ts index 4fce2cb2b..3bd7ee864 100644 --- a/src/.pi/extensions/exchanges/present-question.ts +++ b/src/.pi/extensions/exchanges/present-question.ts @@ -1,7 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { projectPresentQuestion } from '../../../projections/structured-exchange/present-question.js'; -import { formatPresentQuestion } from '../../../renderers/structured-exchange/present-question.js'; +import { projectPresentQuestion } from '../../../projections/exchanges/present-question.js'; +import { formatPresentQuestion } from '../../../renderers/exchanges/present-question.js'; import { piSchema } from './pi-schema.js'; import { zPresentQuestionParams, type PresentQuestionParams } from './schemas/index.js'; import { renderMarkdownResult } from './shared/markdown.js'; diff --git a/src/.pi/extensions/exchanges/present-review-set.ts b/src/.pi/extensions/exchanges/present-review-set.ts index 300e8cc21..f7b8c2d5f 100644 --- a/src/.pi/extensions/exchanges/present-review-set.ts +++ b/src/.pi/extensions/exchanges/present-review-set.ts @@ -2,8 +2,8 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; import type { CommandExecutor, StructuralIllegal } from '../../../graph/command-executor.js'; import type { ReviewSetProposalPayload } from '../../../graph/review-set.js'; -import { projectPresentReviewSet } from '../../../projections/structured-exchange/present-review-set.js'; -import { formatPresentReviewSet } from '../../../renderers/structured-exchange/present-review-set.js'; +import { projectPresentReviewSet } from '../../../projections/exchanges/present-review-set.js'; +import { formatPresentReviewSet } from '../../../renderers/exchanges/present-review-set.js'; import { piSchema } from './pi-schema.js'; import { zPresentReviewSetParams, diff --git a/src/.pi/extensions/exchanges/request-answer.ts b/src/.pi/extensions/exchanges/request-answer.ts index 4a1cdbe68..a27d28b9b 100644 --- a/src/.pi/extensions/exchanges/request-answer.ts +++ b/src/.pi/extensions/exchanges/request-answer.ts @@ -1,7 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { projectRequestAnswer } from '../../../projections/structured-exchange/request-answer.js'; -import { formatRequestAnswer } from '../../../renderers/structured-exchange/request-answer.js'; +import { projectRequestAnswer } from '../../../projections/exchanges/request-answer.js'; +import { formatRequestAnswer } from '../../../renderers/exchanges/request-answer.js'; import { piSchema } from './pi-schema.js'; import { zRequestAnswerParams, type RequestAnswerParams } from './schemas/index.js'; import { renderMarkdownResult } from './shared/markdown.js'; diff --git a/src/.pi/extensions/exchanges/request-choice.ts b/src/.pi/extensions/exchanges/request-choice.ts index cd65660b3..376a4dfa4 100644 --- a/src/.pi/extensions/exchanges/request-choice.ts +++ b/src/.pi/extensions/exchanges/request-choice.ts @@ -1,7 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { projectRequestChoice } from '../../../projections/structured-exchange/request-choice.js'; -import { formatRequestChoice } from '../../../renderers/structured-exchange/request-choice.js'; +import { projectRequestChoice } from '../../../projections/exchanges/request-choice.js'; +import { formatRequestChoice } from '../../../renderers/exchanges/request-choice.js'; import { piSchema } from './pi-schema.js'; import { zRequestChoiceParams, diff --git a/src/.pi/extensions/exchanges/request-choices.ts b/src/.pi/extensions/exchanges/request-choices.ts index 85adafd9f..335e49618 100644 --- a/src/.pi/extensions/exchanges/request-choices.ts +++ b/src/.pi/extensions/exchanges/request-choices.ts @@ -1,7 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { projectRequestChoices } from '../../../projections/structured-exchange/request-choices.js'; -import { formatRequestChoices } from '../../../renderers/structured-exchange/request-choices.js'; +import { projectRequestChoices } from '../../../projections/exchanges/request-choices.js'; +import { formatRequestChoices } from '../../../renderers/exchanges/request-choices.js'; import { piSchema } from './pi-schema.js'; import { zRequestChoicesParams, diff --git a/src/.pi/extensions/exchanges/request-review.ts b/src/.pi/extensions/exchanges/request-review.ts index 1f39ce4e8..0bafbb9a0 100644 --- a/src/.pi/extensions/exchanges/request-review.ts +++ b/src/.pi/extensions/exchanges/request-review.ts @@ -1,10 +1,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; -import { - projectRequestReview, - type ReviewDecision, -} from '../../../projections/structured-exchange/request-review.js'; -import { formatRequestReview } from '../../../renderers/structured-exchange/request-review.js'; +import { projectRequestReview, type ReviewDecision } from '../../../projections/exchanges/request-review.js'; +import { formatRequestReview } from '../../../renderers/exchanges/request-review.js'; import { piSchema } from './pi-schema.js'; import { zRequestReviewParams, type RequestReviewParams } from './schemas/index.js'; import { normalizeOptionalText, renderMarkdownResult } from './shared/markdown.js'; diff --git a/src/.pi/extensions/exchanges/schemas/README.md b/src/.pi/extensions/exchanges/schemas/README.md index 240a55c62..dc5350665 100644 --- a/src/.pi/extensions/exchanges/schemas/README.md +++ b/src/.pi/extensions/exchanges/schemas/README.md @@ -39,15 +39,15 @@ The organization is layer-first: shared vocabulary, tool parameter schemas, pres ```pseudo chain active Pi tool / session trigger / RPC editor relay -> parse params or relay payload at the entry boundary - -> projections/structured-exchange/* constructs details + -> projections/exchanges/* constructs details -> relevant details Zod schema parses result - -> renderers/structured-exchange/* renders durable markdown + -> renderers/exchanges/* renders durable markdown ``` - Active `.pi/extensions/exchanges/*.ts` files own Pi registration and UI collection only. - `../pi-schema.ts` is the only Zod JSON Schema to Pi `TSchema` adapter. -- `projections/structured-exchange/*` is the only construction boundary for active present/request `toolResult.details`. -- `renderers/structured-exchange/*` owns durable markdown for active present/request emissions. +- `projections/exchanges/*` is the only construction boundary for active present/request `toolResult.details`. +- `renderers/exchanges/*` owns durable markdown for active present/request emissions. - Session pending exchange recovery projects from canonical present/request details; it does not author a TypeBox semantic schema. - The RPC/editor relay is an intentional current product fallback and must still emit canonical details through projectors. - The proof-era `brunch.structured_exchange.result` details model is retired. diff --git a/src/.pi/extensions/exchanges/shared/editor-fallback.ts b/src/.pi/extensions/exchanges/shared/editor-fallback.ts index 154694d93..3be1d89fa 100644 --- a/src/.pi/extensions/exchanges/shared/editor-fallback.ts +++ b/src/.pi/extensions/exchanges/shared/editor-fallback.ts @@ -1,7 +1,7 @@ -import { projectRequestChoice } from '../../../../projections/structured-exchange/request-choice.js'; -import { projectRequestChoices } from '../../../../projections/structured-exchange/request-choices.js'; -import { formatRequestChoice } from '../../../../renderers/structured-exchange/request-choice.js'; -import { formatRequestChoices } from '../../../../renderers/structured-exchange/request-choices.js'; +import { projectRequestChoice } from '../../../../projections/exchanges/request-choice.js'; +import { projectRequestChoices } from '../../../../projections/exchanges/request-choices.js'; +import { formatRequestChoice } from '../../../../renderers/exchanges/request-choice.js'; +import { formatRequestChoices } from '../../../../renderers/exchanges/request-choices.js'; import type { SelectedChoice } from '../schemas/index.js'; export type StructuredExchangeMode = 'single-select' | 'multi-select'; diff --git a/src/.pi/extensions/graph/command-adapter.ts b/src/.pi/extensions/graph/command-adapter.ts index 7824a858d..6e6c05f56 100644 --- a/src/.pi/extensions/graph/command-adapter.ts +++ b/src/.pi/extensions/graph/command-adapter.ts @@ -6,7 +6,7 @@ * This module translates Pi tool parameters (flat JSON from LLM tool calls) * into CommandExecutor input types and formats CommandExecutor results into * Pi tool result content. It does NOT import from db/ — all graph access - * routes through CommandExecutor and snapshot readers. + * routes through CommandExecutor and graph query readers. */ import type { @@ -19,8 +19,8 @@ import type { Diagnostic, StructuralIllegal, } from '../../../graph/command-executor.js'; +import type { GraphOverview, NeighborhoodResult, RelatedNodesResult } from '../../../graph/queries.js'; import { formatGraphNodeCode, parseGraphNodeCode } from '../../../graph/schema/nodes.js'; -import type { GraphOverview, NeighborhoodResult } from '../../../graph/snapshot.js'; import type { ToolCommitGraphParams } from './tool-schemas.js'; export type ResolveGraphNodeCode = (code: string) => number | undefined; @@ -145,13 +145,13 @@ export function formatStructuralIllegal(result: StructuralIllegal): string { /** * Format a GraphOverview as readable text for the agent. */ -export function formatGraphOverview(overview: GraphOverview): string { +export function formatGraphOverview(overview: GraphOverview, heading = 'Graph overview'): string { if (overview.nodeCount === 0) { - return 'The graph is empty (no nodes or edges).'; + return `${heading}: empty (no nodes or edges).`; } const lines: string[] = [ - `Graph overview (LSN ${overview.lsn}): ${overview.nodeCount} node(s), ${overview.edgeCount} edge(s).`, + `${heading} (LSN ${overview.lsn}): ${overview.nodeCount} node(s), ${overview.edgeCount} edge(s).`, '', ]; const nodesById = new Map(overview.nodes.map((node) => [node.id, node])); @@ -217,3 +217,47 @@ export function formatNeighborhoodResult(result: NeighborhoodResult): string { return lines.join('\n'); } + +export function formatRelatedNodesResult(result: RelatedNodesResult): string { + if (result.status === 'not_found') { + return 'One or more anchor nodes were not found in the selected spec.'; + } + + const nodesById = new Map([ + ...result.anchors.map((node) => [node.id, node] as const), + ...result.relatedNodes.map((node) => [node.id, node] as const), + ]); + const lines = [ + `Related nodes: ${result.relatedNodes.length} node(s), ${result.edges.length} edge(s).`, + `Anchors: ${result.anchors.map((anchor) => `[${formatGraphNodeCode(anchor.kind, anchor.kindOrdinal)}] ${anchor.title}`).join(', ')}`, + ]; + + if (result.relatedNodes.length === 0) { + lines.push('Related: none'); + } else { + lines.push('Related:'); + for (const node of result.relatedNodes) { + lines.push( + ` - [${formatGraphNodeCode(node.kind, node.kindOrdinal)}] ${node.plane}/${node.kind}: "${node.title}"`, + ); + } + } + + if (result.edges.length === 0) { + lines.push('Edges: none'); + } else { + lines.push('Edges:'); + for (const edge of result.edges) { + const source = nodesById.get(edge.sourceId); + const target = nodesById.get(edge.targetId); + const sourceCode = source ? formatGraphNodeCode(source.kind, source.kindOrdinal) : `#${edge.sourceId}`; + const targetCode = target ? formatGraphNodeCode(target.kind, target.kindOrdinal) : `#${edge.targetId}`; + const direction = result.anchors.some((anchor) => anchor.id === edge.sourceId) + ? 'outgoing' + : 'incoming'; + lines.push(` - ${sourceCode} -[${edge.category}/${direction}]-> ${targetCode}`); + } + } + + return lines.join('\n'); +} diff --git a/src/.pi/extensions/graph/index.ts b/src/.pi/extensions/graph/index.ts index 4429e663c..f0825df72 100644 --- a/src/.pi/extensions/graph/index.ts +++ b/src/.pi/extensions/graph/index.ts @@ -6,14 +6,21 @@ * D53-L (commitGraph atomic batch), I26-L (no db/ imports here) * * This module does NOT import from db/. All graph access routes through - * the CommandExecutor and snapshot reader functions passed as explicit + * the CommandExecutor and graph reads passed as explicit * dependencies from the extension shell. */ 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 type { + GraphOverview, + GraphProjection, + NeighborhoodResult, + RelatedDirection, + GraphGapsOptions, + RelatedNodesResult, +} from '../../../graph/queries.js'; import { projectNeighborhood } from '../../../projections/graph/neighborhood.js'; import { formatNeighborhood } from '../../../renderers/graph/neighborhood.js'; import { graphMutationProductUpdates, type ProductUpdatePublisher } from '../../../rpc/product-updates.js'; @@ -21,6 +28,7 @@ import { translateCommitGraph, formatCommitGraphResult, formatGraphOverview, + formatRelatedNodesResult, formatStructuralIllegal, } from './command-adapter.js'; import { CommitGraphParams, ReadGraphParams } from './tool-schemas.js'; @@ -29,10 +37,29 @@ import { CommitGraphParams, ReadGraphParams } from './tool-schemas.js'; // Dependencies injected by the extension shell // --------------------------------------------------------------------------- -/** Pre-bound snapshot readers so the extension never touches db/ directly. */ -export interface GraphSnapshotReaders { - readonly getGraphOverview: () => GraphOverview; - readonly getNodeNeighborhood: (nodeId: number, options?: { hops?: number }) => NeighborhoodResult; +/** Pre-bound graph reads so the extension never touches db/ directly. */ +export interface GraphReaders { + readonly getGraphOverview: (options?: { projection?: GraphProjection }) => GraphOverview; + readonly getGraphSliceByKinds: (options: { + projection?: GraphProjection; + kinds: readonly string[]; + }) => GraphOverview; + readonly getGraphSliceByReadinessBands: (options: { + projection?: GraphProjection; + readinessBands: readonly string[]; + }) => GraphOverview; + readonly getGraphGaps: (options: GraphGapsOptions) => GraphOverview; + readonly getRelatedNodes: (options: { + anchorIds: readonly number[]; + edgeCategory: GraphOverview['edges'][number]['category']; + direction?: RelatedDirection; + hops?: number; + projection?: GraphProjection; + }) => RelatedNodesResult; + readonly getNodeNeighborhood: ( + nodeId: number, + options?: { hops?: number; projection?: GraphProjection }, + ) => NeighborhoodResult; readonly resolveNodeCode: (code: string) => number | undefined; } @@ -46,7 +73,7 @@ export interface GraphSnapshotReaders { export interface BrunchGraphDeps { readonly specId: number; readonly commandExecutor: CommandExecutor; - readonly snapshots: GraphSnapshotReaders; + readonly reads: GraphReaders; readonly productUpdates?: ProductUpdatePublisher; } @@ -55,7 +82,7 @@ export interface BrunchGraphDeps { // --------------------------------------------------------------------------- export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): void { - const { commandExecutor, snapshots } = deps; + const { commandExecutor, reads } = deps; // ── commit_graph ──────────────────────────────────────────────────── pi.registerTool({ @@ -78,7 +105,7 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo async execute(_toolCallId, params) { const specId = deps.specId; - const input = translateCommitGraph(params, specId, snapshots.resolveNodeCode); + const input = translateCommitGraph(params, specId, reads.resolveNodeCode); const result = 'status' in input ? input : commandExecutor.commitGraph(input); const text = formatCommitGraphResult(result); if (result.status === 'success') { @@ -104,6 +131,10 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo promptGuidelines: [ "Use read_graph with mode 'overview' to see all nodes and edges before committing new graph elements.", "Use read_graph with mode 'neighborhood' and a projected nodeCode such as G1 or CON2 to inspect a specific node and its connections.", + "Use read_graph with mode 'list_by_kind' and one or more kinds to inspect a bounded graph slice without drifting into a generic predicate API.", + "Use read_graph with mode 'list_by_band' and readiness bands (grounding, elicitation, commitment) to inspect spec evidence by D64-L band.", + "Use read_graph with mode 'gaps' to find nodes in a bounded base class that lack one edge category in the chosen direction.", + "Set projection to 'graph_truth' when you need superseded nodes; otherwise the default 'active_context' hides superseded nodes and dangling edges.", ], parameters: ReadGraphParams, @@ -112,15 +143,100 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo let details: | GraphOverview | NeighborhoodResult + | RelatedNodesResult | { readonly status: 'structural_illegal'; readonly diagnostics: readonly { readonly field: string; readonly message: string }[]; }; if (params.mode === 'overview') { - const overview = snapshots.getGraphOverview(); + const overview = reads.getGraphOverview( + params.projection != null ? { projection: params.projection } : undefined, + ); text = formatGraphOverview(overview); details = overview; + } else if (params.mode === 'list_by_kind') { + const overview = reads.getGraphSliceByKinds({ + kinds: params.kinds ?? [], + ...(params.projection != null ? { projection: params.projection } : {}), + }); + text = formatGraphOverview(overview, 'Graph slice by kind'); + details = overview; + } else if (params.mode === 'list_by_band') { + const overview = reads.getGraphSliceByReadinessBands({ + readinessBands: params.readinessBands ?? [], + ...(params.projection != null ? { projection: params.projection } : {}), + }); + text = formatGraphOverview(overview, 'Graph slice by readiness band'); + details = overview; + } else if (params.mode === 'gaps') { + const hasBaseFilter = (params.kinds?.length ?? 0) > 0 || (params.readinessBands?.length ?? 0) > 0; + if (!hasBaseFilter) { + details = { + status: 'structural_illegal', + diagnostics: [ + { + field: 'kinds|readinessBands', + message: 'gaps mode requires kinds and/or readinessBands as a base filter', + }, + ], + }; + text = formatStructuralIllegal(details); + } else if (params.absentEdgeCategory == null) { + details = { + status: 'structural_illegal', + diagnostics: [ + { + field: 'absentEdgeCategory', + message: 'absentEdgeCategory is required for gaps mode', + }, + ], + }; + text = formatStructuralIllegal(details); + } else { + const overview = reads.getGraphGaps({ + ...(params.kinds != null ? { kinds: params.kinds } : {}), + ...(params.readinessBands != null ? { readinessBands: params.readinessBands } : {}), + absentEdgeCategory: params.absentEdgeCategory, + ...(params.direction != null ? { direction: params.direction } : {}), + ...(params.projection != null ? { projection: params.projection } : {}), + }); + text = formatGraphOverview(overview, 'Graph gaps'); + details = overview; + } + } else if (params.mode === 'related') { + const anchorCodes = params.anchorCodes ?? []; + const anchorIds = anchorCodes + .map((code) => ({ code, nodeId: reads.resolveNodeCode(code) })) + .filter((candidate) => candidate.nodeId != null); + if (anchorIds.length !== anchorCodes.length) { + details = { + status: 'structural_illegal', + diagnostics: anchorCodes + .filter((code) => reads.resolveNodeCode(code) == null) + .map((code) => ({ + field: 'anchorCodes', + message: `anchor code ${code} does not resolve in the selected spec`, + })), + }; + text = formatStructuralIllegal(details); + } else if (params.edgeCategory == null) { + details = { + status: 'structural_illegal', + diagnostics: [{ field: 'edgeCategory', message: 'edgeCategory is required for related mode' }], + }; + text = formatStructuralIllegal(details); + } else { + const related = reads.getRelatedNodes({ + anchorIds: anchorIds.map((candidate) => candidate.nodeId!), + edgeCategory: params.edgeCategory, + ...(params.direction != null ? { direction: params.direction } : {}), + ...(params.hops != null ? { hops: params.hops } : {}), + ...(params.projection != null ? { projection: params.projection } : {}), + }); + text = formatRelatedNodesResult(related); + details = related; + } } else if (params.nodeCode == null) { details = { status: 'structural_illegal', @@ -128,7 +244,7 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo }; text = formatStructuralIllegal(details); } else { - const nodeId = snapshots.resolveNodeCode(params.nodeCode); + const nodeId = reads.resolveNodeCode(params.nodeCode); if (nodeId === undefined) { details = { status: 'structural_illegal', @@ -141,9 +257,14 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo }; text = formatStructuralIllegal(details); } else { - const neighborhood = snapshots.getNodeNeighborhood( + const neighborhood = reads.getNodeNeighborhood( nodeId, - params.hops != null ? { hops: params.hops } : undefined, + params.hops != null || params.projection != null + ? { + ...(params.hops != null ? { hops: params.hops } : {}), + ...(params.projection != null ? { projection: params.projection } : {}), + } + : undefined, ); text = formatNeighborhood(projectNeighborhood(neighborhood)); details = neighborhood; diff --git a/src/.pi/extensions/graph/tool-schemas.ts b/src/.pi/extensions/graph/tool-schemas.ts index 5a7c019b1..cd1e1e55c 100644 --- a/src/.pi/extensions/graph/tool-schemas.ts +++ b/src/.pi/extensions/graph/tool-schemas.ts @@ -16,6 +16,8 @@ import { INTENT_KINDS, ORACLE_KINDS, PLAN_KINDS, + type GraphProjection, + type RelatedDirection, } from '../../../graph/index.js'; const ALL_KINDS = [...INTENT_KINDS, ...ORACLE_KINDS, ...DESIGN_KINDS, ...PLAN_KINDS] as const; @@ -84,20 +86,83 @@ export const ReadGraphParams = { additionalProperties: false, required: ['mode'], properties: { - mode: { enum: ['overview', 'neighborhood'] }, + mode: { enum: ['overview', 'neighborhood', 'list_by_kind', 'list_by_band', 'related', 'gaps'] }, + projection: { + enum: ['active_context', 'graph_truth'] satisfies readonly GraphProjection[], + description: 'Graph projection to read (default: active_context)', + }, nodeCode: { type: 'string', description: 'Projected code of the anchor node in the selected spec, e.g. G1 or CON2', }, hops: { type: 'number', description: 'Neighborhood traversal depth (default: 1)' }, + kinds: { + type: 'array', + items: { type: 'string' }, + description: 'One or more node kinds for list_by_kind mode; unknown kinds produce an empty slice', + }, + readinessBands: { + type: 'array', + items: { type: 'string' }, + description: + 'One or more readiness bands for list_by_band mode (grounding, elicitation, commitment); unknown bands produce an empty slice', + }, + anchorCodes: { + type: 'array', + items: { type: 'string' }, + description: 'Projected codes of anchor nodes in the selected spec for related mode', + }, + edgeCategory: { + enum: [...EDGE_CATEGORIES], + description: 'Edge category to follow in related mode', + }, + direction: { + enum: ['outgoing', 'incoming', 'both'] satisfies readonly RelatedDirection[], + description: 'Traversal direction for related or gaps mode (default: both)', + }, + absentEdgeCategory: { + enum: [...EDGE_CATEGORIES], + description: 'Edge category whose absence defines a gaps query', + }, }, description: - 'Read graph overview or a selected-spec node neighborhood. For neighborhood mode, nodeCode is required by the typed adapter contract and missing values return STRUCTURAL_ILLEGAL diagnostics.', + 'Read a graph overview, selected-spec node neighborhood, projection-aware flat graph slice, related nodes, or graph gaps. Neighborhood mode requires nodeCode. List modes accept kind or readiness-band filters and return an empty slice for empty or unknown filters. Gaps mode requires a base filter (kinds and/or readinessBands) plus absentEdgeCategory.', } as const; export type ToolCommitNode = Static; export type ToolCommitEdge = Static; export type ToolCommitGraphParams = Static; export type ToolReadGraphParams = - | { readonly mode: 'overview' } - | { readonly mode: 'neighborhood'; readonly nodeCode: string; readonly hops?: number }; + | { readonly mode: 'overview'; readonly projection?: GraphProjection } + | { + readonly mode: 'neighborhood'; + readonly nodeCode: string; + readonly hops?: number; + readonly projection?: GraphProjection; + } + | { + readonly mode: 'list_by_kind'; + readonly kinds: readonly string[]; + readonly projection?: GraphProjection; + } + | { + readonly mode: 'list_by_band'; + readonly readinessBands: readonly string[]; + readonly projection?: GraphProjection; + } + | { + readonly mode: 'related'; + readonly anchorCodes: readonly string[]; + readonly edgeCategory: (typeof EDGE_CATEGORIES)[number]; + readonly direction?: RelatedDirection; + readonly hops?: number; + readonly projection?: GraphProjection; + } + | { + readonly mode: 'gaps'; + readonly kinds?: readonly string[]; + readonly readinessBands?: readonly string[]; + readonly absentEdgeCategory: (typeof EDGE_CATEGORIES)[number]; + readonly direction?: RelatedDirection; + readonly projection?: GraphProjection; + }; diff --git a/src/.pi/extensions/system-prompts/index.ts b/src/.pi/extensions/system-prompts/index.ts index 7dc5c66aa..2d5333fcd 100644 --- a/src/.pi/extensions/system-prompts/index.ts +++ b/src/.pi/extensions/system-prompts/index.ts @@ -5,11 +5,11 @@ import { renderCwdContext, renderGraphContext, type AgentPromptSessionContext, - type AgentPromptSnapshotContext, + type AgentPromptContextBundle, type AgentPromptSpecContext, type AgentPromptWorkspaceContext, } from '../../agents/index.js'; -import type { GraphSnapshotReaders } from '../graph/index.js'; +import type { GraphReaders } from '../graph/index.js'; import { activeToolNamesForBrunchAgentState, projectBrunchAgentState } from '../runtime/index.js'; type BrunchAgentStateEntries = Parameters[0]; @@ -30,8 +30,8 @@ export interface BrunchPromptContext { spec: AgentPromptSpecContext; workspace: AgentPromptWorkspaceContext; session?: AgentPromptSessionContext; - snapshots?: AgentPromptSnapshotContext; - graphSnapshots?: GraphSnapshotReaders; + context?: AgentPromptContextBundle; + graphReads?: GraphReaders; } export type BrunchPromptContextProvider = @@ -60,13 +60,13 @@ export function registerBrunchPrompting(pi: ExtensionAPI, promptContext: BrunchP if (typeof (pi as Partial).setActiveTools === 'function') { pi.setActiveTools(activeTools); } - const snapshots = snapshotsForPromptContext(resolvedPromptContext, state); + const context = contextForPrompt(resolvedPromptContext, state); const { prompt } = composeAgentPrompt({ agentId: state.agentRole, sessionState: state, spec: resolvedPromptContext.spec, workspace: resolvedPromptContext.workspace, - snapshots, + context, activeTools, }); @@ -79,10 +79,10 @@ export function registerBrunchPrompting(pi: ExtensionAPI, promptContext: BrunchP }); } -function snapshotsForPromptContext( +function contextForPrompt( context: BrunchPromptContext, state: ReturnType, -): AgentPromptSnapshotContext { +): AgentPromptContextBundle { const renderedContexts = [ renderCwdContext({ spec: context.spec, @@ -90,15 +90,15 @@ function snapshotsForPromptContext( ...(context.session ? { session: context.session } : {}), }), ]; - if (context.graphSnapshots) { + if (context.graphReads) { renderedContexts.push( - renderGraphContext(context.graphSnapshots.getGraphOverview(), { lens: state.agentLens }), + renderGraphContext(context.graphReads.getGraphOverview(), { lens: state.agentLens }), ); } return { - ...(context.snapshots?.contextHandles ? { contextHandles: context.snapshots.contextHandles } : {}), - renderedContexts: [...(context.snapshots?.renderedContexts ?? []), ...renderedContexts], + ...(context.context?.contextHandles ? { contextHandles: context.context.contextHandles } : {}), + renderedContexts: [...(context.context?.renderedContexts ?? []), ...renderedContexts], }; } diff --git a/src/.pi/skills/methods/read-context.md b/src/.pi/skills/methods/read-context.md new file mode 100644 index 000000000..010136733 --- /dev/null +++ b/src/.pi/skills/methods/read-context.md @@ -0,0 +1,5 @@ +# Method: read-context + +Use pushed context handles first. When detail matters, call the relevant read tool for selected-spec graph or node context. + +Context reads are read-only. Do not treat them as mutation authority or workspace-global truth. diff --git a/src/.pi/skills/methods/read-snapshot.md b/src/.pi/skills/methods/read-snapshot.md deleted file mode 100644 index 113639fed..000000000 --- a/src/.pi/skills/methods/read-snapshot.md +++ /dev/null @@ -1,5 +0,0 @@ -# Method: read-snapshot - -Use pushed context handles first. When detail matters, call the relevant snapshot/read tool for selected-spec graph or node context. - -Snapshots are read-only context. Do not treat a snapshot as mutation authority or workspace-global truth. diff --git a/src/.pi/skills/strategies/README.md b/src/.pi/skills/strategies/README.md index 5c9aef900..212b05cde 100644 --- a/src/.pi/skills/strategies/README.md +++ b/src/.pi/skills/strategies/README.md @@ -10,6 +10,7 @@ the user experiences. | Strategy | Commitment path | Notes | |---------------------------|-----------------|------------------------------------| +| `freestyle` | ordinary-turn capture | user-pinned free chat; AUTO never selects it | | `step-wise-decision-tree` | single-exchange | Q&A one claim at a time | | `step-wise-disambiguate` | single-exchange | contrastive examples | | `propose-graph` | direct commit | concept → user accepts → commitGraph | diff --git a/src/.pi/skills/strategies/freestyle.md b/src/.pi/skills/strategies/freestyle.md new file mode 100644 index 000000000..f7817624b --- /dev/null +++ b/src/.pi/skills/strategies/freestyle.md @@ -0,0 +1,6 @@ +Use `freestyle` only when the user explicitly pins it. + +- Let the user drive with ordinary turns instead of forcing an offer-first structured exchange every turn. +- Keep structured exchange tools available when a typed question, option set, or review would sharpen the next step. +- Grow graph truth only through the ordinary-message capture path or other existing Brunch graph write seams; `freestyle` itself adds no authority. +- Do not treat `freestyle` as permission to skip capture discipline: only directly stated, high-confidence facts should become graph truth. diff --git a/src/README.md b/src/README.md index 6599bf348..5524a42f5 100644 --- a/src/README.md +++ b/src/README.md @@ -19,7 +19,7 @@ src/ │ ├── graph/ Graph domain layer │ CommandExecutor, readers, policy, validators, -│ snapshot bucketing, change-log replay, recon-need substrate +│ query bucketing, change-log replay, recon-need substrate │ ├── session/ Session domain layer │ transcript projection, exchange extraction, @@ -62,9 +62,9 @@ Rules: ## Migration notes -Product entrypoints now live in `app/`, package identity tests live in `workspace/`, reusable workspace snapshot DTOs live in `projections/workspace/`, and reusable print-mode snapshot text lives in `renderers/workspace/`. No compatibility root files remain for the old `src/brunch*`, `src/print-snapshot*`, or `src/package-identity*` paths. +Product entrypoints now live in `app/`, package identity tests live in `workspace/`, reusable workspace state DTOs live in `projections/workspace/`, and reusable print-mode workspace-state text lives in `renderers/workspace/`. No compatibility root files remain for the old root-level Brunch entrypoint, print helper, or package-identity paths. -The old domain-local `src/{graph,session,structured-exchange}/project/` folders now live under `projections/{graph,session,structured-exchange}/`. +The old domain-local `src/{graph,session,structured-exchange}/project/` folders now live under `projections/{graph,session,exchanges}/`. The old domain-local `src/{graph,session,structured-exchange}/format/` folders and `src/render/` now live under `renderers/{graph,session,structured-exchange}/` and `renderers/`. diff --git a/src/app/brunch-tui.test.ts b/src/app/brunch-tui.test.ts index c9e29127b..76994a2d5 100644 --- a/src/app/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -547,6 +547,8 @@ describe('Brunch TUI boot', () => { 'grep', 'find', 'ls', + 'read_workspace_context', + 'read_session_context', 'present_alternatives', 'present_question', 'present_options', diff --git a/src/app/brunch-tui.ts b/src/app/brunch-tui.ts index 9106b5748..9e0e73c40 100644 --- a/src/app/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -145,10 +145,51 @@ export function createBrunchAgentSessionRuntimeFactory({ return currentWorkspace.spec.id; }, commandExecutor: graph.commandExecutor, - snapshots: { - getGraphOverview: () => graph.forSpec(currentWorkspace.spec.id).getGraphOverview(), - getNodeNeighborhood: (nodeId: number, options?: { hops?: number }) => - graph.forSpec(currentWorkspace.spec.id).getNodeNeighborhood(nodeId, options), + reads: { + getGraphOverview: (options?: { projection?: 'active_context' | 'graph_truth' }) => + graph.forSpec(currentWorkspace.spec.id).getGraphOverview(options), + getGraphSliceByKinds: (options: { + projection?: 'active_context' | 'graph_truth'; + kinds: readonly string[]; + }) => graph.forSpec(currentWorkspace.spec.id).getGraphSliceByKinds(options), + getGraphSliceByReadinessBands: (options: { + projection?: 'active_context' | 'graph_truth'; + readinessBands: readonly string[]; + }) => graph.forSpec(currentWorkspace.spec.id).getGraphSliceByReadinessBands(options), + getGraphGaps: (options: { + projection?: 'active_context' | 'graph_truth'; + kinds?: readonly string[]; + readinessBands?: readonly string[]; + absentEdgeCategory: + | 'dependency' + | 'proof' + | 'support' + | 'realization' + | 'boundary' + | 'composition' + | 'association' + | 'supersession'; + direction?: 'outgoing' | 'incoming' | 'both'; + }) => graph.forSpec(currentWorkspace.spec.id).getGraphGaps(options), + getRelatedNodes: (options: { + anchorIds: readonly number[]; + edgeCategory: + | 'dependency' + | 'proof' + | 'support' + | 'realization' + | 'boundary' + | 'composition' + | 'association' + | 'supersession'; + direction?: 'outgoing' | 'incoming' | 'both'; + hops?: number; + projection?: 'active_context' | 'graph_truth'; + }) => graph.forSpec(currentWorkspace.spec.id).getRelatedNodes(options), + getNodeNeighborhood: ( + nodeId: number, + options?: { hops?: number; projection?: 'active_context' | 'graph_truth' }, + ) => graph.forSpec(currentWorkspace.spec.id).getNodeNeighborhood(nodeId, options), resolveNodeCode: (code: string) => graph.forSpec(currentWorkspace.spec.id).resolveNodeCode(code), }, ...(productUpdates ? { productUpdates } : {}), @@ -180,7 +221,7 @@ export function createBrunchAgentSessionRuntimeFactory({ id: currentWorkspace.session.id, ...(currentWorkspace.session.name ? { label: currentWorkspace.session.name } : {}), }, - graphSnapshots: graphDeps.snapshots, + graphReads: graphDeps.reads, }; }, }), diff --git a/src/app/brunch.test.ts b/src/app/brunch.test.ts index cdb97e0ac..fda76e4bf 100644 --- a/src/app/brunch.test.ts +++ b/src/app/brunch.test.ts @@ -106,7 +106,7 @@ describe('Brunch CLI dispatch', () => { expect(launchedTui).toBe(true); }); - it('routes --mode print through the coordinator snapshot and exits', async () => { + it('routes --mode print through the coordinator state and exits', async () => { let output = ''; const code = await runBrunchCli({ @@ -183,14 +183,9 @@ describe('Brunch CLI dispatch', () => { jsonrpc: '2.0', method: 'brunch.updated', params: { - topics: [ - 'workspace.snapshot', - 'session.pendingExchange', - 'session.exchanges', - 'session.runtimeState', - ], + topics: ['workspace.state', 'session.pendingExchange', 'session.exchanges', 'session.runtimeState'], updates: [ - { topic: 'workspace.snapshot', specId: workspace.spec.id, sessionId: workspace.session.id }, + { topic: 'workspace.state', specId: workspace.spec.id, sessionId: workspace.session.id }, { topic: 'session.pendingExchange', specId: workspace.spec.id, @@ -226,7 +221,7 @@ describe('Brunch CLI dispatch', () => { argv: ['--mode=rpc'], cwd: '/tmp/brunch-project', coordinator: coordinator(), - stdin: rpcRequest('workspace.snapshot'), + stdin: rpcRequest('workspace.state'), stdout, }); @@ -262,7 +257,7 @@ describe('Brunch CLI dispatch', () => { } } }); - it('exposes matching print and RPC workspace snapshots from a real coordinator store', async () => { + it('exposes matching print and RPC workspace states from a real coordinator store', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-parity-')); await createWorkspaceSessionCoordinator({ cwd }).createSetupSession({ specTitle: 'Parity spec', @@ -281,17 +276,17 @@ describe('Brunch CLI dispatch', () => { await runBrunchCli({ argv: ['--mode=rpc'], cwd, - stdin: rpcRequest('workspace.snapshot'), + stdin: rpcRequest('workspace.state'), stdout: rpcOutput, }); - const rpcSnapshot = JSON.parse(rpcChunks.join('')).result; + const rpcState = JSON.parse(rpcChunks.join('')).result; expect(printOutput).toContain('status: ready'); - expect(printOutput).toContain(`cwd: ${rpcSnapshot.cwd}`); + expect(printOutput).toContain(`cwd: ${rpcState.cwd}`); expect(printOutput).toContain('spec: Parity spec'); - expect(printOutput).toContain(`phase: ${rpcSnapshot.chrome.phase}`); - expect(printOutput).toContain(`chatMode: ${rpcSnapshot.chrome.chatMode}`); - expect(rpcSnapshot).toMatchObject({ + expect(printOutput).toContain(`phase: ${rpcState.chrome.phase}`); + expect(printOutput).toContain(`chatMode: ${rpcState.chrome.chatMode}`); + expect(rpcState).toMatchObject({ status: 'ready', cwd, spec: { title: 'Parity spec' }, diff --git a/src/app/brunch.ts b/src/app/brunch.ts index 8d64f69c9..01a474794 100644 --- a/src/app/brunch.ts +++ b/src/app/brunch.ts @@ -2,8 +2,8 @@ import process from 'node:process'; import type { Readable, Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; -import { workspaceSnapshotFromState } from '../projections/workspace/workspace-snapshot.js'; -import { renderWorkspaceSnapshot } from '../renderers/workspace/workspace-snapshot.js'; +import { projectWorkspaceState } from '../projections/workspace/workspace-state.js'; +import { renderWorkspaceState } from '../renderers/workspace/workspace-state.js'; import { createRpcHandlers, runJsonRpcLineServer } from '../rpc/handlers.js'; import { createProductUpdatePublisher } from '../rpc/product-updates.js'; import { startWebHost } from '../rpc/web-host.js'; @@ -36,8 +36,8 @@ export async function runBrunchCli(options: BrunchCliOptions = {}): Promise domain mapping + render-preview.ts + deterministic seeded-fixture render-preview helpers for scripts/tests + workspace-store.ts openWorkspaceGraphRuntime(cwd) openWorkspaceCommandExecutor(cwd) @@ -150,12 +157,12 @@ CommandExecutor │ session.submitExchangeResponse capture wiring │ └─► .pi/agents/contexts future context orchestration - prompt context snapshots + prompt context reads and render inputs ``` ## Fractal split points -Keep `command-executor.ts` and `snapshot.ts` as public entry points. The first +Keep `command-executor.ts` and `queries.ts` as public entry points. The first real split is now `command-executor/commit-graph-batch.ts`: private planner code for the commitGraph seam, imported only by the public `command-executor.ts` entrypoint. Future splits should follow the same pattern: split by semantic @@ -172,7 +179,7 @@ graph/command-executor/ supersession-cycle detection over existing ids + temporary batch keys created-node result formatter -graph/snapshot/ +graph/queries/ row-mappers.ts overview.ts neighborhood.ts diff --git a/src/graph/capture/structured-response.test.ts b/src/graph/capture/structured-response.test.ts index a64ee5900..376df7363 100644 --- a/src/graph/capture/structured-response.test.ts +++ b/src/graph/capture/structured-response.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { CommandExecutor } from '../command-executor.js'; import type { CommitGraphInput, CommitGraphResult } from '../command-executor/commit-graph-types.js'; -import { captureStructuredResponseFacts } from './structured-response.js'; +import { captureExplicitTextFacts, captureStructuredResponseFacts } from './structured-response.js'; class RecordingExecutor { readonly calls: CommitGraphInput[] = []; @@ -93,6 +93,55 @@ describe('captureStructuredResponseFacts', () => { ]); }); + it('keeps every labeled line of the same kind with distinct refs', () => { + const executor = new RecordingExecutor({ + status: 'success', + lsn: 3, + createdNodes: { + goal: { id: 21, code: 'G1' }, + 'goal-2': { id: 22, code: 'G2' }, + }, + edges: [], + }); + + const outcome = captureStructuredResponseFacts({ + specId: 42, + exchangeId: 'grounding-text-3', + answer: { + text: ['Goal: Coordinate specs across teams.', 'Goal: Keep the graph legible to designers.'].join( + '\n', + ), + }, + commandExecutor: executor as unknown as CommandExecutor, + }); + + expect(outcome).toEqual({ + status: 'captured', + lsn: 3, + nodeCount: 2, + createdNodes: { + goal: { id: 21, code: 'G1' }, + 'goal-2': { id: 22, code: 'G2' }, + }, + }); + expect(executor.calls[0]!.nodes).toEqual([ + { + ref: 'goal', + plane: 'intent', + kind: 'goal', + title: 'Coordinate specs across teams.', + source: 'structured_exchange_response:grounding-text-3', + }, + { + ref: 'goal-2', + plane: 'intent', + kind: 'goal', + title: 'Keep the graph legible to designers.', + source: 'structured_exchange_response:grounding-text-3', + }, + ]); + }); + it('returns no_capture for ambiguous or implication-only prose without invoking CommandExecutor', () => { const executor = new RecordingExecutor({ status: 'success', lsn: 1, createdNodes: {}, edges: [] }); @@ -138,3 +187,48 @@ describe('captureStructuredResponseFacts', () => { expect(executor.calls).toEqual([]); }); }); + +describe('captureExplicitTextFacts', () => { + it('reuses the same labeled-text capture core with a caller-provided source prefix', () => { + const executor = new RecordingExecutor({ + status: 'success', + lsn: 9, + createdNodes: { + goal: { id: 31, code: 'G1' }, + }, + edges: [], + }); + + const outcome = captureExplicitTextFacts({ + specId: 42, + text: 'Goal: Capture ordinary user text too.', + source: 'session_message:message-1', + commandExecutor: executor as unknown as CommandExecutor, + }); + + expect(outcome).toEqual({ + status: 'captured', + lsn: 9, + nodeCount: 1, + createdNodes: { + goal: { id: 31, code: 'G1' }, + }, + }); + expect(executor.calls).toEqual([ + { + specId: 42, + basis: 'explicit', + nodes: [ + { + ref: 'goal', + plane: 'intent', + kind: 'goal', + title: 'Capture ordinary user text too.', + source: 'session_message:message-1', + }, + ], + edges: [], + }, + ]); + }); +}); diff --git a/src/graph/capture/structured-response.ts b/src/graph/capture/structured-response.ts index fbb464b73..52fd74b8b 100644 --- a/src/graph/capture/structured-response.ts +++ b/src/graph/capture/structured-response.ts @@ -6,7 +6,6 @@ import type { } from '../command-executor/commit-graph-types.js'; import type { NodePlane } from '../schema/nodes.js'; -const CAPTURE_SOURCE_PREFIX = 'structured_exchange_response:'; const LABELED_FACTS: Record = { goal: { kind: 'goal', ref: 'goal' }, context: { kind: 'context', ref: 'context' }, @@ -39,15 +38,15 @@ export interface StructuredResponseCaptureInput { readonly commandExecutor: CommandExecutor; } -export function captureStructuredResponseFacts( - input: StructuredResponseCaptureInput, -): StructuredResponseCaptureOutcome { - const text = textAnswer(input.answer); - if (text === undefined) { - return { status: 'no_capture', reason: 'Only text structured exchange answers are capture candidates.' }; - } +export interface ExplicitTextCaptureInput { + readonly specId: number; + readonly text: string; + readonly source: string; + readonly commandExecutor: CommandExecutor; +} - const nodes = extractLabeledIntentNodes(text, input.exchangeId); +export function captureExplicitTextFacts(input: ExplicitTextCaptureInput): StructuredResponseCaptureOutcome { + const nodes = extractLabeledIntentNodes(input.text, input.source); if (nodes.length === 0) { return { status: 'no_capture', @@ -72,6 +71,22 @@ export function captureStructuredResponseFacts( }; } +export function captureStructuredResponseFacts( + input: StructuredResponseCaptureInput, +): StructuredResponseCaptureOutcome { + const text = textAnswer(input.answer); + if (text === undefined) { + return { status: 'no_capture', reason: 'Only text structured exchange answers are capture candidates.' }; + } + + return captureExplicitTextFacts({ + specId: input.specId, + text, + source: `structured_exchange_response:${input.exchangeId}`, + commandExecutor: input.commandExecutor, + }); +} + function textAnswer(answer: unknown): string | undefined { if (typeof answer !== 'object' || answer === null) return undefined; const value = (answer as { readonly text?: unknown }).text; @@ -80,9 +95,9 @@ function textAnswer(answer: unknown): string | undefined { type CapturedNode = CommitGraphInput['nodes'][number]; -function extractLabeledIntentNodes(text: string, exchangeId: string): CapturedNode[] { +function extractLabeledIntentNodes(text: string, source: string): CapturedNode[] { const captured: CapturedNode[] = []; - const seenRefs = new Set(); + const refCounts = new Map(); for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim().replace(/^[-*]\s+/, ''); @@ -92,14 +107,21 @@ function extractLabeledIntentNodes(text: string, exchangeId: string): CapturedNo const label = match[1]!.toLowerCase(); const title = match[2]!.trim(); const fact = LABELED_FACTS[label]; - if (!fact || title.length === 0 || seenRefs.has(fact.ref)) continue; - seenRefs.add(fact.ref); + if (!fact || title.length === 0) continue; + + // Each labeled line is a distinct fact. Repeated same-kind labels get a + // suffixed ref (goal, goal-2, …) so none are silently dropped and every + // node maps to a distinct createdNodes entry. + const ordinal = (refCounts.get(fact.ref) ?? 0) + 1; + refCounts.set(fact.ref, ordinal); + const ref = ordinal === 1 ? fact.ref : `${fact.ref}-${ordinal}`; + captured.push({ - ref: fact.ref, + ref, plane: INTENT_PLANE, kind: fact.kind, title, - source: `${CAPTURE_SOURCE_PREFIX}${exchangeId}`, + source, }); } diff --git a/src/graph/export-fixtures.ts b/src/graph/export-fixtures.ts index 26e0ee640..f077b715a 100644 --- a/src/graph/export-fixtures.ts +++ b/src/graph/export-fixtures.ts @@ -13,8 +13,8 @@ import { eq } from 'drizzle-orm'; import { createDb, type BrunchDb } from '../db/connection.js'; import * as schema from '../db/schema.js'; +import { getGraphOverview, type GraphProjection } from './queries.js'; import type { SeedFixture, SeedFixtureEdge, SeedFixtureNode } from './seed-fixtures.js'; -import { getGraphOverview, type GraphProjection } from './snapshot.js'; export interface ExportSeedFixtureInput { readonly specId: number; diff --git a/src/graph/index.ts b/src/graph/index.ts index a788fd309..1d0948a43 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -57,16 +57,31 @@ export { type ReconNeedTrigger, } from './policy/category-policy.js'; -export { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './snapshot.js'; +export { + getGraphOverview, + getGraphGaps, + getGraphSliceByKinds, + getGraphSliceByReadinessBands, + getRelatedNodes, + getNodeNeighborhood, + getOpenReconciliationNeeds, +} from './queries.js'; export type { GraphOverview, GraphOverviewOptions, + GraphGapsOptions, GraphProjection, + GraphSliceByKindsOptions, + GraphSliceByReadinessBandsOptions, NeighborhoodOptions, NeighborhoodNotFound, NeighborhoodResult, NeighborhoodSuccess, -} from './snapshot.js'; + RelatedDirection, + RelatedNodesOptions, + RelatedNodesResult, + RelatedNodesSuccess, +} from './queries.js'; export { CommandExecutor } from './command-executor.js'; export { openWorkspaceCommandExecutor, openWorkspaceGraphRuntime } from './workspace-store.js'; diff --git a/src/graph/policy/category-policy.ts b/src/graph/policy/category-policy.ts index f983f8e58..cf8764894 100644 --- a/src/graph/policy/category-policy.ts +++ b/src/graph/policy/category-policy.ts @@ -18,7 +18,7 @@ * suggesting criteria for the target * node ("requirement with no `proof` * incoming → suggest criterion"). - * - `projectionEffect` — non-default effect on snapshot / + * - `projectionEffect` — non-default effect on active-context / * neighborhood builders. `"none"` means * the edge is rendered ordinarily. * diff --git a/src/graph/snapshot.test.ts b/src/graph/queries.test.ts similarity index 54% rename from src/graph/snapshot.test.ts rename to src/graph/queries.test.ts index dde0d2b8b..c87101109 100644 --- a/src/graph/snapshot.test.ts +++ b/src/graph/queries.test.ts @@ -1,8 +1,8 @@ /** - * Graph snapshot reader tests — acceptance criteria for I35-L. + * Graph read helper tests — acceptance criteria for I35-L. * * SPEC: D52-L (graph/ reads db/), I35-L (cursory + neighborhood) - * Scope card: Graph snapshot readers at cursory and neighborhood detail levels + * Scope card: Graph reads at cursory and neighborhood detail levels * * All graph state is seeded via CommandExecutor (no direct db writes). */ @@ -12,8 +12,16 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; import { graphClock, specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; +import { + getGraphGaps, + getGraphOverview, + getGraphSliceByKinds, + getGraphSliceByReadinessBands, + getNodeNeighborhood, + getRelatedNodes, + getOpenReconciliationNeeds, +} from './queries.js'; import { NODE_KIND_METADATA, parseGraphNodeCode } from './schema/nodes.js'; -import { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './snapshot.js'; function createTestDb(): BrunchDb { return createDb(':memory:'); @@ -169,6 +177,198 @@ describe('getGraphOverview', () => { }); }); +describe('graph slice readers', () => { + 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; + db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); + }); + + it('lists nodes by kind, projection-aware', () => { + const oldRequirement = executor.createNode({ + specId, + plane: 'intent', + kind: 'requirement', + title: 'R_v0', + }); + expect(oldRequirement.status).toBe('success'); + if (oldRequirement.status !== 'success') throw new Error('unreachable'); + + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R_v1' }, + { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, + { ref: 'g1', plane: 'intent', kind: 'goal', title: 'G1' }, + ], + edges: [ + { category: 'supersession', source: 'r1', target: { existing: oldRequirement.nodeId } }, + { category: 'dependency', source: 'r1', target: 'a1' }, + { category: 'support', source: 'g1', target: 'r1', stance: 'for' }, + ], + }); + expect(batch.status).toBe('success'); + + const activeSlice = getGraphSliceByKinds(db, specId, { + kinds: ['requirement', 'assumption'], + projection: 'active_context', + }); + expect(activeSlice.nodes.map((node) => node.title).sort()).toEqual(['A1', 'R_v1']); + expect(activeSlice.edges).toHaveLength(1); + expect(activeSlice.edges[0]!.category).toBe('dependency'); + + const truthSlice = getGraphSliceByKinds(db, specId, { + kinds: ['requirement'], + projection: 'graph_truth', + }); + expect(truthSlice.nodes.map((node) => node.title).sort()).toEqual(['R_v0', 'R_v1']); + expect(truthSlice.edges.map((edge) => edge.category)).toEqual(['supersession']); + }); + + it('lists nodes by readiness band, projection-aware', () => { + const oldRequirement = executor.createNode({ + specId, + plane: 'intent', + kind: 'requirement', + title: 'Legacy requirement', + }); + expect(oldRequirement.status).toBe('success'); + if (oldRequirement.status !== 'success') throw new Error('unreachable'); + + const batch = executor.commitGraph({ + specId, + nodes: [ + { + ref: 't1', + plane: 'intent', + kind: 'term', + title: 'Term node', + detail: { definition: 'Shared vocabulary entry' }, + }, + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Current requirement' }, + { + ref: 'd1', + plane: 'intent', + kind: 'decision', + title: 'Decision node', + detail: { chosen_option: 'A', rejected: ['B'], rationale: 'Because' }, + }, + ], + edges: [{ category: 'supersession', source: 'r1', target: { existing: oldRequirement.nodeId } }], + }); + expect(batch.status).toBe('success'); + + const activeSlice = getGraphSliceByReadinessBands(db, specId, { + readinessBands: ['grounding', 'elicitation'], + projection: 'active_context', + }); + expect(activeSlice.nodes.map((node) => node.title).sort()).toEqual(['Current requirement', 'Term node']); + + const truthSlice = getGraphSliceByReadinessBands(db, specId, { + readinessBands: ['commitment'], + projection: 'graph_truth', + }); + expect(truthSlice.nodes.map((node) => node.title).sort()).toEqual([ + 'Current requirement', + 'Decision node', + 'Legacy requirement', + ]); + }); + + it('returns an empty slice for empty or unknown kind/band filters', () => { + const kindSlice = getGraphSliceByKinds(db, specId, { kinds: ['not_a_kind'] }); + expect(kindSlice).toMatchObject({ nodes: [], edges: [], nodeCount: 0, edgeCount: 0, lsn: 0 }); + + const bandSlice = getGraphSliceByReadinessBands(db, specId, { readinessBands: ['not_a_band'] }); + expect(bandSlice).toMatchObject({ nodes: [], edges: [], nodeCount: 0, edgeCount: 0, lsn: 0 }); + }); + + it('finds graph gaps with projection-aware edge absence', () => { + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'thesis-gap', plane: 'intent', kind: 'thesis', title: 'Unproven thesis' }, + { ref: 'thesis-supported', plane: 'intent', kind: 'thesis', title: 'Supported thesis' }, + { + ref: 'term-gap', + plane: 'intent', + kind: 'term', + title: 'Unproved term', + detail: { definition: 'Gap' }, + }, + { + ref: 'term-target', + plane: 'intent', + kind: 'term', + title: 'Supported term', + detail: { definition: 'Covered' }, + }, + { ref: 'evidence-live', plane: 'oracle', kind: 'evidence', title: 'Active evidence' }, + { ref: 'evidence-old', plane: 'oracle', kind: 'evidence', title: 'Superseded evidence' }, + { ref: 'evidence-new', plane: 'oracle', kind: 'evidence', title: 'Replacement evidence' }, + ], + edges: [ + { category: 'proof', source: 'evidence-live', target: 'thesis-supported', stance: 'for' }, + { category: 'proof', source: 'evidence-old', target: 'term-target', stance: 'for' }, + { category: 'supersession', source: 'evidence-new', target: 'evidence-old' }, + ], + }); + expect(batch.status).toBe('success'); + + const thesisGaps = getGraphGaps(db, specId, { + kinds: ['thesis'], + absentEdgeCategory: 'proof', + direction: 'incoming', + projection: 'active_context', + }); + expect(thesisGaps.nodes.map((node) => node.title)).toEqual(['Unproven thesis']); + + const outgoingEvidenceGaps = getGraphGaps(db, specId, { + kinds: ['evidence'], + absentEdgeCategory: 'proof', + direction: 'outgoing', + projection: 'active_context', + }); + expect(outgoingEvidenceGaps.nodes.map((node) => node.title)).toEqual(['Replacement evidence']); + + const activeTermGaps = getGraphGaps(db, specId, { + readinessBands: ['grounding'], + absentEdgeCategory: 'proof', + direction: 'incoming', + projection: 'active_context', + }); + expect(activeTermGaps.nodes.map((node) => node.title).sort()).toEqual([ + 'Supported term', + 'Unproved term', + 'Unproven thesis', + ]); + + const truthTermGaps = getGraphGaps(db, specId, { + kinds: ['term'], + absentEdgeCategory: 'proof', + direction: 'incoming', + projection: 'graph_truth', + }); + expect(truthTermGaps.nodes.map((node) => node.title)).toEqual(['Unproved term']); + }); + + it('returns an empty slice for gaps when the base filter is unknown', () => { + const gaps = getGraphGaps(db, specId, { + kinds: ['not_a_kind'], + absentEdgeCategory: 'proof', + }); + expect(gaps).toMatchObject({ nodes: [], edges: [], nodeCount: 0, edgeCount: 0, lsn: 0 }); + }); +}); + describe('getNodeNeighborhood', () => { let db: BrunchDb; let executor: CommandExecutor; @@ -327,6 +527,143 @@ describe('getNodeNeighborhood', () => { }); }); +describe('getRelatedNodes', () => { + 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; + db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); + }); + + it('finds related nodes by category, direction, and bounded hops', () => { + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Anchor requirement' }, + { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'Direct assumption' }, + { ref: 'c1', plane: 'intent', kind: 'constraint', title: 'Two-hop constraint' }, + ], + edges: [ + { category: 'dependency', source: 'r1', target: 'a1' }, + { category: 'dependency', source: 'a1', target: 'c1' }, + ], + }); + expect(batch.status).toBe('success'); + if (batch.status !== 'success') throw new Error('unreachable'); + + const outgoing = getRelatedNodes(db, specId, { + anchorIds: [batch.createdNodes['r1']!.id], + edgeCategory: 'dependency', + direction: 'outgoing', + hops: 1, + }); + expect(outgoing.status).toBe('success'); + if (outgoing.status !== 'success') throw new Error('unreachable'); + expect(outgoing.relatedNodes.map((node) => node.title)).toEqual(['Direct assumption']); + + const twoHop = getRelatedNodes(db, specId, { + anchorIds: [batch.createdNodes['r1']!.id], + edgeCategory: 'dependency', + direction: 'outgoing', + hops: 2, + }); + expect(twoHop.status).toBe('success'); + if (twoHop.status !== 'success') throw new Error('unreachable'); + expect(twoHop.relatedNodes.map((node) => node.title).sort()).toEqual([ + 'Direct assumption', + 'Two-hop constraint', + ]); + expect(twoHop.edges).toHaveLength(2); + + const incoming = getRelatedNodes(db, specId, { + anchorIds: [batch.createdNodes['c1']!.id], + edgeCategory: 'dependency', + direction: 'incoming', + hops: 1, + }); + expect(incoming.status).toBe('success'); + if (incoming.status !== 'success') throw new Error('unreachable'); + expect(incoming.relatedNodes.map((node) => node.title)).toEqual(['Direct assumption']); + }); + + it('omits superseded related nodes in active_context but includes them in graph_truth', () => { + const legacy = executor.createNode({ + specId, + plane: 'intent', + kind: 'requirement', + title: 'Legacy requirement', + }); + expect(legacy.status).toBe('success'); + if (legacy.status !== 'success') throw new Error('unreachable'); + + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'g1', plane: 'intent', kind: 'goal', title: 'Anchor goal' }, + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Current requirement' }, + ], + edges: [ + { category: 'support', source: 'g1', target: 'r1', stance: 'for' }, + { category: 'support', source: 'g1', target: { existing: legacy.nodeId }, stance: 'for' }, + { category: 'supersession', source: 'r1', target: { existing: legacy.nodeId } }, + ], + }); + expect(batch.status).toBe('success'); + if (batch.status !== 'success') throw new Error('unreachable'); + + const active = getRelatedNodes(db, specId, { + anchorIds: [batch.createdNodes['g1']!.id], + edgeCategory: 'support', + direction: 'outgoing', + projection: 'active_context', + }); + expect(active.status).toBe('success'); + if (active.status !== 'success') throw new Error('unreachable'); + expect(active.relatedNodes.map((node) => node.title)).toEqual(['Current requirement']); + + const truth = getRelatedNodes(db, specId, { + anchorIds: [batch.createdNodes['g1']!.id], + edgeCategory: 'support', + direction: 'outgoing', + projection: 'graph_truth', + }); + expect(truth.status).toBe('success'); + if (truth.status !== 'success') throw new Error('unreachable'); + expect(truth.relatedNodes.map((node) => node.title).sort()).toEqual([ + 'Current requirement', + 'Legacy requirement', + ]); + }); + + it('returns not_found when any anchor does not belong to the selected spec', () => { + const otherSpec = executor.createSpec({ name: 'Other Spec', slug: 'other-spec' }); + expect(otherSpec.status).toBe('success'); + if (otherSpec.status !== 'success') throw new Error('unreachable'); + const otherNode = executor.createNode({ + specId: otherSpec.specId, + plane: 'intent', + kind: 'goal', + title: 'Foreign anchor', + }); + expect(otherNode.status).toBe('success'); + if (otherNode.status !== 'success') throw new Error('unreachable'); + + expect( + getRelatedNodes(db, specId, { + anchorIds: [otherNode.nodeId], + edgeCategory: 'dependency', + }), + ).toEqual({ status: 'not_found' }); + }); +}); + describe('getOpenReconciliationNeeds', () => { let db: BrunchDb; let executor: CommandExecutor; diff --git a/src/graph/queries.ts b/src/graph/queries.ts new file mode 100644 index 000000000..dffb7c90b --- /dev/null +++ b/src/graph/queries.ts @@ -0,0 +1,632 @@ +/** + * Graph read helpers — cursory overview and node neighborhood. + * + * SPEC: I35-L (two detail levels), D52-L (graph/ reads db/) + * + * These are pure read functions over BrunchDb. They return typed + * domain objects (GraphNode, GraphEdge), not raw Drizzle rows. + * Superseded predecessors (nodes that are targets of a `supersession` + * edge) are excluded per CATEGORY_POLICY projectionEffect. + */ + +import { and, eq, inArray, or } from 'drizzle-orm'; + +import type { BrunchDb } from '../db/connection.js'; +import * as schema from '../db/schema.js'; +import type { Lsn } from './atoms.js'; +import type { GraphEdge } from './schema/edges.js'; +import { + NODE_KIND_METADATA, + parseGraphNodeCode, + type GraphNode, + type NodeDetail, + type NodeKind, + type ReadinessBand, +} from './schema/nodes.js'; +import type { ReconciliationNeed, ReconciliationNeedTarget } from './schema/reconciliation-need.js'; + +// --------------------------------------------------------------------------- +// Return types +// --------------------------------------------------------------------------- + +/** Full-graph cursory overview. */ +export type GraphProjection = 'active_context' | 'graph_truth'; + +/** Full-graph cursory overview. */ +export interface GraphOverview { + readonly nodes: readonly GraphNode[]; + readonly edges: readonly GraphEdge[]; + readonly nodeCount: number; + readonly edgeCount: number; + readonly lsn: Lsn; +} + +export interface GraphOverviewOptions { + readonly projection?: GraphProjection; +} + +export interface GraphSliceByKindsOptions extends GraphOverviewOptions { + readonly kinds: readonly string[]; +} + +export interface GraphSliceByReadinessBandsOptions extends GraphOverviewOptions { + readonly readinessBands: readonly string[]; +} + +export interface GraphGapsOptions extends GraphOverviewOptions { + readonly kinds?: readonly string[]; + readonly readinessBands?: readonly string[]; + readonly absentEdgeCategory: GraphEdge['category']; + readonly direction?: RelatedDirection; +} + +export type RelatedDirection = 'outgoing' | 'incoming' | 'both'; + +export interface RelatedNodesOptions extends GraphOverviewOptions { + readonly anchorIds: readonly number[]; + readonly edgeCategory: GraphEdge['category']; + readonly direction?: RelatedDirection; + readonly hops?: number; +} + +/** Successful neighborhood result. */ +export interface NeighborhoodSuccess { + readonly status: 'success'; + readonly anchor: GraphNode; + readonly neighbors: readonly GraphNode[]; + readonly edges: readonly GraphEdge[]; +} + +/** Node not found. */ +export interface NeighborhoodNotFound { + readonly status: 'not_found'; +} + +export type NeighborhoodResult = NeighborhoodSuccess | NeighborhoodNotFound; + +export interface RelatedNodesSuccess { + readonly status: 'success'; + readonly anchors: readonly GraphNode[]; + readonly relatedNodes: readonly GraphNode[]; + readonly edges: readonly GraphEdge[]; +} + +export type RelatedNodesResult = RelatedNodesSuccess | NeighborhoodNotFound; + +export interface NeighborhoodOptions { + /** Number of hops from the anchor node. Defaults to 1. */ + readonly hops?: number; + readonly projection?: GraphProjection; +} + +const DEFAULT_RELATED_HOPS = 1; +const MAX_RELATED_HOPS = 3; + +// --------------------------------------------------------------------------- +// Row → domain mapping +// --------------------------------------------------------------------------- + +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'], + kindOrdinal: row.kind_ordinal, + title: row.title, + ...(row.body != null ? { body: row.body } : {}), + basis: row.basis as GraphNode['basis'], + ...(row.source != null ? { source: row.source } : {}), + ...(row.detail != null ? { detail: JSON.parse(row.detail) as NodeDetail } : {}), + createdAtLsn: row.created_at_lsn, + updatedAtLsn: row.updated_at_lsn, + }; +} + +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, + basis: row.basis as GraphEdge['basis'], + createdAtLsn: row.created_at_lsn, + updatedAtLsn: row.updated_at_lsn, + }; + return row.stance != null + ? row.rationale != null + ? { + ...base, + stance: row.stance as NonNullable, + rationale: row.rationale, + } + : { ...base, stance: row.stance as NonNullable } + : row.rationale != null + ? { ...base, rationale: row.rationale } + : base; +} + +// --------------------------------------------------------------------------- +// Supersession helpers +// --------------------------------------------------------------------------- + +/** 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(and(eq(schema.edges.category, 'supersession'), eq(schema.edges.spec_id, specId))) + .all(); + return new Set(rows.map((r) => r.targetId)); +} + +function getProjectionState(db: BrunchDb, specId: number, projection: GraphProjection) { + const supersededIds = projection === 'active_context' ? getSupersededIds(db, specId) : new Set(); + const allNodeRows = db.select().from(schema.nodes).where(eq(schema.nodes.spec_id, specId)).all(); + const visibleNodeRows = allNodeRows.filter((row) => !supersededIds.has(row.id)); + const visibleNodeIds = new Set(visibleNodeRows.map((row) => row.id)); + const allEdgeRows = db.select().from(schema.edges).where(eq(schema.edges.spec_id, specId)).all(); + + return { supersededIds, allNodeRows, visibleNodeRows, visibleNodeIds, allEdgeRows }; +} + +function getProjectedEdges( + edgeRows: readonly (typeof schema.edges.$inferSelect)[], + projection: GraphProjection, + visibleNodeIds: ReadonlySet, +): GraphEdge[] { + return edgeRows + .filter( + (edge) => + projection === 'graph_truth' || + (visibleNodeIds.has(edge.source_id) && visibleNodeIds.has(edge.target_id)), + ) + .map(rowToEdge); +} + +function isNodeKind(value: string): value is NodeKind { + return value in NODE_KIND_METADATA; +} + +function getKindsForReadinessBands(readinessBands: readonly string[]): Set { + const requestedBands = new Set( + readinessBands.filter( + (band): band is ReadinessBand => + band === 'grounding' || band === 'elicitation' || band === 'commitment', + ), + ); + + if (requestedBands.size === 0) { + return new Set(); + } + + return new Set( + Object.entries(NODE_KIND_METADATA) + .filter(([, metadata]) => metadata.readinessBands.some((band) => requestedBands.has(band))) + .map(([kind]) => kind as NodeKind), + ); +} + +function getMatchingNodeIds( + projectionState: ReturnType, + options: { + readonly kinds?: readonly string[]; + readonly readinessBands?: readonly string[]; + }, +): Set { + const requestedKinds = new Set((options.kinds ?? []).filter(isNodeKind)); + const bandKinds = getKindsForReadinessBands(options.readinessBands ?? []); + const matchingKinds = new Set([...requestedKinds, ...bandKinds]); + + if (matchingKinds.size === 0) { + return new Set(); + } + + return new Set( + projectionState.visibleNodeRows + .filter((row) => matchingKinds.has(row.kind as NodeKind)) + .map((row) => row.id), + ); +} + +function buildGraphSlice( + projectionState: ReturnType, + projection: GraphProjection, + matchingNodeIds: ReadonlySet, +): GraphOverview { + const visibleNodeRows = projectionState.visibleNodeRows.filter((row) => matchingNodeIds.has(row.id)); + const visibleNodeIds = new Set(visibleNodeRows.map((row) => row.id)); + const edgeRows = projectionState.allEdgeRows.filter( + (edge) => visibleNodeIds.has(edge.source_id) && visibleNodeIds.has(edge.target_id), + ); + + return { + nodes: visibleNodeRows.map(rowToNode), + edges: getProjectedEdges(edgeRows, projection, visibleNodeIds), + nodeCount: visibleNodeRows.length, + edgeCount: edgeRows.length, + lsn: 0, + }; +} + +function withClock(db: BrunchDb, specId: number, overview: Omit): GraphOverview { + const clockRow = db.select().from(schema.graphClock).where(eq(schema.graphClock.spec_id, specId)).get(); + return { + ...overview, + lsn: clockRow?.lsn ?? 0, + }; +} + +// --------------------------------------------------------------------------- +export function resolveGraphNodeCode(db: BrunchDb, specId: number, code: string): number | undefined { + const parsed = parseGraphNodeCode(code); + if (!parsed) return undefined; + return db + .select({ id: schema.nodes.id }) + .from(schema.nodes) + .where( + and( + eq(schema.nodes.spec_id, specId), + eq(schema.nodes.kind, parsed.kind), + eq(schema.nodes.kind_ordinal, parsed.kindOrdinal), + ), + ) + .get()?.id; +} + +// getGraphOverview +// --------------------------------------------------------------------------- + +/** + * Cursory selected-spec graph overview (D61-L). + * + * 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, + specId: number, + options: GraphOverviewOptions = {}, +): GraphOverview { + const projection = options.projection ?? 'active_context'; + const projectionState = getProjectionState(db, specId, projection); + const nodes = projectionState.visibleNodeRows.map(rowToNode); + const edges = getProjectedEdges(projectionState.allEdgeRows, projection, projectionState.visibleNodeIds); + + return withClock(db, specId, { + nodes, + edges, + nodeCount: nodes.length, + edgeCount: edges.length, + }); +} + +export function getGraphSliceByKinds( + db: BrunchDb, + specId: number, + options: GraphSliceByKindsOptions, +): GraphOverview { + const projection = options.projection ?? 'active_context'; + const projectionState = getProjectionState(db, specId, projection); + const matchingNodeIds = getMatchingNodeIds(projectionState, { kinds: options.kinds }); + + if (matchingNodeIds.size === 0) { + return withClock(db, specId, { nodes: [], edges: [], nodeCount: 0, edgeCount: 0 }); + } + + return withClock(db, specId, buildGraphSlice(projectionState, projection, matchingNodeIds)); +} + +export function getGraphSliceByReadinessBands( + db: BrunchDb, + specId: number, + options: GraphSliceByReadinessBandsOptions, +): GraphOverview { + const projection = options.projection ?? 'active_context'; + const projectionState = getProjectionState(db, specId, projection); + const matchingNodeIds = getMatchingNodeIds(projectionState, { + readinessBands: options.readinessBands, + }); + + if (matchingNodeIds.size === 0) { + return withClock(db, specId, { nodes: [], edges: [], nodeCount: 0, edgeCount: 0 }); + } + + return withClock(db, specId, buildGraphSlice(projectionState, projection, matchingNodeIds)); +} + +export function getGraphGaps(db: BrunchDb, specId: number, options: GraphGapsOptions): GraphOverview { + const projection = options.projection ?? 'active_context'; + const direction = options.direction ?? 'both'; + const projectionState = getProjectionState(db, specId, projection); + const baseNodeIds = getMatchingNodeIds(projectionState, { + ...(options.kinds != null ? { kinds: options.kinds } : {}), + ...(options.readinessBands != null ? { readinessBands: options.readinessBands } : {}), + }); + + if (baseNodeIds.size === 0) { + return withClock(db, specId, { nodes: [], edges: [], nodeCount: 0, edgeCount: 0 }); + } + + const nodesWithVisibleEdges = new Set(); + for (const edge of projectionState.allEdgeRows) { + const sourceVisible = projectionState.visibleNodeIds.has(edge.source_id); + const targetVisible = projectionState.visibleNodeIds.has(edge.target_id); + if (!sourceVisible || !targetVisible) { + continue; + } + if (edge.category !== options.absentEdgeCategory) { + continue; + } + if (direction === 'outgoing' || direction === 'both') { + if (baseNodeIds.has(edge.source_id)) { + nodesWithVisibleEdges.add(edge.source_id); + } + } + if (direction === 'incoming' || direction === 'both') { + if (baseNodeIds.has(edge.target_id)) { + nodesWithVisibleEdges.add(edge.target_id); + } + } + } + + const gapNodeIds = new Set([...baseNodeIds].filter((nodeId) => !nodesWithVisibleEdges.has(nodeId))); + return withClock(db, specId, buildGraphSlice(projectionState, projection, gapNodeIds)); +} + +export function getRelatedNodes( + db: BrunchDb, + specId: number, + options: RelatedNodesOptions, +): RelatedNodesResult { + const projection = options.projection ?? 'active_context'; + const direction = options.direction ?? 'both'; + const hops = Math.max( + DEFAULT_RELATED_HOPS, + Math.min(options.hops ?? DEFAULT_RELATED_HOPS, MAX_RELATED_HOPS), + ); + + const anchorRows = db + .select() + .from(schema.nodes) + .where(and(eq(schema.nodes.spec_id, specId), inArray(schema.nodes.id, [...options.anchorIds]))) + .all(); + + if (anchorRows.length !== options.anchorIds.length) { + return { status: 'not_found' }; + } + + const projectionState = getProjectionState(db, specId, projection); + const hiddenNodeIds = new Set( + projectionState.allNodeRows + .filter((row) => !projectionState.visibleNodeIds.has(row.id)) + .map((row) => row.id), + ); + const anchorIds = new Set(options.anchorIds); + const visited = new Set(options.anchorIds); + let frontier = new Set(options.anchorIds); + const collectedRelatedIds = new Set(); + const collectedEdgeIds = new Set(); + + for (let hop = 0; hop < hops; hop++) { + if (frontier.size === 0) break; + + const frontierIds = [...frontier]; + const edgeRows = db + .select() + .from(schema.edges) + .where( + and( + eq(schema.edges.spec_id, specId), + eq(schema.edges.category, options.edgeCategory), + direction === 'outgoing' + ? inArray(schema.edges.source_id, frontierIds) + : direction === 'incoming' + ? inArray(schema.edges.target_id, frontierIds) + : or( + inArray(schema.edges.source_id, frontierIds), + inArray(schema.edges.target_id, frontierIds), + ), + ), + ) + .all(); + + const nextFrontier = new Set(); + for (const edge of edgeRows) { + const candidateIds = + direction === 'outgoing' + ? [edge.target_id] + : direction === 'incoming' + ? [edge.source_id] + : frontier.has(edge.source_id) + ? [edge.target_id] + : frontier.has(edge.target_id) + ? [edge.source_id] + : [edge.source_id, edge.target_id]; + + for (const candidateId of candidateIds) { + if (candidateId === edge.source_id && !frontier.has(edge.target_id) && direction === 'both') continue; + if (hiddenNodeIds.has(candidateId) && !anchorIds.has(candidateId)) continue; + + collectedEdgeIds.add(edge.id); + if (!visited.has(candidateId)) { + visited.add(candidateId); + if (!anchorIds.has(candidateId)) { + collectedRelatedIds.add(candidateId); + } + nextFrontier.add(candidateId); + } + } + } + frontier = nextFrontier; + } + + const visibleIds = new Set([...anchorIds, ...collectedRelatedIds]); + const nodesById = new Map( + db + .select() + .from(schema.nodes) + .where(and(eq(schema.nodes.spec_id, specId), inArray(schema.nodes.id, [...visibleIds]))) + .all() + .map((row) => [row.id, rowToNode(row)] as const), + ); + + const edges = db + .select() + .from(schema.edges) + .where(and(eq(schema.edges.spec_id, specId), inArray(schema.edges.id, [...collectedEdgeIds]))) + .all() + .filter((edge) => visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id)) + .map(rowToEdge); + + return { + status: 'success', + anchors: options.anchorIds.map((anchorId) => nodesById.get(anchorId)!).filter(Boolean), + relatedNodes: [...collectedRelatedIds].map((nodeId) => nodesById.get(nodeId)!).filter(Boolean), + edges, + }; +} + +// --------------------------------------------------------------------------- +// getNodeNeighborhood +// --------------------------------------------------------------------------- + +/** + * Neighborhood read around a given node, scoped to a single spec (D61-L). + * + * 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; + const projection = options?.projection ?? 'active_context'; + + // 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 = projection === 'active_context' ? getSupersededIds(db, specId) : new Set(); + const anchor = rowToNode(anchorRow); + + // 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(); + + for (let hop = 0; hop < hops; hop++) { + if (frontier.size === 0) break; + + // Find all edges touching frontier nodes (within this spec) + const frontierArr = [...frontier]; + const edgeRows = db + .select() + .from(schema.edges) + .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(); + for (const edge of edgeRows) { + collectedEdgeIds.add(edge.id); + for (const peerId of [edge.source_id, edge.target_id]) { + if (!visited.has(peerId)) { + // Exclude superseded predecessors (unless it's the anchor) + if (supersededIds.has(peerId) && peerId !== nodeId) continue; + visited.add(peerId); + nextFrontier.add(peerId); + } + } + } + frontier = nextFrontier; + } + + // Fetch neighbor nodes (exclude anchor) — restrict to same spec defensively + const neighborIds = [...visited].filter((id) => id !== nodeId); + const neighborNodes: GraphNode[] = []; + const visibleIds = new Set([nodeId, ...neighborIds]); + if (neighborIds.length > 0) { + 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)); + } + + // Fetch collected edges + const edgeIdArr = [...collectedEdgeIds]; + const edgeNodes: GraphEdge[] = []; + if (edgeIdArr.length > 0) { + const rows = db.select().from(schema.edges).where(inArray(schema.edges.id, edgeIdArr)).all(); + edgeNodes.push( + ...rows + .filter( + (row) => + projection === 'graph_truth' || (visibleIds.has(row.source_id) && visibleIds.has(row.target_id)), + ) + .map(rowToEdge), + ); + } + + return { + status: 'success', + anchor, + neighbors: neighborNodes, + edges: edgeNodes, + }; +} + +// --------------------------------------------------------------------------- +// getOpenReconciliationNeeds +// --------------------------------------------------------------------------- + +function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): ReconciliationNeed { + const target: ReconciliationNeedTarget = + row.target_kind === 'edge' + ? { kind: 'edge', edgeId: row.target_edge_id! } + : { kind: 'node_pair', aId: row.target_a_id!, bId: row.target_b_id! }; + + return { + id: String(row.id), + specId: row.spec_id, + kind: row.kind as ReconciliationNeed['kind'], + target, + ...(row.reason != null ? { rationale: row.reason } : {}), + createdAtLsn: row.created_at_lsn, + ...(row.resolved_at_lsn != null ? { resolvedAtLsn: row.resolved_at_lsn } : {}), + }; +} + +/** + * Return all open (unresolved) reconciliation needs for a single spec. + */ +export function getOpenReconciliationNeeds(db: BrunchDb, specId: number): ReconciliationNeed[] { + const rows = db + .select() + .from(schema.reconciliationNeed) + .where(and(eq(schema.reconciliationNeed.status, 'open'), eq(schema.reconciliationNeed.spec_id, specId))) + .all(); + return rows.map(rowToReconNeed); +} diff --git a/src/graph/render-preview.ts b/src/graph/render-preview.ts new file mode 100644 index 000000000..eb3f11873 --- /dev/null +++ b/src/graph/render-preview.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createDb } from '../db/connection.js'; +import { projectNeighborhood } from '../projections/graph/neighborhood.js'; +import { formatNeighborhood } from '../renderers/graph/neighborhood.js'; +import { CommandExecutor } from './command-executor.js'; +import { getNodeNeighborhood, resolveGraphNodeCode } from './queries.js'; +import { seedFixture, type SeedFixture } from './seed-fixtures.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SEEDS_ROOT = resolve(HERE, '../../.fixtures/seeds'); + +export interface NeighborhoodPreviewOptions { + readonly set: string; + readonly fixture: string; + readonly anchorCode: string; + readonly hops?: number; +} + +export function renderNeighborhoodPreview(options: NeighborhoodPreviewOptions): string { + const fixture = loadFixture(options.set, options.fixture); + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const seeded = seedFixture(executor, fixture); + const anchorId = resolveGraphNodeCode(db, seeded.specId, options.anchorCode); + + if (!anchorId) { + throw new Error( + `renderNeighborhoodPreview: anchor code "${options.anchorCode}" not found in ${options.set}/${options.fixture}`, + ); + } + + const neighborhood = getNodeNeighborhood(db, seeded.specId, anchorId, { hops: options.hops ?? 1 }); + return formatNeighborhood(projectNeighborhood(neighborhood)); +} + +function loadFixture(set: string, fixture: string): SeedFixture { + const fixturePath = resolve(SEEDS_ROOT, set, `${fixture}.json`); + return JSON.parse(readFileSync(fixturePath, 'utf8')) as SeedFixture; +} diff --git a/src/graph/review-set.test.ts b/src/graph/review-set.test.ts index cc9ab290d..aeb8e2610 100644 --- a/src/graph/review-set.test.ts +++ b/src/graph/review-set.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; import { CommandExecutor } from './command-executor.js'; +import { getGraphOverview } from './queries.js'; import { translateReviewSetPayloadToCommitGraph, type ReviewSetProposalPayload } from './review-set.js'; -import { getGraphOverview } from './snapshot.js'; function seedSpec(db: BrunchDb): number { const result = new CommandExecutor(db).createSpec({ name: 'Test Spec', slug: 'test' }); diff --git a/src/graph/seed-fixtures.test.ts b/src/graph/seed-fixtures.test.ts index 2ab69dc3e..725898250 100644 --- a/src/graph/seed-fixtures.test.ts +++ b/src/graph/seed-fixtures.test.ts @@ -12,8 +12,9 @@ import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { createDb, type BrunchDb } from '../db/connection.js'; -import { changeLog, edges, graphClock, nodes, specs } from '../db/schema.js'; +import { EDGE_CATEGORIES, changeLog, edges, graphClock, nodes, specs } from '../db/schema.js'; import { CommandExecutor } from './command-executor.js'; +import { NODE_KIND_METADATA, type ReadinessBand } from './schema/nodes.js'; import { seedFixture, type SeedFixture } from './seed-fixtures.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -85,6 +86,51 @@ describe('seedFixture', () => { expect(nodeRows.every((row) => row.plane === 'intent' && row.basis === 'explicit')).toBe(true); }); + it('loads the kind-band spread fixture with every node kind and all readiness bands represented', () => { + const db: BrunchDb = createDb(':memory:'); + const executor = new CommandExecutor(db); + const fixture = loadFixture('coverage-matrix', 'kind-band-spread'); + + const result = seedFixture(executor, fixture); + const nodeRows = db.select().from(nodes).where(eq(nodes.spec_id, result.specId)).all(); + + expect(new Set(nodeRows.map((row) => row.kind))).toEqual(new Set(Object.keys(NODE_KIND_METADATA))); + expect(new Set(readinessBandsFor(nodeRows.map((row) => row.kind)))).toEqual( + new Set(['grounding', 'elicitation', 'commitment']), + ); + }); + + it('loads the edge-spread fixture with every edge category and a thesis absence case', () => { + const db: BrunchDb = createDb(':memory:'); + const executor = new CommandExecutor(db); + const fixture = loadFixture('category-directions', 'edge-spread'); + + const result = seedFixture(executor, fixture); + const specEdges = db.select().from(edges).where(eq(edges.spec_id, result.specId)).all(); + const specNodes = db.select().from(nodes).where(eq(nodes.spec_id, result.specId)).all(); + const thesisId = specNodes.find((row) => row.title === 'Unproven thesis exemplar')?.id; + + expect(new Set(specEdges.map((row) => row.category))).toEqual(new Set(EDGE_CATEGORIES)); + expect(thesisId).toBeDefined(); + expect(specEdges.some((row) => row.category === 'proof' && row.target_id === thesisId)).toBe(false); + }); + + it('loads the workspace-spread fixtures into one DB with distinct slugs and readiness grades', () => { + const db: BrunchDb = createDb(':memory:'); + const executor = new CommandExecutor(db); + const alpha = seedFixture(executor, loadFixture('alpha-grounding', 'workspace-spread')); + const beta = seedFixture(executor, loadFixture('beta-commitments', 'workspace-spread')); + + const specRows = db.select({ slug: specs.slug, readinessGrade: specs.readiness_grade }).from(specs).all(); + + expect(specRows).toEqual([ + { slug: 'alpha-grounding', readinessGrade: 'grounding_onboarding' }, + { slug: 'beta-commitments', readinessGrade: 'commitments_ready' }, + ]); + expect(graphClockLsn(db, alpha.specId)).toBe(2); + expect(graphClockLsn(db, beta.specId)).toBe(2); + }); + it('keeps seeded spec LSNs coherent independent of seed order', () => { const db: BrunchDb = createDb(':memory:'); const executor = new CommandExecutor(db); @@ -118,3 +164,7 @@ describe('seedFixture', () => { expect(() => seedFixture(executor, fixture)).toThrow(/only "explicit" basis/); }); }); + +function readinessBandsFor(kinds: string[]): ReadinessBand[] { + return kinds.flatMap((kind) => NODE_KIND_METADATA[kind as keyof typeof NODE_KIND_METADATA].readinessBands); +} diff --git a/src/graph/snapshot.ts b/src/graph/snapshot.ts deleted file mode 100644 index 4b3736b8e..000000000 --- a/src/graph/snapshot.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Graph snapshot readers — cursory overview and node neighborhood. - * - * SPEC: I35-L (two detail levels), D52-L (graph/ reads db/) - * - * These are pure read functions over BrunchDb. They return typed - * domain objects (GraphNode, GraphEdge), not raw Drizzle rows. - * Superseded predecessors (nodes that are targets of a `supersession` - * edge) are excluded per CATEGORY_POLICY projectionEffect. - */ - -import { and, eq, or, inArray } from 'drizzle-orm'; - -import type { BrunchDb } from '../db/connection.js'; -import * as schema from '../db/schema.js'; -import type { Lsn } from './atoms.js'; -import type { GraphEdge } from './schema/edges.js'; -import { parseGraphNodeCode, type GraphNode, type NodeDetail } from './schema/nodes.js'; -import type { ReconciliationNeed, ReconciliationNeedTarget } from './schema/reconciliation-need.js'; - -// --------------------------------------------------------------------------- -// Return types -// --------------------------------------------------------------------------- - -/** Full-graph cursory overview. */ -export type GraphProjection = 'active_context' | 'graph_truth'; - -/** Full-graph cursory overview. */ -export interface GraphOverview { - readonly nodes: readonly GraphNode[]; - readonly edges: readonly GraphEdge[]; - readonly nodeCount: number; - readonly edgeCount: number; - readonly lsn: Lsn; -} - -export interface GraphOverviewOptions { - readonly projection?: GraphProjection; -} - -/** Successful neighborhood result. */ -export interface NeighborhoodSuccess { - readonly status: 'success'; - readonly anchor: GraphNode; - readonly neighbors: readonly GraphNode[]; - readonly edges: readonly GraphEdge[]; -} - -/** Node not found. */ -export interface NeighborhoodNotFound { - readonly status: 'not_found'; -} - -export type NeighborhoodResult = NeighborhoodSuccess | NeighborhoodNotFound; - -export interface NeighborhoodOptions { - /** Number of hops from the anchor node. Defaults to 1. */ - readonly hops?: number; -} - -// --------------------------------------------------------------------------- -// Row → domain mapping -// --------------------------------------------------------------------------- - -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'], - kindOrdinal: row.kind_ordinal, - title: row.title, - ...(row.body != null ? { body: row.body } : {}), - basis: row.basis as GraphNode['basis'], - ...(row.source != null ? { source: row.source } : {}), - ...(row.detail != null ? { detail: JSON.parse(row.detail) as NodeDetail } : {}), - createdAtLsn: row.created_at_lsn, - updatedAtLsn: row.updated_at_lsn, - }; -} - -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, - basis: row.basis as GraphEdge['basis'], - createdAtLsn: row.created_at_lsn, - updatedAtLsn: row.updated_at_lsn, - }; - return row.stance != null - ? row.rationale != null - ? { - ...base, - stance: row.stance as NonNullable, - rationale: row.rationale, - } - : { ...base, stance: row.stance as NonNullable } - : row.rationale != null - ? { ...base, rationale: row.rationale } - : base; -} - -// --------------------------------------------------------------------------- -// Supersession helpers -// --------------------------------------------------------------------------- - -/** 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(and(eq(schema.edges.category, 'supersession'), eq(schema.edges.spec_id, specId))) - .all(); - return new Set(rows.map((r) => r.targetId)); -} - -// --------------------------------------------------------------------------- -export function resolveGraphNodeCode(db: BrunchDb, specId: number, code: string): number | undefined { - const parsed = parseGraphNodeCode(code); - if (!parsed) return undefined; - return db - .select({ id: schema.nodes.id }) - .from(schema.nodes) - .where( - and( - eq(schema.nodes.spec_id, specId), - eq(schema.nodes.kind, parsed.kind), - eq(schema.nodes.kind_ordinal, parsed.kindOrdinal), - ), - ) - .get()?.id; -} - -// getGraphOverview -// --------------------------------------------------------------------------- - -/** - * Cursory selected-spec graph overview (D61-L). - * - * 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, - specId: number, - options: GraphOverviewOptions = {}, -): GraphOverview { - const projection = options.projection ?? 'active_context'; - const supersededIds = projection === 'active_context' ? getSupersededIds(db, specId) : new Set(); - - const allNodeRows = db.select().from(schema.nodes).where(eq(schema.nodes.spec_id, specId)).all(); - const visibleNodeRows = allNodeRows.filter((r) => !supersededIds.has(r.id)); - const visibleNodeIds = new Set(visibleNodeRows.map((row) => row.id)); - const allEdgeRows = db.select().from(schema.edges).where(eq(schema.edges.spec_id, specId)).all(); - - const nodes = visibleNodeRows.map(rowToNode); - - const edges = allEdgeRows - .filter( - (edge) => - projection === 'graph_truth' || - (visibleNodeIds.has(edge.source_id) && visibleNodeIds.has(edge.target_id)), - ) - .map(rowToEdge); - - const clockRow = db.select().from(schema.graphClock).where(eq(schema.graphClock.spec_id, specId)).get(); - const lsn = clockRow?.lsn ?? 0; - - return { - nodes, - edges, - nodeCount: nodes.length, - edgeCount: edges.length, - lsn, - }; -} - -// --------------------------------------------------------------------------- -// getNodeNeighborhood -// --------------------------------------------------------------------------- - -/** - * Neighborhood snapshot around a given node, scoped to a single spec (D61-L). - * - * 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 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, specId); - const anchor = rowToNode(anchorRow); - - // 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(); - - for (let hop = 0; hop < hops; hop++) { - if (frontier.size === 0) break; - - // Find all edges touching frontier nodes (within this spec) - const frontierArr = [...frontier]; - const edgeRows = db - .select() - .from(schema.edges) - .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(); - for (const edge of edgeRows) { - collectedEdgeIds.add(edge.id); - for (const peerId of [edge.source_id, edge.target_id]) { - if (!visited.has(peerId)) { - // Exclude superseded predecessors (unless it's the anchor) - if (supersededIds.has(peerId) && peerId !== nodeId) continue; - visited.add(peerId); - nextFrontier.add(peerId); - } - } - } - frontier = nextFrontier; - } - - // Fetch neighbor nodes (exclude anchor) — restrict to same spec defensively - const neighborIds = [...visited].filter((id) => id !== nodeId); - const neighborNodes: GraphNode[] = []; - const visibleIds = new Set([nodeId, ...neighborIds]); - if (neighborIds.length > 0) { - 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)); - } - - // Fetch collected edges - const edgeIdArr = [...collectedEdgeIds]; - const edgeNodes: GraphEdge[] = []; - if (edgeIdArr.length > 0) { - const rows = db.select().from(schema.edges).where(inArray(schema.edges.id, edgeIdArr)).all(); - edgeNodes.push( - ...rows.filter((row) => visibleIds.has(row.source_id) && visibleIds.has(row.target_id)).map(rowToEdge), - ); - } - - return { - status: 'success', - anchor, - neighbors: neighborNodes, - edges: edgeNodes, - }; -} - -// --------------------------------------------------------------------------- -// getOpenReconciliationNeeds -// --------------------------------------------------------------------------- - -function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): ReconciliationNeed { - const target: ReconciliationNeedTarget = - row.target_kind === 'edge' - ? { kind: 'edge', edgeId: row.target_edge_id! } - : { kind: 'node_pair', aId: row.target_a_id!, bId: row.target_b_id! }; - - return { - id: String(row.id), - specId: row.spec_id, - kind: row.kind as ReconciliationNeed['kind'], - target, - ...(row.reason != null ? { rationale: row.reason } : {}), - createdAtLsn: row.created_at_lsn, - ...(row.resolved_at_lsn != null ? { resolvedAtLsn: row.resolved_at_lsn } : {}), - }; -} - -/** - * Return all open (unresolved) reconciliation needs for a single spec. - */ -export function getOpenReconciliationNeeds(db: BrunchDb, specId: number): ReconciliationNeed[] { - const rows = db - .select() - .from(schema.reconciliationNeed) - .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 index 58b0e5108..0bf938d11 100644 --- a/src/graph/spec-ownership.test.ts +++ b/src/graph/spec-ownership.test.ts @@ -13,7 +13,7 @@ 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'; +import { getGraphOverview, getNodeNeighborhood, getOpenReconciliationNeeds } from './queries.js'; function freshDbWithTwoSpecs(): { db: BrunchDb; diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index cf90d40c9..58905b51a 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -3,26 +3,48 @@ import { join } from 'node:path'; import { createDb } from '../db/connection.js'; import { CommandExecutor } from './command-executor.js'; -import { getGraphOverview, getNodeNeighborhood, resolveGraphNodeCode } from './snapshot.js'; -import type { GraphOverview, NeighborhoodOptions, NeighborhoodResult } from './snapshot.js'; +import { + getGraphGaps, + getGraphOverview, + getGraphSliceByKinds, + getGraphSliceByReadinessBands, + getRelatedNodes, + getNodeNeighborhood, + resolveGraphNodeCode, +} from './queries.js'; +import type { + GraphOverview, + GraphOverviewOptions, + GraphGapsOptions, + GraphSliceByKindsOptions, + GraphSliceByReadinessBandsOptions, + NeighborhoodOptions, + NeighborhoodResult, + RelatedNodesOptions, + RelatedNodesResult, +} from './queries.js'; const BRUNCH_DIR = '.brunch'; const DATA_DB_FILE = 'data.db'; /** - * Spec-scoped snapshot readers. Returned by `WorkspaceGraphRuntime.forSpec` + * Spec-scoped graph reads. 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 getGraphOverview: (options?: GraphOverviewOptions) => GraphOverview; + readonly getGraphSliceByKinds: (options: GraphSliceByKindsOptions) => GraphOverview; + readonly getGraphSliceByReadinessBands: (options: GraphSliceByReadinessBandsOptions) => GraphOverview; + readonly getGraphGaps: (options: GraphGapsOptions) => GraphOverview; + readonly getRelatedNodes: (options: RelatedNodesOptions) => RelatedNodesResult; readonly getNodeNeighborhood: (nodeId: number, options?: NeighborhoodOptions) => NeighborhoodResult; readonly resolveNodeCode: (code: string) => number | undefined; } export interface WorkspaceGraphRuntime { readonly commandExecutor: CommandExecutor; - /** Bind snapshot readers to a single spec (D61-L). */ + /** Bind graph reads to a single spec (D61-L). */ readonly forSpec: (specId: number) => SpecScopedReaders; } @@ -32,7 +54,11 @@ export async function openWorkspaceGraphRuntime(cwd: string): Promise getGraphOverview(db, specId), + getGraphOverview: (options) => getGraphOverview(db, specId, options), + getGraphSliceByKinds: (options) => getGraphSliceByKinds(db, specId, options), + getGraphSliceByReadinessBands: (options) => getGraphSliceByReadinessBands(db, specId, options), + getGraphGaps: (options) => getGraphGaps(db, specId, options), + getRelatedNodes: (options) => getRelatedNodes(db, specId, options), getNodeNeighborhood: (nodeId, options) => getNodeNeighborhood(db, specId, nodeId, options), resolveNodeCode: (code) => resolveGraphNodeCode(db, specId, code), }; diff --git a/src/probes/capture-response-to-graph-proof.test.ts b/src/probes/capture-response-to-graph-proof.test.ts index c3a9c0171..75e0a5bfa 100644 --- a/src/probes/capture-response-to-graph-proof.test.ts +++ b/src/probes/capture-response-to-graph-proof.test.ts @@ -38,27 +38,26 @@ describe('capture response to graph proof', () => { const report = await runCaptureResponseToGraphProof({ fixtureRoot, runId: 'artifact-test' }); expect(report.artifacts).toEqual({ - runDir: join(fixtureRoot, 'runs', 'capture-response-to-graph', 'artifact-test'), - sessionJsonl: join(fixtureRoot, 'runs', 'capture-response-to-graph', 'artifact-test', 'session.jsonl'), - transcriptMarkdown: join( - fixtureRoot, - 'runs', - 'capture-response-to-graph', - 'artifact-test', - 'transcript.md', - ), - reportJson: join(fixtureRoot, 'runs', 'capture-response-to-graph', 'artifact-test', 'report.json'), + runDir: 'runs/capture-response-to-graph/artifact-test', + sessionJsonl: 'runs/capture-response-to-graph/artifact-test/session.jsonl', + transcriptMarkdown: 'runs/capture-response-to-graph/artifact-test/transcript.md', + reportJson: 'runs/capture-response-to-graph/artifact-test/report.json', }); if (!report.artifacts) throw new Error('expected artifacts'); - const sessionJsonl = await readFile(report.artifacts.sessionJsonl, 'utf8'); - const transcript = await readFile(report.artifacts.transcriptMarkdown, 'utf8'); - const persistedReport = JSON.parse(await readFile(report.artifacts.reportJson, 'utf8')) as typeof report; + const sessionJsonl = await readFile(join(fixtureRoot, report.artifacts.sessionJsonl), 'utf8'); + const transcript = await readFile(join(fixtureRoot, report.artifacts.transcriptMarkdown), 'utf8'); + const persistedReport = JSON.parse( + await readFile(join(fixtureRoot, report.artifacts.reportJson), 'utf8'), + ) as typeof report; expect(sessionJsonl).toContain('Goal: Help product teams turn elicitation answers into graph truth.'); expect(transcript).toContain('Tool result: request_answer'); expect(persistedReport.capture).toEqual(report.capture); expect(persistedReport.graph.codes).toEqual(['G1', 'CTX1', 'CON1', 'CR1']); expect(persistedReport.friction).toEqual([]); + // Persisted refs stay fixture-root-relative and the temp cwd is scrubbed. + expect(JSON.stringify(persistedReport.artifacts)).not.toContain(fixtureRoot); + expect(persistedReport.cwd).toBe(''); }); }); diff --git a/src/probes/capture-response-to-graph-proof.ts b/src/probes/capture-response-to-graph-proof.ts index 5c8152fcb..39626f639 100644 --- a/src/probes/capture-response-to-graph-proof.ts +++ b/src/probes/capture-response-to-graph-proof.ts @@ -1,13 +1,14 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import type { GraphOverview } from '../graph/queries.js'; import { formatGraphNodeCode } from '../graph/schema/nodes.js'; -import type { GraphOverview } from '../graph/snapshot.js'; import { createRpcHandlers } from '../rpc/handlers.js'; import { createProductUpdatePublisher, type ProductUpdate } from '../rpc/product-updates.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; interface JsonRpcSuccess { readonly jsonrpc: '2.0'; @@ -188,19 +189,24 @@ export async function runCaptureResponseToGraphProof( }; if (options.fixtureRoot === undefined) return reportWithoutArtifacts; + const fixtureRoot = options.fixtureRoot; - const runDir = join(options.fixtureRoot, 'runs', 'capture-response-to-graph', runId); - await mkdir(runDir, { recursive: true }); + // Persisted artifact references are fixture-root-relative so committed + // reports stay portable; disk paths are resolved against the fixture root. + const runDirRef = `runs/capture-response-to-graph/${runId}`; + const diskPath = (ref: string) => resolve(fixtureRoot, ref); const artifacts = { - runDir, - sessionJsonl: join(runDir, 'session.jsonl'), - transcriptMarkdown: join(runDir, 'transcript.md'), - reportJson: join(runDir, 'report.json'), + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, }; const report = { ...reportWithoutArtifacts, artifacts }; - await writeFile(artifacts.sessionJsonl, sessionJsonl); - await writeFile(artifacts.transcriptMarkdown, transcriptMarkdown); - await writeFile(artifacts.reportJson, `${JSON.stringify(report, null, 2)}\n`); + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), sessionJsonl); + await writeFile(diskPath(artifacts.transcriptMarkdown), transcriptMarkdown); + const persistedReport = { ...report, cwd: portableCwd(report.cwd) }; + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(persistedReport, null, 2)}\n`); return report; } diff --git a/src/probes/fixture-curation-loop.test.ts b/src/probes/fixture-curation-loop.test.ts index 712f186ca..8d021bb1c 100644 --- a/src/probes/fixture-curation-loop.test.ts +++ b/src/probes/fixture-curation-loop.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../graph/snapshot.js'; +import type { GraphOverview } from '../graph/queries.js'; import { summarizeFixtureCurationRun, writeFixtureCurationArtifacts, @@ -156,7 +156,7 @@ describe('fixture curation loop report', () => { expect(report.friction).toContain('No implicit graph nodes were present in graph readback.'); }); - it('writes session, transcript, report, and graph snapshot artifacts', async () => { + it('writes session, transcript, report, and graph overview artifacts', async () => { const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-fixture-curation-artifacts-')); const report: FixtureCurationReport = summarizeFixtureCurationRun({ runId: 'fixture-curation-test', @@ -186,15 +186,70 @@ describe('fixture curation loop report', () => { runId: 'fixture-curation-test', sessionText: toolResultEntry('commit_graph', { status: 'success' }), report, - graphSnapshot: mixedBasisOverview, + graphOverview: mixedBasisOverview, }); - expect(artifacts.runDir).toBe(join(fixtureRoot, 'runs', 'fixture-curation', 'fixture-curation-test')); - await expect(readFile(artifacts.sessionJsonl, 'utf8')).resolves.toContain('"toolName":"commit_graph"'); - await expect(readFile(artifacts.transcriptMarkdown, 'utf8')).resolves.toContain('## Raw session JSONL'); - await expect(readFile(artifacts.reportJson, 'utf8')).resolves.toContain( + expect(artifacts.runDir).toBe('runs/fixture-curation/fixture-curation-test'); + await expect(readFile(join(fixtureRoot, artifacts.sessionJsonl), 'utf8')).resolves.toContain( + '"toolName":"commit_graph"', + ); + await expect(readFile(join(fixtureRoot, artifacts.transcriptMarkdown), 'utf8')).resolves.toContain( + '## Raw session JSONL', + ); + await expect(readFile(join(fixtureRoot, artifacts.reportJson), 'utf8')).resolves.toContain( '"seedSlug": "macro-view-grounded-intent"', ); - await expect(readFile(artifacts.graphSnapshotJson, 'utf8')).resolves.toContain('"basis": "implicit"'); + await expect(readFile(join(fixtureRoot, artifacts.graphOverviewJson), 'utf8')).resolves.toContain( + '"basis": "implicit"', + ); + }); + + it('persists portable, fixture-relative artifact references in report JSON', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-fixture-curation-portable-')); + const report: FixtureCurationReport = summarizeFixtureCurationRun({ + runId: 'portable-run', + generatedAt: '2026-06-05T00:00:00.000Z', + cwd: fixtureRoot, + seedSlug: 'macro-view-grounded-intent', + selectedBaseProfile: 'grounded-intent', + specId: 7, + sessionId: 'session-1', + prompt: 'Please curate the graph.', + runtimeState: { + operationalMode: 'elicit', + agentStrategy: 'propose-graph', + agentLens: 'intent', + agentGoal: 'commit-converge', + }, + sessionText: toolResultEntry('commit_graph', { + status: 'success', + lsn: 3, + createdNodes: { node: { id: 2 } }, + }), + overview: mixedBasisOverview, + }); + + const artifacts = await writeFixtureCurationArtifacts({ + fixtureRoot, + runId: 'portable-run', + sessionText: toolResultEntry('commit_graph', { status: 'success' }), + report, + graphOverview: mixedBasisOverview, + }); + + const expectedRefs = { + runDir: 'runs/fixture-curation/portable-run', + sessionJsonl: 'runs/fixture-curation/portable-run/session.jsonl', + transcriptMarkdown: 'runs/fixture-curation/portable-run/transcript.md', + reportJson: 'runs/fixture-curation/portable-run/report.json', + graphOverviewJson: 'runs/fixture-curation/portable-run/graph-overview.json', + }; + expect(artifacts).toEqual(expectedRefs); + + const persisted = JSON.parse(await readFile(join(fixtureRoot, expectedRefs.reportJson), 'utf8')) as { + artifacts: typeof expectedRefs; + }; + expect(persisted.artifacts).toEqual(expectedRefs); + expect(JSON.stringify(persisted.artifacts)).not.toContain(fixtureRoot); }); }); diff --git a/src/probes/fixture-curation-loop.ts b/src/probes/fixture-curation-loop.ts index 3a77ec3b9..3673f3784 100644 --- a/src/probes/fixture-curation-loop.ts +++ b/src/probes/fixture-curation-loop.ts @@ -20,6 +20,7 @@ import { import { seedFixture, type SeedFixture } from '../graph/seed-fixtures.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; const PROBE_ID = 'fixture-curation' as const; const DEFAULT_SEED_SET = 'bilal-port-variants'; @@ -56,7 +57,7 @@ export interface FixtureCurationArtifacts { readonly sessionJsonl: string; readonly transcriptMarkdown: string; readonly reportJson: string; - readonly graphSnapshotJson: string; + readonly graphOverviewJson: string; } export interface FixtureCurationCommitAttempt { @@ -202,7 +203,7 @@ export async function runFixtureCurationLoop( runId, sessionText, report, - graphSnapshot: overview, + graphOverview: overview, }), }; return report; @@ -281,27 +282,35 @@ export async function writeFixtureCurationArtifacts(options: { readonly runId: string; readonly sessionText: string; readonly report: FixtureCurationReport; - readonly graphSnapshot: GraphOverview; + readonly graphOverview: GraphOverview; }): Promise { - const runDir = join(options.fixtureRoot, 'runs', PROBE_ID, options.runId); + // Persisted artifact references are fixture-root-relative so committed + // reports stay portable; the disk paths used for writing are resolved + // against the (possibly absolute) fixture root. + const runDirRef = `runs/${PROBE_ID}/${options.runId}`; const artifacts: FixtureCurationArtifacts = { - runDir, - sessionJsonl: join(runDir, 'session.jsonl'), - transcriptMarkdown: join(runDir, 'transcript.md'), - reportJson: join(runDir, 'report.json'), - graphSnapshotJson: join(runDir, 'graph-snapshot.json'), + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, + graphOverviewJson: `${runDirRef}/graph-overview.json`, }; - const report = { ...options.report, artifacts }; + const diskPath = (ref: string) => resolve(options.fixtureRoot, ref); + const report = { ...options.report, cwd: portableCwd(options.report.cwd), artifacts }; - await mkdir(runDir, { recursive: true }); - await writeFile(artifacts.sessionJsonl, options.sessionText, 'utf8'); + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), options.sessionText, 'utf8'); await writeFile( - artifacts.transcriptMarkdown, + diskPath(artifacts.transcriptMarkdown), `${renderSessionTranscript(options.sessionText, { title: 'session.jsonl' })}\n\n## Raw session JSONL\n\n\`\`\`jsonl\n${options.sessionText.trimEnd()}\n\`\`\`\n`, 'utf8', ); - await writeFile(artifacts.reportJson, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); - await writeFile(artifacts.graphSnapshotJson, `${JSON.stringify(options.graphSnapshot, null, 2)}\n`, 'utf8'); + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile( + diskPath(artifacts.graphOverviewJson), + `${JSON.stringify(options.graphOverview, null, 2)}\n`, + 'utf8', + ); return artifacts; } diff --git a/src/probes/portable-report.ts b/src/probes/portable-report.ts new file mode 100644 index 000000000..2cfc4bc9a --- /dev/null +++ b/src/probes/portable-report.ts @@ -0,0 +1,21 @@ +import { tmpdir } from 'node:os'; +import { resolve, sep } from 'node:path'; + +/** + * Probe runs default to an ephemeral `mkdtemp` workspace under the OS temp + * directory. Persisting that absolute path (e.g. `/var/folders/…/T/brunch-…`) + * into committed report fixtures leaks machine-specific, non-deterministic + * paths. Replace such a `cwd` with a stable, portable marker so persisted + * reports stay reproducible; leave explicit non-temp working directories + * untouched. + */ +export const EPHEMERAL_WORKSPACE_CWD = ''; + +export function portableCwd(cwd: string): string { + const tempRoot = resolve(tmpdir()); + const resolved = resolve(cwd); + if (resolved === tempRoot || resolved.startsWith(`${tempRoot}${sep}`)) { + return EPHEMERAL_WORKSPACE_CWD; + } + return cwd; +} diff --git a/src/probes/project-graph-review-cycle-proof.test.ts b/src/probes/project-graph-review-cycle-proof.test.ts index adb01f862..ca2568513 100644 --- a/src/probes/project-graph-review-cycle-proof.test.ts +++ b/src/probes/project-graph-review-cycle-proof.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../graph/snapshot.js'; +import type { GraphOverview } from '../graph/queries.js'; import type { JsonRpcResponse } from '../rpc/protocol.js'; import { summarizeProjectGraphReviewCycleProof, @@ -231,7 +231,7 @@ describe('project-graph review-cycle proof report', () => { ); }); - it('writes session, transcript, report, and graph snapshot artifacts', async () => { + it('writes session, transcript, report, and graph overview artifacts', async () => { const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-project-graph-review-artifacts-')); const report: ProjectGraphReviewCycleReport = summarizeProjectGraphReviewCycleProof({ runId: 'artifact-run', @@ -254,15 +254,63 @@ describe('project-graph review-cycle proof report', () => { runId: report.runId, sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), report, - graphSnapshot: approvedOverview, + graphOverview: approvedOverview, }); - expect(artifacts.runDir).toBe(join(fixtureRoot, 'runs', 'project-graph-review-cycle', 'artifact-run')); - await expect(readFile(artifacts.sessionJsonl, 'utf8')).resolves.toContain('present_review_set'); - await expect(readFile(artifacts.transcriptMarkdown, 'utf8')).resolves.toContain('## Raw session JSONL'); - await expect(readFile(artifacts.reportJson, 'utf8')).resolves.toContain('project-graph-review-cycle'); - await expect(readFile(artifacts.graphSnapshotJson, 'utf8')).resolves.toContain( + expect(artifacts.runDir).toBe('runs/project-graph-review-cycle/artifact-run'); + await expect(readFile(join(fixtureRoot, artifacts.sessionJsonl), 'utf8')).resolves.toContain( + 'present_review_set', + ); + await expect(readFile(join(fixtureRoot, artifacts.transcriptMarkdown), 'utf8')).resolves.toContain( + '## Raw session JSONL', + ); + await expect(readFile(join(fixtureRoot, artifacts.reportJson), 'utf8')).resolves.toContain( + 'project-graph-review-cycle', + ); + await expect(readFile(join(fixtureRoot, artifacts.graphOverviewJson), 'utf8')).resolves.toContain( 'Macro view names impasse resolution state', ); }); + + it('persists portable, fixture-relative artifact references in report JSON', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-project-graph-review-portable-')); + const report: ProjectGraphReviewCycleReport = summarizeProjectGraphReviewCycleProof({ + runId: 'portable-run', + generatedAt: '2026-06-06T00:00:00.000Z', + cwd: fixtureRoot, + seedSlug: 'macro-view-grounded-intent', + specId: 7, + sessionId: 'session-1', + prompt: 'Present a review set.', + runtimeState, + sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), + baseOverview, + finalOverview: approvedOverview, + pendingResponse: pendingReviewResponse(), + approvalResponse: approvedResponse(), + }); + + const artifacts = await writeProjectGraphReviewCycleArtifacts({ + fixtureRoot, + runId: report.runId, + sessionText: [presentReviewSetEntry(), requestReviewEntry()].join('\n'), + report, + graphOverview: approvedOverview, + }); + + const expectedRefs = { + runDir: 'runs/project-graph-review-cycle/portable-run', + sessionJsonl: 'runs/project-graph-review-cycle/portable-run/session.jsonl', + transcriptMarkdown: 'runs/project-graph-review-cycle/portable-run/transcript.md', + reportJson: 'runs/project-graph-review-cycle/portable-run/report.json', + graphOverviewJson: 'runs/project-graph-review-cycle/portable-run/graph-overview.json', + }; + expect(artifacts).toEqual(expectedRefs); + + const persisted = JSON.parse(await readFile(join(fixtureRoot, expectedRefs.reportJson), 'utf8')) as { + artifacts: typeof expectedRefs; + }; + expect(persisted.artifacts).toEqual(expectedRefs); + expect(JSON.stringify(persisted.artifacts)).not.toContain(fixtureRoot); + }); }); diff --git a/src/probes/project-graph-review-cycle-proof.ts b/src/probes/project-graph-review-cycle-proof.ts index 8e89c41f0..1a9ca7114 100644 --- a/src/probes/project-graph-review-cycle-proof.ts +++ b/src/probes/project-graph-review-cycle-proof.ts @@ -16,6 +16,7 @@ import { createProductUpdatePublisher, type ProductUpdate } from '../rpc/product import type { JsonRpcResponse } from '../rpc/protocol.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; const PROBE_ID = 'project-graph-review-cycle' as const; const DEFAULT_SEED_SET = 'bilal-port-variants'; @@ -43,7 +44,7 @@ export interface ProjectGraphReviewCycleArtifacts { readonly sessionJsonl: string; readonly transcriptMarkdown: string; readonly reportJson: string; - readonly graphSnapshotJson: string; + readonly graphOverviewJson: string; } export interface ReviewCycleToolEvidence { @@ -278,7 +279,7 @@ export async function runProjectGraphReviewCycleProof( runId, sessionText, report, - graphSnapshot: finalOverview, + graphOverview: finalOverview, }), }; return report; @@ -397,27 +398,35 @@ export async function writeProjectGraphReviewCycleArtifacts(options: { readonly runId: string; readonly sessionText: string; readonly report: ProjectGraphReviewCycleReport; - readonly graphSnapshot: GraphOverview; + readonly graphOverview: GraphOverview; }): Promise { - const runDir = join(options.fixtureRoot, 'runs', PROBE_ID, options.runId); + // Persisted artifact references are fixture-root-relative so committed + // reports stay portable; the disk paths used for writing are resolved + // against the (possibly absolute) fixture root. + const runDirRef = `runs/${PROBE_ID}/${options.runId}`; const artifacts: ProjectGraphReviewCycleArtifacts = { - runDir, - sessionJsonl: join(runDir, 'session.jsonl'), - transcriptMarkdown: join(runDir, 'transcript.md'), - reportJson: join(runDir, 'report.json'), - graphSnapshotJson: join(runDir, 'graph-snapshot.json'), + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, + graphOverviewJson: `${runDirRef}/graph-overview.json`, }; - const report = { ...options.report, artifacts }; + const diskPath = (ref: string) => resolve(options.fixtureRoot, ref); + const report = { ...options.report, cwd: portableCwd(options.report.cwd), artifacts }; - await mkdir(runDir, { recursive: true }); - await writeFile(artifacts.sessionJsonl, options.sessionText, 'utf8'); + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), options.sessionText, 'utf8'); await writeFile( - artifacts.transcriptMarkdown, + diskPath(artifacts.transcriptMarkdown), `${renderSessionTranscript(options.sessionText, { title: 'session.jsonl' })}\n\n## Raw session JSONL\n\n\`\`\`jsonl\n${options.sessionText.trimEnd()}\n\`\`\`\n`, 'utf8', ); - await writeFile(artifacts.reportJson, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); - await writeFile(artifacts.graphSnapshotJson, `${JSON.stringify(options.graphSnapshot, null, 2)}\n`, 'utf8'); + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile( + diskPath(artifacts.graphOverviewJson), + `${JSON.stringify(options.graphOverview, null, 2)}\n`, + 'utf8', + ); return artifacts; } diff --git a/src/probes/propose-graph-commit-proof.test.ts b/src/probes/propose-graph-commit-proof.test.ts index 821021302..70bf9edd8 100644 --- a/src/probes/propose-graph-commit-proof.test.ts +++ b/src/probes/propose-graph-commit-proof.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../graph/snapshot.js'; +import type { GraphOverview } from '../graph/queries.js'; import { summarizeProposeGraphCommitProof, writeProposeGraphCommitProofArtifacts, @@ -338,8 +338,16 @@ describe('propose-graph commit proof report', () => { report, }); - expect(await readFile(artifacts.reportJson, 'utf8')).toContain('propose-graph-commit'); - expect(await readFile(artifacts.sessionJsonl, 'utf8')).toContain('commit_graph'); - expect(await readFile(artifacts.transcriptMarkdown, 'utf8')).toContain('Graph committed successfully'); + expect(artifacts).toEqual({ + runDir: 'runs/propose-graph-commit/artifact-run', + sessionJsonl: 'runs/propose-graph-commit/artifact-run/session.jsonl', + transcriptMarkdown: 'runs/propose-graph-commit/artifact-run/transcript.md', + reportJson: 'runs/propose-graph-commit/artifact-run/report.json', + }); + expect(await readFile(join(fixtureRoot, artifacts.reportJson), 'utf8')).toContain('propose-graph-commit'); + expect(await readFile(join(fixtureRoot, artifacts.sessionJsonl), 'utf8')).toContain('commit_graph'); + expect(await readFile(join(fixtureRoot, artifacts.transcriptMarkdown), 'utf8')).toContain( + 'Graph committed successfully', + ); }); }); diff --git a/src/probes/propose-graph-commit-proof.ts b/src/probes/propose-graph-commit-proof.ts index 054780cd9..e8d9f58bf 100644 --- a/src/probes/propose-graph-commit-proof.ts +++ b/src/probes/propose-graph-commit-proof.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -14,10 +14,11 @@ import { type Diagnostic, type StructuralIllegal, } from '../graph/index.js'; +import type { GraphOverview } from '../graph/queries.js'; import { formatGraphNodeCode } from '../graph/schema/nodes.js'; -import type { GraphOverview } from '../graph/snapshot.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; const PROBE_ID = 'propose-graph-commit' as const; const DEFAULT_MAX_ATTEMPTS = 2; @@ -150,7 +151,7 @@ export async function runProposeGraphCommitProof( throw new Error('failed to advance probe spec to elicitation_ready'); } const expectedExistingCode = seedScenarioGraph(graph, workspace.spec.id, scenarioId); - const specSnapshots = graph.forSpec(workspace.spec.id); + const specReads = graph.forSpec(workspace.spec.id); const agentDir = options.agentDir ?? getAgentDir(); const createRuntime = createBrunchAgentSessionRuntimeFactory({ workspace, coordinator }); const created = await createRuntime({ @@ -173,7 +174,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: specSnapshots.getGraphOverview(), + overview: specReads.getGraphOverview(), prompt, scenarioId, ...(expectedExistingCode !== undefined ? { expectedExistingCode } : {}), @@ -192,7 +193,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: specSnapshots.getGraphOverview(), + overview: specReads.getGraphOverview(), prompt, scenarioId, ...(expectedExistingCode !== undefined ? { expectedExistingCode } : {}), @@ -547,20 +548,24 @@ export async function writeProposeGraphCommitProofArtifacts(options: { sessionText: string; report: ProposeGraphCommitProofReport; }): Promise { - const runDir = join(options.fixtureRoot, 'runs', PROBE_ID, options.runId); + // Persisted artifact references are fixture-root-relative so committed + // reports stay portable; disk paths are resolved against the fixture root. + const runDirRef = `runs/${PROBE_ID}/${options.runId}`; const artifacts: ProposeGraphCommitProofArtifacts = { - runDir, - sessionJsonl: join(runDir, 'session.jsonl'), - transcriptMarkdown: join(runDir, 'transcript.md'), - reportJson: join(runDir, 'report.json'), + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, }; - const report: ProposeGraphCommitProofReport = { + const diskPath = (ref: string) => resolve(options.fixtureRoot, ref); + const persistedReport: ProposeGraphCommitProofReport = { ...options.report, + cwd: portableCwd(options.report.cwd), artifacts, }; - await mkdir(runDir, { recursive: true }); - await writeFile(artifacts.sessionJsonl, options.sessionText, 'utf8'); + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), options.sessionText, 'utf8'); const transcriptMarkdown = [ renderSessionTranscript(options.sessionText, { title: 'session.jsonl' }), '', @@ -571,8 +576,8 @@ export async function writeProposeGraphCommitProofArtifacts(options: { '```', '', ].join('\n'); - await writeFile(artifacts.transcriptMarkdown, transcriptMarkdown, 'utf8'); - await writeFile(artifacts.reportJson, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile(diskPath(artifacts.transcriptMarkdown), transcriptMarkdown, 'utf8'); + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(persistedReport, null, 2)}\n`, 'utf8'); return artifacts; } diff --git a/src/probes/public-rpc-parity-proof.test.ts b/src/probes/public-rpc-parity-proof.test.ts index 77f1962f7..688c87864 100644 --- a/src/probes/public-rpc-parity-proof.test.ts +++ b/src/probes/public-rpc-parity-proof.test.ts @@ -50,10 +50,10 @@ describe('public Brunch RPC structured-exchange parity proof', () => { const artifacts = report.artifacts; expect(artifacts).toEqual({ - runDir: join(fixtureRoot, 'runs', 'public-rpc-parity', report.runId), - sessionJsonl: join(fixtureRoot, 'runs', 'public-rpc-parity', report.runId, 'session.jsonl'), - transcriptMarkdown: join(fixtureRoot, 'runs', 'public-rpc-parity', report.runId, 'transcript.md'), - reportJson: join(fixtureRoot, 'runs', 'public-rpc-parity', report.runId, 'report.json'), + runDir: `runs/public-rpc-parity/${report.runId}`, + sessionJsonl: `runs/public-rpc-parity/${report.runId}/session.jsonl`, + transcriptMarkdown: `runs/public-rpc-parity/${report.runId}/transcript.md`, + reportJson: `runs/public-rpc-parity/${report.runId}/report.json`, }); if (artifacts === undefined) throw new Error('Expected artifact paths'); @@ -61,9 +61,14 @@ describe('public Brunch RPC structured-exchange parity proof', () => { expect(basename(artifacts.runDir)).toBe(report.runId); expect(basename(dirname(artifacts.runDir))).toBe(report.probeId); - const sessionJsonl = await readFile(artifacts.sessionJsonl, 'utf8'); - const transcript = await readFile(artifacts.transcriptMarkdown, 'utf8'); - const persistedReport = JSON.parse(await readFile(artifacts.reportJson, 'utf8')) as typeof report; + const sessionJsonl = await readFile(join(fixtureRoot, artifacts.sessionJsonl), 'utf8'); + const transcript = await readFile(join(fixtureRoot, artifacts.transcriptMarkdown), 'utf8'); + const persistedReport = JSON.parse( + await readFile(join(fixtureRoot, artifacts.reportJson), 'utf8'), + ) as typeof report; + // Persisted refs stay fixture-root-relative and the temp cwd is scrubbed. + expect(JSON.stringify(persistedReport.artifacts)).not.toContain(fixtureRoot); + expect(persistedReport.cwd).toBe(''); expect(sessionJsonl).toContain('"toolName":"present_options"'); expect(transcript).toContain('# Transcript — session.jsonl'); diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index e1c6decbf..9276c99fc 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -1,10 +1,11 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { createRpcHandlers } from '../rpc/handlers.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; const PUBLIC_RPC_PARITY_PERMUTATION_COUNT = 3; @@ -377,26 +378,31 @@ async function writeProofArtifacts(options: { sessionText: string; report: PublicRpcParityProofReport; }): Promise { - const runDir = join(options.fixtureRoot, 'runs', 'public-rpc-parity', options.runId); + // Persisted artifact references are fixture-root-relative so committed + // reports stay portable; the disk paths used for writing are resolved + // against the (possibly absolute) fixture root. + const runDirRef = `runs/public-rpc-parity/${options.runId}`; const artifacts: PublicRpcParityProofArtifacts = { - runDir, - sessionJsonl: join(runDir, 'session.jsonl'), - transcriptMarkdown: join(runDir, 'transcript.md'), - reportJson: join(runDir, 'report.json'), + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, }; + const diskPath = (ref: string) => resolve(options.fixtureRoot, ref); const persistedReport: PublicRpcParityProofReport = { ...options.report, + cwd: portableCwd(options.report.cwd), artifacts, }; - await mkdir(runDir, { recursive: true }); - await writeFile(artifacts.sessionJsonl, options.sessionText, 'utf8'); + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), options.sessionText, 'utf8'); await writeFile( - artifacts.transcriptMarkdown, + diskPath(artifacts.transcriptMarkdown), renderSessionTranscript(options.sessionText, { title: 'session.jsonl' }), 'utf8', ); - await writeFile(artifacts.reportJson, `${JSON.stringify(persistedReport, null, 2)}\n`, 'utf8'); + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(persistedReport, null, 2)}\n`, 'utf8'); return artifacts; } diff --git a/src/probes/submit-message-capture-proof.test.ts b/src/probes/submit-message-capture-proof.test.ts new file mode 100644 index 000000000..329df6ca1 --- /dev/null +++ b/src/probes/submit-message-capture-proof.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { runSubmitMessageCaptureProof } from './submit-message-capture-proof.js'; + +describe('submit message capture proof', () => { + it('proves public RPC submitMessage captures ordinary labeled text into graph truth', async () => { + const report = await runSubmitMessageCaptureProof({ runId: 'unit-proof' }); + + expect(report).toMatchObject({ + schemaVersion: 1, + probeId: 'submit-message-capture', + runId: 'unit-proof', + specId: expect.any(Number), + sessionId: expect.any(String), + messageId: expect.any(String), + capture: { status: 'captured', nodeCount: 4, lsn: expect.any(Number) }, + graph: { + nodeCount: 4, + codes: ['G1', 'CTX1', 'CON1', 'CR1'], + lsn: expect.any(Number), + }, + updates: expect.arrayContaining([ + { topic: 'graph.overview', specId: expect.any(Number), lsn: expect.any(Number) }, + { topic: 'graph.nodeNeighborhood', specId: expect.any(Number), lsn: expect.any(Number) }, + ]), + friction: [], + }); + expect(report.graph.lsn).toBeGreaterThanOrEqual(report.capture.lsn); + }); + + it('writes transcript, capture outcome, graph evidence, lsn, and friction artifacts', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'brunch-submit-message-fixtures-')); + + const report = await runSubmitMessageCaptureProof({ fixtureRoot, runId: 'artifact-test' }); + + expect(report.artifacts).toEqual({ + runDir: 'runs/submit-message-capture/artifact-test', + sessionJsonl: 'runs/submit-message-capture/artifact-test/session.jsonl', + transcriptMarkdown: 'runs/submit-message-capture/artifact-test/transcript.md', + reportJson: 'runs/submit-message-capture/artifact-test/report.json', + }); + if (!report.artifacts) throw new Error('expected artifacts'); + + const sessionJsonl = await readFile(join(fixtureRoot, report.artifacts.sessionJsonl), 'utf8'); + const transcript = await readFile(join(fixtureRoot, report.artifacts.transcriptMarkdown), 'utf8'); + const persistedReport = JSON.parse( + await readFile(join(fixtureRoot, report.artifacts.reportJson), 'utf8'), + ) as typeof report; + + expect(sessionJsonl).toContain('Goal: Keep ordinary user messages on the same capture path.'); + expect(transcript).toContain('User'); + expect(persistedReport.capture).toEqual(report.capture); + expect(persistedReport.graph.codes).toEqual(['G1', 'CTX1', 'CON1', 'CR1']); + expect(persistedReport.friction).toEqual([]); + expect(JSON.stringify(persistedReport.artifacts)).not.toContain(fixtureRoot); + expect(persistedReport.cwd).toBe(''); + }); +}); diff --git a/src/probes/submit-message-capture-proof.ts b/src/probes/submit-message-capture-proof.ts new file mode 100644 index 000000000..657f42227 --- /dev/null +++ b/src/probes/submit-message-capture-proof.ts @@ -0,0 +1,194 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import type { GraphOverview } from '../graph/queries.js'; +import { formatGraphNodeCode } from '../graph/schema/nodes.js'; +import { createRpcHandlers } from '../rpc/handlers.js'; +import { createProductUpdatePublisher, type ProductUpdate } from '../rpc/product-updates.js'; +import { renderSessionTranscript } from '../session/session-transcript.js'; +import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; +import { portableCwd } from './portable-report.js'; + +interface JsonRpcSuccess { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly result: T; +} + +interface SubmitMessageResult { + readonly status: 'accepted'; + readonly messageId: string; + readonly capture: CaptureOutcome; +} + +interface CaptureOutcome { + readonly status: 'captured'; + readonly lsn: number; + readonly nodeCount: number; + readonly createdNodes: Record; +} + +export interface SubmitMessageCaptureProofArtifacts { + readonly runDir: string; + readonly sessionJsonl: string; + readonly transcriptMarkdown: string; + readonly reportJson: string; +} + +export interface SubmitMessageCaptureProofOptions { + readonly fixtureRoot?: string; + readonly runId?: string; +} + +export interface SubmitMessageCaptureProofReport { + readonly schemaVersion: 1; + readonly probeId: 'submit-message-capture'; + readonly runId: string; + readonly generatedAt: string; + readonly mission: string; + readonly evaluationFocus: string; + readonly cwd: string; + readonly specId: number; + readonly sessionId: string; + readonly messageId: string; + readonly capture: CaptureOutcome; + readonly graph: { + readonly nodeCount: number; + readonly edgeCount: number; + readonly lsn: number; + readonly codes: readonly string[]; + readonly titles: readonly string[]; + }; + readonly updates: readonly ProductUpdate[]; + readonly friction: readonly string[]; + readonly artifacts?: SubmitMessageCaptureProofArtifacts; +} + +const CAPTURE_TEXT = [ + 'Goal: Keep ordinary user messages on the same capture path.', + 'Context: This message arrives outside a structured exchange.', + 'Constraint: The selected session binding still owns the graph target.', + 'Criterion: Observers see selected-spec graph invalidations after capture.', +].join('\n'); + +export async function runSubmitMessageCaptureProof( + options: SubmitMessageCaptureProofOptions = {}, +): Promise { + const runId = + options.runId ?? + new Date() + .toISOString() + .replaceAll(':', '-') + .replace(/\.\d{3}Z$/, 'Z'); + const generatedAt = new Date().toISOString(); + const cwd = await mkdtemp(join(tmpdir(), 'brunch-submit-message-')); + const coordinator = createWorkspaceSessionCoordinator({ cwd }); + const productUpdates = createProductUpdatePublisher(); + const updates: ProductUpdate[] = []; + productUpdates.subscribe((batch) => updates.push(...batch)); + const handlers = createRpcHandlers({ coordinator, cwd, productUpdates }); + const friction: string[] = []; + + await handlers.handle({ + jsonrpc: '2.0', + id: 1, + method: 'workspace.activate', + params: { decision: { action: 'newSpec', title: 'Submit message proof spec' } }, + }); + const workspace = await coordinator.openDefaultWorkspace(); + if (workspace.status !== 'ready') { + throw new Error('workspace.activate(newSpec) did not create a ready workspace'); + } + + const submitted = success( + await handlers.handle({ + jsonrpc: '2.0', + id: 2, + method: 'session.submitMessage', + params: { text: CAPTURE_TEXT }, + }), + ); + if (submitted.capture.status !== 'captured') { + throw new Error(`Expected capture success, got ${JSON.stringify(submitted.capture)}`); + } + + const overview = success( + await handlers.handle({ + jsonrpc: '2.0', + id: 3, + method: 'graph.overview', + params: { specId: workspace.spec.id }, + }), + ); + if (overview.nodeCount !== submitted.capture.nodeCount) { + friction.push( + `Overview node count ${overview.nodeCount} did not match capture count ${submitted.capture.nodeCount}.`, + ); + } + + const orderedNodes = [...overview.nodes].sort( + (left, right) => captureKindOrder(left.kind) - captureKindOrder(right.kind), + ); + + const graph = { + nodeCount: overview.nodeCount, + edgeCount: overview.edgeCount, + lsn: overview.lsn, + codes: orderedNodes.map((node) => formatGraphNodeCode(node.kind, node.kindOrdinal)), + titles: orderedNodes.map((node) => node.title), + }; + + const sessionJsonl = await readFile(workspace.session.file, 'utf8'); + const transcriptMarkdown = renderSessionTranscript(sessionJsonl, { title: 'session.jsonl' }); + + const reportWithoutArtifacts = { + schemaVersion: 1 as const, + probeId: 'submit-message-capture' as const, + runId, + generatedAt, + mission: 'Drive an ordinary user message through public RPC capture into selected-spec graph truth.', + evaluationFocus: + 'Public RPC submitMessage/graph.overview path, explicit-basis capture outcome, graph counts/codes, transcript evidence, and observer invalidations.', + cwd, + specId: workspace.spec.id, + sessionId: workspace.session.id, + messageId: submitted.messageId, + capture: submitted.capture, + graph, + updates, + friction, + }; + + if (options.fixtureRoot === undefined) return reportWithoutArtifacts; + const runDirRef = `runs/submit-message-capture/${runId}`; + const diskPath = (ref: string) => resolve(options.fixtureRoot!, ref); + const artifacts = { + runDir: runDirRef, + sessionJsonl: `${runDirRef}/session.jsonl`, + transcriptMarkdown: `${runDirRef}/transcript.md`, + reportJson: `${runDirRef}/report.json`, + }; + const report = { ...reportWithoutArtifacts, artifacts }; + await mkdir(diskPath(artifacts.runDir), { recursive: true }); + await writeFile(diskPath(artifacts.sessionJsonl), sessionJsonl); + await writeFile(diskPath(artifacts.transcriptMarkdown), transcriptMarkdown); + const persistedReport = { ...report, cwd: portableCwd(report.cwd) }; + await writeFile(diskPath(artifacts.reportJson), `${JSON.stringify(persistedReport, null, 2)}\n`); + return report; +} + +function success(response: unknown): T { + if (typeof response === 'object' && response !== null && 'result' in response) { + return (response as JsonRpcSuccess).result; + } + throw new Error(`Expected JSON-RPC success response: ${JSON.stringify(response)}`); +} + +function captureKindOrder(kind: string): number { + if (kind === 'goal') return 0; + if (kind === 'context') return 1; + if (kind === 'constraint') return 2; + if (kind === 'criterion') return 3; + return 4; +} diff --git a/src/projections/README.md b/src/projections/README.md index e97dafd94..f4a246e7d 100644 --- a/src/projections/README.md +++ b/src/projections/README.md @@ -14,8 +14,8 @@ Projection modules preserve information; they do not render markdown, perform Pi projections/ graph/ graph read/command DTO projection session/ transcript-context and runtime-state DTO projection - structured-exchange/ canonical toolResult.details construction and transcript details → domain DTO adapters - workspace/ workspace/session snapshot DTO projection + exchanges/ canonical toolResult.details construction and transcript details → domain DTO adapters + workspace/ workspace/session state DTO projection ``` ## Dependency direction @@ -27,5 +27,5 @@ projections/ x> .pi/, rpc/, app/, web/ Current migration notes: -- `projections/structured-exchange/*` imports Zod schemas from `.pi/extensions/exchanges/schemas/` because D37-L/D41-L currently place the structured-exchange schema lock at that Pi transcript seam. That is an explicit temporary exception, not a general adapter dependency permission. +- `projections/exchanges/*` imports Zod schemas from `.pi/extensions/exchanges/schemas/` because D37-L/D41-L currently place the structured-exchange schema lock at that Pi transcript seam. That is an explicit temporary exception, not a general adapter dependency permission. - `projections/session/runtime-state.ts` owns flattened runtime-state DTO projection while `session/runtime-state.ts` owns transcript entry facts and append helpers. diff --git a/src/projections/structured-exchange/present-candidates.ts b/src/projections/exchanges/present-candidates.ts similarity index 84% rename from src/projections/structured-exchange/present-candidates.ts rename to src/projections/exchanges/present-candidates.ts index 4d14509c6..888d30197 100644 --- a/src/projections/structured-exchange/present-candidates.ts +++ b/src/projections/exchanges/present-candidates.ts @@ -8,7 +8,7 @@ * - normalized candidate comparison projection, recommendation cues, and rubric traces * * Future users: - * - renderers/structured-exchange/present-candidates.ts + * - renderers/exchanges/present-candidates.ts * - .pi/extensions/exchanges/present-candidates.ts */ diff --git a/src/projections/structured-exchange/present-options.ts b/src/projections/exchanges/present-options.ts similarity index 100% rename from src/projections/structured-exchange/present-options.ts rename to src/projections/exchanges/present-options.ts diff --git a/src/projections/structured-exchange/present-question.ts b/src/projections/exchanges/present-question.ts similarity index 96% rename from src/projections/structured-exchange/present-question.ts rename to src/projections/exchanges/present-question.ts index b04d396c3..009ffae0b 100644 --- a/src/projections/structured-exchange/present-question.ts +++ b/src/projections/exchanges/present-question.ts @@ -8,7 +8,7 @@ * - normalized heading/body projection plus canonical Zod-authored details * * Used by: - * - renderers/structured-exchange/present-question.ts + * - renderers/exchanges/present-question.ts * - session/structured-exchange-loop.ts * - .pi/extensions/exchanges/present-question.ts */ diff --git a/src/projections/structured-exchange/present-review-set.ts b/src/projections/exchanges/present-review-set.ts similarity index 100% rename from src/projections/structured-exchange/present-review-set.ts rename to src/projections/exchanges/present-review-set.ts diff --git a/src/projections/structured-exchange/request-answer.ts b/src/projections/exchanges/request-answer.ts similarity index 100% rename from src/projections/structured-exchange/request-answer.ts rename to src/projections/exchanges/request-answer.ts diff --git a/src/projections/exchanges/request-choice.test.ts b/src/projections/exchanges/request-choice.test.ts new file mode 100644 index 000000000..7bdaf5736 --- /dev/null +++ b/src/projections/exchanges/request-choice.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { projectRequestChoice } from './request-choice.js'; + +const answeredChoice = { + id: 'opt-1', + label: 'Keep the current shell location', + kind: 'listed', +} as const; + +describe('projectRequestChoice next-tool metadata', () => { + it('derives the capture_choice next step when answering present_options', () => { + const details = projectRequestChoice({ + exchangeId: 'shell-location', + respondsToPresentTool: 'present_options', + status: 'answered', + choice: answeredChoice, + }); + + expect(details.tool_meta).toEqual({ + prev: 'present_options', + curr: 'request_choice', + next: 'capture_choice', + }); + }); + + it('derives the capture_candidate next step when answering present_candidates', () => { + const details = projectRequestChoice({ + exchangeId: 'candidate-pick', + respondsToPresentTool: 'present_candidates', + status: 'answered', + choice: answeredChoice, + }); + + expect(details.tool_meta).toEqual({ + prev: 'present_candidates', + curr: 'request_choice', + next: 'capture_candidate', + }); + }); + + it('omits the next step for non-answered outcomes', () => { + const cancelled = projectRequestChoice({ + exchangeId: 'shell-location', + respondsToPresentTool: 'present_options', + status: 'cancelled', + }); + + expect(cancelled.tool_meta).toEqual({ + prev: 'present_options', + curr: 'request_choice', + }); + }); +}); diff --git a/src/projections/structured-exchange/request-choice.ts b/src/projections/exchanges/request-choice.ts similarity index 75% rename from src/projections/structured-exchange/request-choice.ts rename to src/projections/exchanges/request-choice.ts index 50fe65304..af0e152f5 100644 --- a/src/projections/structured-exchange/request-choice.ts +++ b/src/projections/exchanges/request-choice.ts @@ -28,6 +28,10 @@ export function projectRequestChoice(input: { const comment = normalizeOptionalText(input.comment); return zRequestChoiceDetails.parse({ ...base, + tool_meta: { + ...base.tool_meta, + next: captureToolForPresentTool(input.respondsToPresentTool), + }, answered: { choice: input.choice, ...(comment !== undefined ? { comment } : {}), @@ -43,6 +47,17 @@ export function projectRequestChoice(input: { }); } +/** + * The capture tool that answers a request_choice depends on which present tool + * the request responds to: option lists capture as a plain choice, candidate + * lists capture as a candidate selection. + */ +function captureToolForPresentTool( + presentTool: RequestChoicePresentTool, +): 'capture_choice' | 'capture_candidate' { + return presentTool === 'present_candidates' ? 'capture_candidate' : 'capture_choice'; +} + function normalizeOptionalText(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; diff --git a/src/projections/structured-exchange/request-choices.ts b/src/projections/exchanges/request-choices.ts similarity index 100% rename from src/projections/structured-exchange/request-choices.ts rename to src/projections/exchanges/request-choices.ts diff --git a/src/projections/structured-exchange/request-review.ts b/src/projections/exchanges/request-review.ts similarity index 100% rename from src/projections/structured-exchange/request-review.ts rename to src/projections/exchanges/request-review.ts diff --git a/src/projections/structured-exchange/review-set-payload.ts b/src/projections/exchanges/review-set-payload.ts similarity index 100% rename from src/projections/structured-exchange/review-set-payload.ts rename to src/projections/exchanges/review-set-payload.ts diff --git a/src/projections/graph/neighborhood.ts b/src/projections/graph/neighborhood.ts index 3ee42219c..33c5b7b4f 100644 --- a/src/projections/graph/neighborhood.ts +++ b/src/projections/graph/neighborhood.ts @@ -1,8 +1,8 @@ /** - * Canonical projection for selected-spec node neighborhood snapshots. + * Canonical projection for selected-spec node neighborhood context. * * Input: - * - NeighborhoodResult from graph/snapshot.ts + * - NeighborhoodResult from graph/queries.ts * * Output: * - compact typed shape for anchor, neighbors, and connecting edges @@ -13,9 +13,9 @@ * - .pi/extensions/graph/index.ts via read_graph neighborhood results */ +import type { NeighborhoodResult } from '../../graph/queries.js'; import { formatGraphNodeCode } from '../../graph/schema/nodes.js'; import type { GraphNode } from '../../graph/schema/nodes.js'; -import type { NeighborhoodResult } from '../../graph/snapshot.js'; export interface ProjectNeighborhoodOptions { readonly maxNeighbors?: number; @@ -83,7 +83,7 @@ export function projectNeighborhood( const rationale = edge.rationale ? ` — ${truncate(edge.rationale, 100)}` : ''; const source = nodesById.get(edge.sourceId); const target = nodesById.get(edge.targetId); - return `#${edge.id}: ${formatEdgeEndpoint(edge.sourceId, source)} -[${edge.category}${stance}]-> ${formatEdgeEndpoint(edge.targetId, target)}${rationale}`; + return `${formatEdgeEndpoint(edge.sourceId, source)} -[${edge.category}${stance}]-> ${formatEdgeEndpoint(edge.targetId, target)}${rationale}`; }), omittedCount: Math.max(0, result.edges.length - maxEdges), }, diff --git a/src/projections/graph/overview.ts b/src/projections/graph/overview.ts index 217d446d9..15ebb79e9 100644 --- a/src/projections/graph/overview.ts +++ b/src/projections/graph/overview.ts @@ -1,8 +1,8 @@ /** - * Canonical projection for selected-spec graph overview snapshots. + * Canonical projection for selected-spec graph overview context. * * Input: - * - GraphOverview from graph/snapshot.ts + * - GraphOverview from graph/queries.ts * * Output: * - compact typed shape for LLM-facing formatting @@ -11,7 +11,7 @@ * Used by: * - renderers/graph/overview.ts * - .pi/extensions/graph/index.ts via graph overview tool results - * - .pi/extensions/prompting.ts via pushed graph snapshot context + * - .pi/extensions/prompting.ts via pushed graph context */ export {}; diff --git a/src/projections/graph/reconciliation-needs.ts b/src/projections/graph/reconciliation-needs.ts index 16fd2ef26..f1c9295f1 100644 --- a/src/projections/graph/reconciliation-needs.ts +++ b/src/projections/graph/reconciliation-needs.ts @@ -1,8 +1,8 @@ /** - * Canonical projection for open reconciliation-need snapshots. + * Canonical projection for open reconciliation-need context. * * Input: - * - ReconciliationNeed[] from graph/snapshot.ts + * - ReconciliationNeed[] from graph/queries.ts * * Output: * - compact typed shape grouped and ordered for LLM inspection @@ -10,7 +10,7 @@ * * Future users: * - renderers/graph/reconciliation-needs.ts - * - pushed prompt snapshots and/or future read tools + * - pushed prompt context and/or future read tools */ export {}; diff --git a/src/projections/session/runtime-policy.ts b/src/projections/session/runtime-policy.ts index d95d5bc30..15dc9dbf6 100644 --- a/src/projections/session/runtime-policy.ts +++ b/src/projections/session/runtime-policy.ts @@ -64,6 +64,7 @@ export const AGENT_ROLE_DEFINITIONS: Record = operationalMode: 'elicit', defaultStrategy: 'auto', allowedStrategies: [ + 'freestyle', 'step-wise-decision-tree', 'step-wise-disambiguate', 'propose-graph', diff --git a/src/projections/session/runtime-state.ts b/src/projections/session/runtime-state.ts index 4e61b9613..f0f94fcf9 100644 --- a/src/projections/session/runtime-state.ts +++ b/src/projections/session/runtime-state.ts @@ -118,7 +118,7 @@ function projectMentions(entries: readonly FileEntry[]): RuntimeStateProjection[ if (id) { const handle = stringField(data.handle); const title = stringField(data.title); - const seenLsn = integerField(data.snapshottedLsn); + const seenLsn = integerField(data.seenLsn); graphNodes.push({ id, ...(handle === undefined ? {} : { handle }), diff --git a/src/projections/structured-exchange/capture-answer.ts b/src/projections/structured-exchange/capture-answer.ts deleted file mode 100644 index a4c21c87d..000000000 --- a/src/projections/structured-exchange/capture-answer.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Canonical projection for `capture_answer` analysis content. - * - * Input: - * - capture-answer details once the tool lands - * - * Output: - * - normalized captured semantic summary - * - * Future users: - * - renderers/structured-exchange/capture-answer.ts - */ - -export {}; diff --git a/src/projections/structured-exchange/capture-candidate.ts b/src/projections/structured-exchange/capture-candidate.ts deleted file mode 100644 index c0ff375c4..000000000 --- a/src/projections/structured-exchange/capture-candidate.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Canonical projection for `capture_candidate` analysis content. - * - * Input: - * - capture-candidate details once the tool lands - * - * Output: - * - normalized captured semantic summary - * - * Future users: - * - renderers/structured-exchange/capture-candidate.ts - */ - -export {}; diff --git a/src/projections/structured-exchange/capture-choice.ts b/src/projections/structured-exchange/capture-choice.ts deleted file mode 100644 index 5b47bd788..000000000 --- a/src/projections/structured-exchange/capture-choice.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Canonical projection for `capture_choice` analysis content. - * - * Input: - * - capture-choice details once the tool lands - * - * Output: - * - normalized captured semantic summary - * - * Future users: - * - renderers/structured-exchange/capture-choice.ts - */ - -export {}; diff --git a/src/projections/structured-exchange/capture-choices.ts b/src/projections/structured-exchange/capture-choices.ts deleted file mode 100644 index 3cefb07e3..000000000 --- a/src/projections/structured-exchange/capture-choices.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Canonical projection for `capture_choices` analysis content. - * - * Input: - * - capture-choices details once the tool lands - * - * Output: - * - normalized captured semantic summary - * - * Future users: - * - renderers/structured-exchange/capture-choices.ts - */ - -export {}; diff --git a/src/projections/structured-exchange/capture-review.ts b/src/projections/structured-exchange/capture-review.ts deleted file mode 100644 index e62ba841a..000000000 --- a/src/projections/structured-exchange/capture-review.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Canonical projection for `capture_review` analysis content. - * - * Input: - * - capture-review details once the tool lands - * - * Output: - * - normalized captured semantic summary - * - * Future users: - * - renderers/structured-exchange/capture-review.ts - */ - -export {}; diff --git a/src/projections/topology-boundaries.test.ts b/src/projections/topology-boundaries.test.ts index a654aa975..bd4437205 100644 --- a/src/projections/topology-boundaries.test.ts +++ b/src/projections/topology-boundaries.test.ts @@ -9,13 +9,13 @@ const PROJECTIONS_ROOT = 'src/projections'; const RENDERERS_ROOT = 'src/renderers'; const ADAPTER_IMPORT_SEGMENTS = ['/.pi/', '/rpc/', '/app/', '/web/']; const PROJECTION_ADAPTER_EXCEPTIONS: Record = { - 'src/projections/structured-exchange/present-options.ts': true, - 'src/projections/structured-exchange/present-question.ts': true, - 'src/projections/structured-exchange/present-review-set.ts': true, - 'src/projections/structured-exchange/request-answer.ts': true, - 'src/projections/structured-exchange/request-choice.ts': true, - 'src/projections/structured-exchange/request-choices.ts': true, - 'src/projections/structured-exchange/request-review.ts': true, + 'src/projections/exchanges/present-options.ts': true, + 'src/projections/exchanges/present-question.ts': true, + 'src/projections/exchanges/present-review-set.ts': true, + 'src/projections/exchanges/request-answer.ts': true, + 'src/projections/exchanges/request-choice.ts': true, + 'src/projections/exchanges/request-choices.ts': true, + 'src/projections/exchanges/request-review.ts': true, }; function sourceFilesUnder(path: string): string[] { diff --git a/src/projections/workspace/workspace-context.ts b/src/projections/workspace/workspace-context.ts new file mode 100644 index 000000000..b34715867 --- /dev/null +++ b/src/projections/workspace/workspace-context.ts @@ -0,0 +1,25 @@ +import type { WorkspaceCwdInventory, WorkspaceOverview } from '../../session/workspace-context.js'; + +export type WorkspaceContextProjection = + | { + readonly mode: 'cwd_inventory'; + readonly data: WorkspaceCwdInventory; + } + | { + readonly mode: 'workspace_overview'; + readonly data: WorkspaceOverview; + }; + +export function projectWorkspaceCwdContext(data: WorkspaceCwdInventory): WorkspaceContextProjection { + return { + mode: 'cwd_inventory', + data, + }; +} + +export function projectWorkspaceOverviewContext(data: WorkspaceOverview): WorkspaceContextProjection { + return { + mode: 'workspace_overview', + data, + }; +} diff --git a/src/projections/workspace/workspace-snapshot.ts b/src/projections/workspace/workspace-state.ts similarity index 86% rename from src/projections/workspace/workspace-snapshot.ts rename to src/projections/workspace/workspace-state.ts index 00b0e030f..aa1508fd6 100644 --- a/src/projections/workspace/workspace-snapshot.ts +++ b/src/projections/workspace/workspace-state.ts @@ -1,6 +1,6 @@ import type { WorkspaceSessionState } from '../../session/workspace-session-coordinator.js'; -export interface WorkspaceSnapshot { +export interface WorkspaceState { status: WorkspaceSessionState['status']; cwd: string; spec: { @@ -18,7 +18,7 @@ export interface WorkspaceSnapshot { reason?: string; } -export function workspaceSnapshotFromState(state: WorkspaceSessionState): WorkspaceSnapshot { +export function projectWorkspaceState(state: WorkspaceSessionState): WorkspaceState { const base = { status: state.status, cwd: state.cwd, diff --git a/src/renderers/README.md b/src/renderers/README.md index 69778fe29..7fd6c458b 100644 --- a/src/renderers/README.md +++ b/src/renderers/README.md @@ -16,8 +16,8 @@ renderers/ toon.ts compact structured-data rendering stub graph/ graph overview/neighborhood/command markdown session/ transcript markdown - structured-exchange/ durable structured-exchange markdown - workspace/ print-mode workspace snapshot text + exchanges/ durable exchange markdown + workspace/ print-mode workspace state text ``` ## Dependency direction diff --git a/src/renderers/structured-exchange/present-candidates.ts b/src/renderers/exchanges/present-candidates.ts similarity index 69% rename from src/renderers/structured-exchange/present-candidates.ts rename to src/renderers/exchanges/present-candidates.ts index c1d24b510..5464ee5a4 100644 --- a/src/renderers/structured-exchange/present-candidates.ts +++ b/src/renderers/exchanges/present-candidates.ts @@ -2,7 +2,7 @@ * Formats projected `present_candidates` data into durable markdown. * * Input: - * - projected output from projections/structured-exchange/present-candidates.ts + * - projected output from projections/exchanges/present-candidates.ts * * Output: * - durable candidate-comparison markdown for toolResult.content diff --git a/src/renderers/structured-exchange/present-options.ts b/src/renderers/exchanges/present-options.ts similarity index 94% rename from src/renderers/structured-exchange/present-options.ts rename to src/renderers/exchanges/present-options.ts index 338fd5616..882a18e20 100644 --- a/src/renderers/structured-exchange/present-options.ts +++ b/src/renderers/exchanges/present-options.ts @@ -1,4 +1,4 @@ -import type { PresentOptionsProjection } from '../../projections/structured-exchange/present-options.js'; +import type { PresentOptionsProjection } from '../../projections/exchanges/present-options.js'; function markdownEscape(text: string): string { return text.replace(/([\\`*_{}[\]()#+\-.!|>])/g, '\\$1'); diff --git a/src/renderers/structured-exchange/present-question.ts b/src/renderers/exchanges/present-question.ts similarity index 79% rename from src/renderers/structured-exchange/present-question.ts rename to src/renderers/exchanges/present-question.ts index e80d23b18..93f06a338 100644 --- a/src/renderers/structured-exchange/present-question.ts +++ b/src/renderers/exchanges/present-question.ts @@ -2,13 +2,13 @@ * Formats projected `present_question` data into durable markdown. * * Input: - * - projected output from projections/structured-exchange/present-question.ts + * - projected output from projections/exchanges/present-question.ts * * Output: * - durable prompt-side markdown for toolResult.content */ -import type { PresentQuestionProjection } from '../../projections/structured-exchange/present-question.js'; +import type { PresentQuestionProjection } from '../../projections/exchanges/present-question.js'; import { joinMarkdownBlocks, markdownHeading } from '../markdown.js'; export function formatPresentQuestion(projection: PresentQuestionProjection): string { diff --git a/src/renderers/structured-exchange/present-review-set.ts b/src/renderers/exchanges/present-review-set.ts similarity index 96% rename from src/renderers/structured-exchange/present-review-set.ts rename to src/renderers/exchanges/present-review-set.ts index 73eb5dbef..78c36226b 100644 --- a/src/renderers/structured-exchange/present-review-set.ts +++ b/src/renderers/exchanges/present-review-set.ts @@ -1,4 +1,4 @@ -import type { PresentReviewSetProjection } from '../../projections/structured-exchange/present-review-set.js'; +import type { PresentReviewSetProjection } from '../../projections/exchanges/present-review-set.js'; export function formatPresentReviewSet(projection: PresentReviewSetProjection): string { const payload = projection.payload; diff --git a/src/renderers/structured-exchange/request-answer.ts b/src/renderers/exchanges/request-answer.ts similarity index 76% rename from src/renderers/structured-exchange/request-answer.ts rename to src/renderers/exchanges/request-answer.ts index b0a9e24c9..37b4689be 100644 --- a/src/renderers/structured-exchange/request-answer.ts +++ b/src/renderers/exchanges/request-answer.ts @@ -1,4 +1,4 @@ -import type { RequestAnswerDetails } from '../../projections/structured-exchange/request-answer.js'; +import type { RequestAnswerDetails } from '../../projections/exchanges/request-answer.js'; export function formatRequestAnswer(details: RequestAnswerDetails): string { if ('cancelled' in details) return '### Response\n\n_User cancelled the request._'; diff --git a/src/renderers/structured-exchange/request-choice.ts b/src/renderers/exchanges/request-choice.ts similarity index 82% rename from src/renderers/structured-exchange/request-choice.ts rename to src/renderers/exchanges/request-choice.ts index ee139ac71..021f5c4f1 100644 --- a/src/renderers/structured-exchange/request-choice.ts +++ b/src/renderers/exchanges/request-choice.ts @@ -1,4 +1,4 @@ -import type { RequestChoiceDetails } from '../../projections/structured-exchange/request-choice.js'; +import type { RequestChoiceDetails } from '../../projections/exchanges/request-choice.js'; export function formatRequestChoice(details: RequestChoiceDetails): string { if ('cancelled' in details) return '### Response\n\n_User cancelled the request._'; diff --git a/src/renderers/structured-exchange/request-choices.ts b/src/renderers/exchanges/request-choices.ts similarity index 86% rename from src/renderers/structured-exchange/request-choices.ts rename to src/renderers/exchanges/request-choices.ts index 550da6211..fd1eedacd 100644 --- a/src/renderers/structured-exchange/request-choices.ts +++ b/src/renderers/exchanges/request-choices.ts @@ -1,4 +1,4 @@ -import type { RequestChoicesDetails } from '../../projections/structured-exchange/request-choices.js'; +import type { RequestChoicesDetails } from '../../projections/exchanges/request-choices.js'; function markdownEscape(text: string): string { return text.replace(/([\\`*_{}[\]()#+\-.!|>])/g, '\\$1'); diff --git a/src/renderers/structured-exchange/request-review.ts b/src/renderers/exchanges/request-review.ts similarity index 86% rename from src/renderers/structured-exchange/request-review.ts rename to src/renderers/exchanges/request-review.ts index 18ecb8392..d977c2ec5 100644 --- a/src/renderers/structured-exchange/request-review.ts +++ b/src/renderers/exchanges/request-review.ts @@ -1,4 +1,4 @@ -import type { RequestReviewDetails } from '../../projections/structured-exchange/request-review.js'; +import type { RequestReviewDetails } from '../../projections/exchanges/request-review.js'; export function formatRequestReview(details: RequestReviewDetails): string { if ('cancelled' in details) return '### Review decision\n\n_User cancelled the review request._'; diff --git a/src/renderers/graph/__previews__/neighborhood-code-health-R1.md b/src/renderers/graph/__previews__/neighborhood-code-health-R1.md new file mode 100644 index 000000000..9ae8afd36 --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-code-health-R1.md @@ -0,0 +1,9 @@ +[Selected-spec node context] +- anchor: [R1] intent/requirement: Stage 2 must compute three configuration spaces with the semantics defined in T20: M_current (satisfies constraints and current baseline, e… +- anchor body: Stage 2 must compute three configuration spaces with the semantics defined in T20: M_current (satisfies constraints and current baseline, excluding alternatives requiring locked-b… +- neighbors: + - [T4] intent/term: Three configuration spaces are defined: M_current (satisfies constraints and cu… + - [D11] intent/decision: Adopt the two-stage split with a hard schema boundary forbidding solver outputs from Stage 1. +- edges: + - R1 -[dependency]-> D11 + - R1 -[dependency]-> T4 diff --git a/src/renderers/graph/__previews__/neighborhood-code-health-R1.preview.md b/src/renderers/graph/__previews__/neighborhood-code-health-R1.preview.md new file mode 100644 index 000000000..beb98b2fa --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-code-health-R1.preview.md @@ -0,0 +1,9 @@ +[Selected-spec node context] +- anchor: [R1] intent/requirement: Stage 2 must compute three configuration spaces with the semantics defined in T20: M_current (satisfies constraints and current baseline, e… +- anchor body: Stage 2 must compute three configuration spaces with the semantics defined in T20: M_current (satisfies constraints and current baseline, excluding alternatives requiring locked-b… +- neighbors: + - [T4] intent/term: Three configuration spaces are defined: M_current (satisfies constraints and cu… + - [D11] intent/decision: Adopt the two-stage split with a hard schema boundary forbidding solver outputs from Stage 1. +- edges: + - R1 -[dependency]-> D11 + - R1 -[dependency]-> T4 \ No newline at end of file diff --git a/src/renderers/graph/neighborhood.test.ts b/src/renderers/graph/neighborhood.test.ts new file mode 100644 index 000000000..ea8b16d96 --- /dev/null +++ b/src/renderers/graph/neighborhood.test.ts @@ -0,0 +1,26 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test } from 'vitest'; + +import { renderNeighborhoodPreview } from '../../graph/render-preview.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PREVIEWS_DIR = resolve(HERE, '__previews__'); +const GOLDEN_PATH = resolve(PREVIEWS_DIR, 'neighborhood-code-health-R1.md'); + +test('locks graph neighborhood preview for code-health R1 and preserves projected invariants', async () => { + const rendered = renderNeighborhoodPreview({ + set: 'bilal-port', + fixture: 'code-health', + anchorCode: 'R1', + }); + const locked = rendered.endsWith('\n') ? rendered : `${rendered}\n`; + + mkdirSync(PREVIEWS_DIR, { recursive: true }); + await expect(locked).toMatchFileSnapshot(GOLDEN_PATH); + expect(rendered).toContain('anchor: [R1] intent/requirement:'); + expect(rendered).not.toContain('#'); + expect(rendered).toContain('R1 -[dependency]-> D11'); +}); diff --git a/src/renderers/graph/neighborhood.ts b/src/renderers/graph/neighborhood.ts index b12280165..3f9843cab 100644 --- a/src/renderers/graph/neighborhood.ts +++ b/src/renderers/graph/neighborhood.ts @@ -1,5 +1,5 @@ /** - * Formats projected node neighborhood snapshots into model-facing text. + * Formats projected node neighborhood context into model-facing text. * * Input: * - projected output from projections/graph/neighborhood.ts diff --git a/src/renderers/graph/overview.ts b/src/renderers/graph/overview.ts index 5ae6baa04..8dd433a86 100644 --- a/src/renderers/graph/overview.ts +++ b/src/renderers/graph/overview.ts @@ -1,5 +1,5 @@ /** - * Formats projected graph overview snapshots into model-facing text. + * Formats projected graph overview context into model-facing text. * * Input: * - projected output from projections/graph/overview.ts diff --git a/src/renderers/graph/reconciliation-needs.ts b/src/renderers/graph/reconciliation-needs.ts index 4a90e6a54..16f6f987f 100644 --- a/src/renderers/graph/reconciliation-needs.ts +++ b/src/renderers/graph/reconciliation-needs.ts @@ -1,5 +1,5 @@ /** - * Formats projected reconciliation-need snapshots into model-facing text. + * Formats projected reconciliation-need context into model-facing text. * * Input: * - projected output from projections/graph/reconciliation-needs.ts @@ -8,7 +8,7 @@ * - markdown-framed TOON or equivalent compact text for LLM consumption * * Future users: - * - pushed prompt snapshots + * - pushed prompt context * - future graph read surfaces covering reconciliation work */ diff --git a/src/renderers/markdown.ts b/src/renderers/markdown.ts index a164c6077..4791359bd 100644 --- a/src/renderers/markdown.ts +++ b/src/renderers/markdown.ts @@ -9,7 +9,7 @@ * Future callers: * - renderers/graph/* * - renderers/session/* - * - renderers/structured-exchange/* + * - renderers/exchanges/* */ export function markdownHeading(level: number, text: string): string { diff --git a/src/renderers/session/__previews__/runtime-frame-ready.md b/src/renderers/session/__previews__/runtime-frame-ready.md new file mode 100644 index 000000000..6ab6b107f --- /dev/null +++ b/src/renderers/session/__previews__/runtime-frame-ready.md @@ -0,0 +1,8 @@ +[Selected session runtime frame] +- status: ready +- binding: spec #1; session session-1 +- agent: mode=elicit; role=elicitor; strategy=project-graph; lens=oracle; goal=commit-converge +- graph mentions: #D12 Decision seam @lsn 7 +- file mentions: src/session/runtime-state.ts @git abc123 +- world: graph_lsn=12; git_head=def456 +- lifecycle: spec_origin=existing; session_origin=resumed; session_index=10; first=no; tenth=yes diff --git a/src/renderers/session/runtime-frame.test.ts b/src/renderers/session/runtime-frame.test.ts new file mode 100644 index 000000000..b667ceb23 --- /dev/null +++ b/src/renderers/session/runtime-frame.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { RuntimeStateProjection } from '../../projections/session/runtime-state.js'; +import { renderRuntimeFrame } from './runtime-frame.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PREVIEWS_DIR = resolve(HERE, '__previews__'); +const GOLDEN_PATH = resolve(PREVIEWS_DIR, 'runtime-frame-ready.md'); + +function readyProjection(): RuntimeStateProjection { + return { + status: 'ready', + specId: 1, + sessionId: 'session-1', + 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, + }, + }; +} + +describe('renderRuntimeFrame', () => { + it('locks the ready runtime-frame preview and renders projected graph handles', async () => { + const rendered = renderRuntimeFrame(readyProjection()); + const locked = rendered.endsWith('\n') ? rendered : `${rendered}\n`; + + mkdirSync(PREVIEWS_DIR, { recursive: true }); + await expect(locked).toMatchFileSnapshot(GOLDEN_PATH); + expect(rendered).toContain('#D12'); + expect(rendered).not.toContain('node-1'); + expect(rendered).toContain( + 'mode=elicit; role=elicitor; strategy=project-graph; lens=oracle; goal=commit-converge', + ); + }); + + it('renders not-ready state without throwing', () => { + expect( + renderRuntimeFrame({ status: 'not_ready', reason: 'missing_binding', sessionId: 'session-1' }), + ).toContain('status: not_ready'); + }); +}); diff --git a/src/renderers/session/runtime-frame.ts b/src/renderers/session/runtime-frame.ts new file mode 100644 index 000000000..bfe44e836 --- /dev/null +++ b/src/renderers/session/runtime-frame.ts @@ -0,0 +1,58 @@ +import type { RuntimeStateProjection } from '../../projections/session/runtime-state.js'; + +export type SessionRuntimeFrameRenderInput = + | RuntimeStateProjection + | { + status: 'not_ready'; + reason: 'missing_session_header' | 'missing_binding' | 'non_linear'; + sessionId: string | null; + }; + +export function renderRuntimeFrame(input: SessionRuntimeFrameRenderInput): string { + if (input.status === 'not_ready') { + return [ + '[Selected session runtime frame]', + '- status: not_ready', + `- reason: ${input.reason}`, + `- session: ${input.sessionId ?? 'unrecorded'}`, + ].join('\n'); + } + + const lines = [ + '[Selected session runtime frame]', + '- status: ready', + `- binding: spec #${input.specId}; session ${input.sessionId}`, + `- agent: mode=${input.agent.operationalMode}; role=${input.agent.role}; strategy=${input.agent.strategy}; lens=${input.agent.lens}; goal=${input.agent.goal}`, + `- graph mentions: ${renderGraphMentions(input.mentions.graphNodes)}`, + `- file mentions: ${renderFileMentions(input.mentions.files)}`, + `- world: graph_lsn=${input.world.graph.latestLsn ?? 'unknown'}; git_head=${input.world.git.head ?? 'unknown'}`, + `- lifecycle: spec_origin=${input.lifecycle.specOrigin ?? 'unknown'}; session_origin=${input.lifecycle.sessionOrigin ?? 'unknown'}; session_index=${input.lifecycle.sessionIndexInSpec ?? 'unknown'}; first=${renderBoolean(input.lifecycle.isFirstSessionForSpec)}; tenth=${renderBoolean(input.lifecycle.isTenthSessionForSpec)}`, + ]; + + return lines.join('\n'); +} + +function renderGraphMentions(graphNodes: RuntimeStateProjection['mentions']['graphNodes']): string { + if (graphNodes.length === 0) return 'none'; + + return graphNodes + .map((mention) => { + const code = mention.handle ? `#${mention.handle}` : '(unprojected mention)'; + const title = mention.title ? ` ${mention.title}` : ''; + const seen = mention.seenLsn !== undefined ? ` @lsn ${mention.seenLsn}` : ''; + return `${code}${title}${seen}`; + }) + .join(', '); +} + +function renderFileMentions(files: RuntimeStateProjection['mentions']['files']): string { + if (files.length === 0) return 'none'; + + return files + .map((file) => (file.seenGitHead ? `${file.path} @git ${file.seenGitHead}` : file.path)) + .join(', '); +} + +function renderBoolean(value: boolean | null): string { + return value === null ? 'unknown' : value ? 'yes' : 'no'; +} diff --git a/src/renderers/structured-exchange/capture-answer.ts b/src/renderers/structured-exchange/capture-answer.ts deleted file mode 100644 index 57038232b..000000000 --- a/src/renderers/structured-exchange/capture-answer.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `capture_answer` analysis into durable markdown. - * - * Input: - * - projected output from projections/structured-exchange/capture-answer.ts - * - * Output: - * - capture-side markdown for toolResult.content - */ - -export {}; diff --git a/src/renderers/structured-exchange/capture-candidate.ts b/src/renderers/structured-exchange/capture-candidate.ts deleted file mode 100644 index 2c4da00e3..000000000 --- a/src/renderers/structured-exchange/capture-candidate.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `capture_candidate` analysis into durable markdown. - * - * Input: - * - projected output from projections/structured-exchange/capture-candidate.ts - * - * Output: - * - capture-side markdown for toolResult.content - */ - -export {}; diff --git a/src/renderers/structured-exchange/capture-choice.ts b/src/renderers/structured-exchange/capture-choice.ts deleted file mode 100644 index c1bc0a246..000000000 --- a/src/renderers/structured-exchange/capture-choice.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `capture_choice` analysis into durable markdown. - * - * Input: - * - projected output from projections/structured-exchange/capture-choice.ts - * - * Output: - * - capture-side markdown for toolResult.content - */ - -export {}; diff --git a/src/renderers/structured-exchange/capture-choices.ts b/src/renderers/structured-exchange/capture-choices.ts deleted file mode 100644 index 0eb763cb7..000000000 --- a/src/renderers/structured-exchange/capture-choices.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `capture_choices` analysis into durable markdown. - * - * Input: - * - projected output from projections/structured-exchange/capture-choices.ts - * - * Output: - * - capture-side markdown for toolResult.content - */ - -export {}; diff --git a/src/renderers/structured-exchange/capture-review.ts b/src/renderers/structured-exchange/capture-review.ts deleted file mode 100644 index 79cdfbc9b..000000000 --- a/src/renderers/structured-exchange/capture-review.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Formats projected `capture_review` analysis into durable markdown. - * - * Input: - * - projected output from projections/structured-exchange/capture-review.ts - * - * Output: - * - capture-side markdown for toolResult.content - */ - -export {}; diff --git a/src/renderers/toon.ts b/src/renderers/toon.ts index 3a711c68c..20e836c89 100644 --- a/src/renderers/toon.ts +++ b/src/renderers/toon.ts @@ -1,5 +1,5 @@ /** - * Shared TOON formatting substrate for Brunch LLM-facing structured snapshots. + * Shared TOON formatting substrate for Brunch LLM-facing structured context data. * * Owns: * - thin wrapper helpers around @toon-format/toon @@ -8,7 +8,7 @@ * * Future callers: * - renderers/graph/* - * - any later snapshot formatter that needs compact structured data + * - any later context formatter that needs compact structured data */ export {}; diff --git a/src/renderers/workspace/workspace-context.ts b/src/renderers/workspace/workspace-context.ts new file mode 100644 index 000000000..d428726f9 --- /dev/null +++ b/src/renderers/workspace/workspace-context.ts @@ -0,0 +1,79 @@ +import type { WorkspaceContextProjection } from '../../projections/workspace/workspace-context.js'; + +export function renderWorkspaceContext(context: WorkspaceContextProjection): string { + if (context.mode === 'workspace_overview') { + return renderWorkspaceOverview(context); + } + + return renderWorkspaceCwd(context); +} + +function renderWorkspaceCwd( + context: Extract, +): string { + const { data: inventory } = context; + const lines = [ + '[Workspace cwd inventory]', + `- cwd: ${inventory.cwd}`, + `- workspace: ${inventory.hasBrunchDir ? 'existing .brunch state detected' : 'fresh workspace (no .brunch directory)'}`, + `- session files: ${inventory.sessionFiles.length}`, + ]; + + if (inventory.sessionFiles.length > 0) { + lines.push('- session lengths:'); + for (const session of inventory.sessionFiles) { + lines.push(` - ${session.file}: ${session.lineCount} lines, ${session.byteCount} bytes`); + } + } + + lines.push('- top-level tree:'); + for (const entry of inventory.topLevelEntries) { + const suffix = entry.kind === 'directory' ? '/' : ''; + lines.push(` - ${entry.name}${suffix}: ${entry.fileCount} file(s)`); + } + + if (inventory.markdownFiles.length === 0) { + lines.push('- markdown files: none'); + } else { + lines.push('- markdown files:'); + for (const file of inventory.markdownFiles) { + lines.push(` - ${file.path}: ${file.lineCount} lines, ${file.byteCount} bytes`); + } + } + + return `${lines.join('\n')}\n`; +} + +function renderWorkspaceOverview( + context: Extract, +): string { + const { data: overview } = context; + const lines = [ + '[Workspace overview]', + `- cwd: ${overview.cwd}`, + `- specs: ${overview.specs.length}`, + `- sessions: ${overview.sessions.length}`, + ]; + + if (overview.specs.length > 0) { + lines.push('- spec inventory:'); + for (const spec of overview.specs) { + lines.push( + ` - ${spec.title} (#${spec.id}): ${spec.nodeCount} node(s), ${spec.sessionCount} session(s)`, + ); + } + } + + if (overview.sessions.length === 0) { + lines.push('- session inventory: none'); + } else { + lines.push('- session inventory:'); + for (const session of overview.sessions) { + lines.push( + ` - ${session.file} (${session.id}) → ${session.specTitle} (#${session.specId}), ${session.turnCount} turn(s), readiness_grade=${session.readinessGrade}`, + ); + } + } + + return `${lines.join('\n')}\n`; +} diff --git a/src/renderers/workspace/workspace-snapshot.ts b/src/renderers/workspace/workspace-snapshot.ts deleted file mode 100644 index 7854d159a..000000000 --- a/src/renderers/workspace/workspace-snapshot.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; - -export function renderWorkspaceSnapshot(snapshot: WorkspaceSnapshot): string { - const lines = [ - 'Brunch workspace snapshot', - `status: ${snapshot.status}`, - `cwd: ${snapshot.cwd}`, - `spec: ${snapshot.spec ? `${snapshot.spec.title} (${snapshot.spec.id})` : ''}`, - `phase: ${snapshot.chrome.phase}`, - `chatMode: ${snapshot.chrome.chatMode}`, - ]; - - if (snapshot.session) { - lines.push(`session: ${snapshot.session.id}`, `sessionFile: ${snapshot.session.file}`); - } - if (snapshot.reason) { - lines.push(`reason: ${snapshot.reason}`); - } - - return `${lines.join('\n')}\n`; -} diff --git a/src/renderers/workspace/workspace-snapshot.test.ts b/src/renderers/workspace/workspace-state.test.ts similarity index 59% rename from src/renderers/workspace/workspace-snapshot.test.ts rename to src/renderers/workspace/workspace-state.test.ts index 78ce254d3..8c34a67f0 100644 --- a/src/renderers/workspace/workspace-snapshot.test.ts +++ b/src/renderers/workspace/workspace-state.test.ts @@ -1,9 +1,9 @@ import type { SessionManager } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; -import { workspaceSnapshotFromState } from '../../projections/workspace/workspace-snapshot.js'; +import { projectWorkspaceState } from '../../projections/workspace/workspace-state.js'; import type { WorkspaceSessionState } from '../../session/workspace-session-coordinator.js'; -import { renderWorkspaceSnapshot } from './workspace-snapshot.js'; +import { renderWorkspaceState } from './workspace-state.js'; const cwd = '/tmp/brunch-project'; @@ -26,11 +26,11 @@ function readyState(): WorkspaceSessionState { }; } -describe('print snapshot', () => { +describe('print state', () => { it('projects and renders a ready workspace without exposing pi internals', () => { - const snapshot = workspaceSnapshotFromState(readyState()); + const state = projectWorkspaceState(readyState()); - expect(snapshot).toEqual({ + expect(state).toEqual({ status: 'ready', cwd, spec: { id: 1, title: 'Alpha spec' }, @@ -43,13 +43,14 @@ describe('print snapshot', () => { chatMode: 'responding-to-elicitation', }, }); - expect(renderWorkspaceSnapshot(snapshot)).toContain('status: ready'); - expect(renderWorkspaceSnapshot(snapshot)).toContain('spec: Alpha spec (1)'); - expect(renderWorkspaceSnapshot(snapshot)).toContain('session: session-1'); + expect(renderWorkspaceState(state)).toContain('Brunch workspace state'); + expect(renderWorkspaceState(state)).toContain('status: ready'); + expect(renderWorkspaceState(state)).toContain('spec: Alpha spec (1)'); + expect(renderWorkspaceState(state)).toContain('session: session-1'); }); - it('renders select-spec as a snapshot instead of prompting', () => { - const snapshot = workspaceSnapshotFromState({ + it('renders select-spec as state instead of prompting', () => { + const state = projectWorkspaceState({ status: 'select_spec', cwd, chrome: { @@ -60,8 +61,8 @@ describe('print snapshot', () => { }, }); - expect(renderWorkspaceSnapshot(snapshot)).toContain('status: select_spec'); - expect(renderWorkspaceSnapshot(snapshot)).toContain('spec: '); - expect(renderWorkspaceSnapshot(snapshot)).not.toContain('session:'); + expect(renderWorkspaceState(state)).toContain('status: select_spec'); + expect(renderWorkspaceState(state)).toContain('spec: '); + expect(renderWorkspaceState(state)).not.toContain('session:'); }); }); diff --git a/src/renderers/workspace/workspace-state.ts b/src/renderers/workspace/workspace-state.ts new file mode 100644 index 000000000..b01802c4a --- /dev/null +++ b/src/renderers/workspace/workspace-state.ts @@ -0,0 +1,21 @@ +import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; + +export function renderWorkspaceState(state: WorkspaceState): string { + const lines = [ + 'Brunch workspace state', + `status: ${state.status}`, + `cwd: ${state.cwd}`, + `spec: ${state.spec ? `${state.spec.title} (${state.spec.id})` : ''}`, + `phase: ${state.chrome.phase}`, + `chatMode: ${state.chrome.chatMode}`, + ]; + + if (state.session) { + lines.push(`session: ${state.session.id}`, `sessionFile: ${state.session.file}`); + } + if (state.reason) { + lines.push(`reason: ${state.reason}`); + } + + return `${lines.join('\n')}\n`; +} diff --git a/src/rpc/README.md b/src/rpc/README.md index cd2faf15a..93aeddadb 100644 --- a/src/rpc/README.md +++ b/src/rpc/README.md @@ -72,7 +72,7 @@ rpc/ full RPC host: reads: rpc.discover - workspace.snapshot + workspace.state workspace.selectionState session.pendingExchange session.exchanges @@ -83,6 +83,7 @@ full RPC host: workspace.activate session.triggerExchange session.submitExchangeResponse + session.submitMessage dev-enabled full RPC host only: writes: @@ -96,7 +97,7 @@ dev-enabled full RPC host only: TUI-started web sidecar: reads: rpc.discover - workspace.snapshot + workspace.state workspace.selectionState session.pendingExchange session.exchanges @@ -107,6 +108,7 @@ TUI-started web sidecar: workspace.activate session.triggerExchange session.submitExchangeResponse + session.submitMessage ``` ## Method overview @@ -118,7 +120,7 @@ rpc.discover result: supported methods with descriptions, schemas, and examples source: active method registry -workspace.snapshot +workspace.state access: read params: none result: cwd-scoped workspace product state @@ -138,7 +140,7 @@ workspace.activate access: write params: {decision} continue | openSession | newSession | newSpec | cancel - result: workspace snapshot or cancelled activation state + result: workspace state or cancelled activation state effects: creates/opens selected spec/session and publishes selected-session invalidations session.pendingExchange @@ -183,6 +185,18 @@ session.submitExchangeResponse | structural_illegal(diagnostics) effects: appends request_* toolResult response, publishes selected-session invalidations, and when captured or approved publishes graph.overview / graph.nodeNeighborhood invalidations for the transcript-bound spec +session.submitMessage + access: write + params: + text + interruption? + result: accepted ordinary user message plus capture outcome + capture: + captured(lsn, nodeCount, createdNodes) + | no_capture(reason) + | structural_illegal(diagnostics) + effects: appends a user message to the selected session transcript, rejects ordinary text while a structured exchange is pending unless interruption=true, and when captured publishes graph.overview / graph.nodeNeighborhood invalidations for the transcript-bound spec + graph.overview access: read params: {specId} @@ -222,7 +236,7 @@ dev.graph.commitGraph brunch.updated: params: topics: - - workspace.snapshot + - workspace.state - workspace.selectionState - session.pendingExchange - session.exchanges @@ -241,7 +255,7 @@ Current web code only uses the read sidecar. Write hooks are named here as the e ```pseudo query key families: - workspace.snapshot -> ['workspace.snapshot'] + workspace.state -> ['workspace.state'] 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 @@ -253,7 +267,7 @@ query key families: | 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.state` | `workspaceStateQueryOptions(rpc)` | implemented; root/spec loaders prime it | exact `workspace.state` | | `workspace.selectionState` | `workspaceSelectionStateQueryOptions(rpc)` | target; picker route not built | `workspace.selectionState` or activation success | | `workspace.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` | @@ -328,11 +342,9 @@ if session.pendingExchange returns pending: if no exchange is pending: session.triggerExchange may ask the agent for the next exchange - future session.submitMessage may append ordinary user text or an explicit interruption + session.submitMessage may append ordinary user text or an explicit interruption ``` -`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. @@ -372,9 +384,6 @@ command.* -> internal authority seam, not a browser RPC pr 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 diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 3db23ac65..124dced10 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'; import { openWorkspaceGraphRuntime } from '../graph/workspace-store.js'; import { assistantMessage, userMessage } from '../probes/test-helpers.js'; -import { projectPresentReviewSet } from '../projections/structured-exchange/present-review-set.js'; +import { projectPresentReviewSet } from '../projections/exchanges/present-review-set.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'; @@ -294,10 +294,11 @@ describe('JSON-RPC handlers', () => { 'session.pendingExchange', 'session.runtimeState', 'session.submitExchangeResponse', + 'session.submitMessage', 'session.triggerExchange', 'workspace.activate', 'workspace.selectionState', - 'workspace.snapshot', + 'workspace.state', ]); const discoveredNames = new Set(methods.map((entry) => entry.method)); @@ -442,6 +443,7 @@ describe('JSON-RPC handlers', () => { 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'); + const submitMessage = methods.find((entry) => entry.method === 'session.submitMessage'); const submit = methods.find((entry) => entry.method === 'session.submitExchangeResponse'); for (const entry of [exchanges, pending]) { @@ -472,6 +474,16 @@ describe('JSON-RPC handlers', () => { expect(JSON.stringify(submit.resultSchema.properties?.capture)).toContain('captured'); expect(JSON.stringify(submit.resultSchema.properties?.capture)).toContain('no_capture'); expect(JSON.stringify(submit.resultSchema.properties?.capture)).toContain('structural_illegal'); + + if (!submitMessage) throw new Error('expected discovered session.submitMessage method'); + expect(JSON.stringify(submitMessage.paramsSchema.properties?.text)).toContain('string'); + expect(JSON.stringify(submitMessage.paramsSchema.properties?.interruption)).toContain('boolean'); + expect(JSON.stringify(submitMessage.resultSchema.properties?.capture)).toContain('captured'); + expect(JSON.stringify(submitMessage.resultSchema.properties?.capture)).toContain('no_capture'); + expect(JSON.stringify(submitMessage.resultSchema.properties?.capture)).toContain('structural_illegal'); + for (const example of submitMessage.examples.filter((example) => example.params !== undefined)) { + expect(Value.Check(submitMessage.paramsSchema, example.params)).toBe(true); + } }); it('serves discovery examples that are valid JSON-RPC requests for advertised methods', async () => { @@ -565,14 +577,14 @@ describe('JSON-RPC handlers', () => { ).resolves.toMatchObject({ jsonrpc: '2.0', id: 35, result: { status: 'ready' } }); expect(observed).toEqual([ - { topic: 'workspace.snapshot', specId: 1, sessionId: 'session-1' }, + { topic: 'workspace.state', 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 () => { + it('activates valid spec/session decisions and returns serializable product state', async () => { const decisions: SpecSessionActivationDecision[] = []; const handlers = createRpcHandlers({ cwd: '/tmp/brunch-project', @@ -656,7 +668,7 @@ describe('JSON-RPC handlers', () => { expect(source).not.toContain('pi --mode rpc'); }); - it('serves a named workspace snapshot method', async () => { + it('serves the named workspace.state method', async () => { const handlers = createRpcHandlers({ coordinator: coordinator(), cwd: '/tmp/brunch-project', @@ -665,7 +677,7 @@ describe('JSON-RPC handlers', () => { const result = await handlers.handle({ jsonrpc: '2.0', id: 1, - method: 'workspace.snapshot', + method: 'workspace.state', }); expect(result).toMatchObject({ @@ -1510,6 +1522,192 @@ describe('JSON-RPC handlers', () => { }); }); + it('captures explicit labeled ordinary messages into the transcript-bound spec graph and publishes graph invalidations', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-message-capture-')); + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: 'Ordinary message capture spec', + }); + const graph = await openWorkspaceGraphRuntime(cwd); + const sibling = graph.commandExecutor.createSpec({ name: 'Sibling spec', slug: 'sibling-spec' }); + if (sibling.status !== 'success') throw new Error('failed to create sibling spec fixture'); + const productUpdates = createProductUpdatePublisher(); + const updates: unknown[] = []; + productUpdates.subscribe((batch) => updates.push(...batch)); + const handlers = createRpcHandlers({ + coordinator: coordinator({ + ...workspace, + spec: { id: sibling.specId, title: 'Sibling spec' }, + }), + cwd, + productUpdates, + }); + + const before = await readFile(workspace.session.file, 'utf8'); + const response = await handlers.handle({ + jsonrpc: '2.0', + id: 280, + method: 'session.submitMessage', + params: { + text: [ + 'Goal: Keep ordinary messages on the same selected-spec capture path.', + 'Context: Users may say this outside a structured exchange.', + 'Constraint: Do not silently answer pending structured requests.', + 'Criterion: Graph updates still publish selected-spec invalidations.', + ].join('\n'), + }, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 280, + result: { + status: 'accepted', + interruption: false, + capture: { + status: 'captured', + lsn: expect.any(Number), + nodeCount: 4, + createdNodes: { + goal: { code: 'G1' }, + context: { code: 'CTX1' }, + constraint: { code: 'CON1' }, + criterion: { code: 'CR1' }, + }, + }, + }, + }); + if (!('result' in response)) throw new Error('expected submitMessage response'); + const lsn = (response.result as { capture: { lsn: number } }).capture.lsn; + expect(updates).toContainEqual({ topic: 'graph.overview', specId: workspace.spec.id, lsn }); + expect(updates).toContainEqual({ topic: 'graph.nodeNeighborhood', specId: workspace.spec.id, lsn }); + + const overview = await handlers.handle({ + jsonrpc: '2.0', + id: 281, + method: 'graph.overview', + params: { specId: workspace.spec.id }, + }); + expect(overview).toMatchObject({ + result: { + nodeCount: 4, + nodes: expect.arrayContaining([ + expect.objectContaining({ + kind: 'goal', + basis: 'explicit', + source: expect.stringContaining('session_message:'), + }), + ]), + }, + }); + + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 282, + method: 'graph.overview', + params: { specId: sibling.specId }, + }), + ).resolves.toMatchObject({ result: { nodeCount: 0 } }); + const after = await readFile(workspace.session.file, 'utf8'); + expect(after.length).toBeGreaterThan(before.length); + expect(after).toContain('Keep ordinary messages on the same selected-spec capture path.'); + }); + + it('rejects ordinary messages while a structured exchange is pending unless they are explicit interruptions', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-message-pending-')); + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); + const workspace = await coordinatorInstance.createSetupSession({ + specTitle: 'Pending message guard spec', + }); + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }); + await handlers.handle({ + jsonrpc: '2.0', + id: 283, + method: 'session.triggerExchange', + }); + const before = await readFile(workspace.session.file, 'utf8'); + + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 284, + method: 'session.submitMessage', + params: { text: 'This should not answer the pending exchange.' }, + }), + ).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 284, + error: { + code: -32009, + message: + 'Pending structured exchange requires session.submitExchangeResponse unless this message is an explicit interruption', + }, + }); + await expect(readFile(workspace.session.file, 'utf8')).resolves.toBe(before); + }); + + it('records explicit interruptions transcript-visibly without answering the pending structured exchange', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-message-interruption-')); + const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); + const workspace = await coordinatorInstance.createSetupSession({ specTitle: 'Interruption spec' }); + const handlers = createRpcHandlers({ + coordinator: coordinatorInstance, + cwd, + }); + const pending = await handlers.handle({ + jsonrpc: '2.0', + id: 285, + method: 'session.triggerExchange', + }); + const exchangeId = ( + pending as { + result: { exchange: { exchangeId: string } }; + } + ).result.exchange.exchangeId; + + const response = await handlers.handle({ + jsonrpc: '2.0', + id: 286, + method: 'session.submitMessage', + params: { + text: 'Pause this question and let me clarify a broader constraint first.', + interruption: true, + }, + }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 286, + result: { + status: 'accepted', + interruption: true, + capture: { + status: 'no_capture', + reason: 'explicit interruptions are transcript-visible only and do not run synchronous capture', + }, + }, + }); + const sessionText = await readFile(workspace.session.file, 'utf8'); + expect(sessionText).toContain('Pause this question and let me clarify a broader constraint first.'); + expect(sessionText).toContain('brunch.session_interruption'); + await expect( + handlers.handle({ + jsonrpc: '2.0', + id: 287, + method: 'session.pendingExchange', + }), + ).resolves.toMatchObject({ + result: { + status: 'pending', + exchange: { exchangeId }, + }, + }); + }); + it('rejects mismatched elicitation response ids without appending transcript entries', async () => { const cwd = await mkdtemp(join(tmpdir(), 'brunch-rpc-respond-bad-id-')); const coordinatorInstance = createWorkspaceSessionCoordinator({ cwd }); @@ -2109,7 +2307,7 @@ describe('JSON-RPC handlers', () => { handlers.handle({ jsonrpc: '2.0', id: { bad: true }, - method: 'workspace.snapshot', + method: 'workspace.state', }), ).resolves.toMatchObject({ jsonrpc: '2.0', @@ -2527,7 +2725,7 @@ describe('JSON-RPC handlers', () => { }, }); - input.end(`${JSON.stringify({ jsonrpc: '2.0', id: 15, method: 'workspace.snapshot' })}\n`); + input.end(`${JSON.stringify({ jsonrpc: '2.0', id: 15, method: 'workspace.state' })}\n`); await done; expect(JSON.parse(chunks.join(''))).toEqual({ @@ -2552,7 +2750,7 @@ describe('JSON-RPC handlers', () => { }), }); - input.end(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'workspace.snapshot' })}\n`); + input.end(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'workspace.state' })}\n`); await done; expect(JSON.parse(chunks.join(''))).toMatchObject({ diff --git a/src/rpc/methods/registry.test.ts b/src/rpc/methods/registry.test.ts new file mode 100644 index 000000000..2a8777e4c --- /dev/null +++ b/src/rpc/methods/registry.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { createJsonRpcSuccess, type JsonRpcRequest, type JsonRpcResponse } from '../protocol.js'; +import { type RpcMethodDefinition, registryByMethod } from './registry.js'; + +function defineMethod(method: string): RpcMethodDefinition { + return { + method, + access: 'read', + description: `method ${method}`, + paramsSchema: {}, + resultSchema: {}, + examples: [], + handle: (_context: unknown, request: JsonRpcRequest): Promise => + Promise.resolve(createJsonRpcSuccess(request.id ?? null, null)), + }; +} + +describe('registryByMethod', () => { + it('indexes definitions by method name', () => { + const byMethod = registryByMethod([defineMethod('graph.read'), defineMethod('graph.commit')]); + expect([...byMethod.keys()]).toEqual(['graph.read', 'graph.commit']); + }); + + it('throws on duplicate method names instead of silently last-winning', () => { + expect(() => registryByMethod([defineMethod('graph.read'), defineMethod('graph.read')])).toThrow( + 'Duplicate RPC method definition: graph.read', + ); + }); +}); diff --git a/src/rpc/methods/registry.ts b/src/rpc/methods/registry.ts index 1f9e07545..65a583a57 100644 --- a/src/rpc/methods/registry.ts +++ b/src/rpc/methods/registry.ts @@ -53,5 +53,12 @@ export function discoverRpcMethods(registry: RpcMethodRegistry export function registryByMethod( registry: RpcMethodRegistry, ): ReadonlyMap> { - return new Map(registry.map((definition) => [definition.method, definition])); + const byMethod = new Map>(); + for (const definition of registry) { + if (byMethod.has(definition.method)) { + throw new Error(`Duplicate RPC method definition: ${definition.method}`); + } + byMethod.set(definition.method, definition); + } + return byMethod; } diff --git a/src/rpc/methods/session.ts b/src/rpc/methods/session.ts index 40df50191..7be7f3a17 100644 --- a/src/rpc/methods/session.ts +++ b/src/rpc/methods/session.ts @@ -1,11 +1,14 @@ import { Type, type Static } from 'typebox'; import { Value } from 'typebox/value'; -import { captureStructuredResponseFacts } from '../../graph/capture/structured-response.js'; +import { + captureExplicitTextFacts, + captureStructuredResponseFacts, +} from '../../graph/capture/structured-response.js'; import type { StructuredResponseCaptureOutcome } from '../../graph/capture/structured-response.js'; import type { WorkspaceGraphRuntime } from '../../graph/workspace-store.js'; +import { reviewSetProposalPayloadFromDetails } from '../../projections/exchanges/review-set-payload.js'; import { projectSessionRuntimeState } from '../../projections/session/runtime-state.js'; -import { reviewSetProposalPayloadFromDetails } from '../../projections/structured-exchange/review-set-payload.js'; import { readBrunchSessionEnvelope, NonLinearTranscriptError, @@ -89,6 +92,7 @@ const RuntimeStateResultSchema = Type.Object( role: Type.Literal('elicitor'), strategy: Type.Union([ Type.Literal('auto'), + Type.Literal('freestyle'), Type.Literal('step-wise-decision-tree'), Type.Literal('step-wise-disambiguate'), Type.Literal('propose-graph'), @@ -289,10 +293,33 @@ const ExchangeResponseResultSchema = Type.Object( { additionalProperties: false }, ); +const SubmitMessageParamsSchema = Type.Object( + { + text: NonBlankStringSchema, + interruption: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +const SubmitMessageResultSchema = Type.Object( + { + status: Type.Literal('accepted'), + messageId: NonBlankStringSchema, + text: NonBlankStringSchema, + interruption: Type.Boolean(), + capture: ExchangeResponseCaptureResultSchema, + }, + { additionalProperties: false }, +); + type ExchangeResponseParams = StructuredExchangeResponseInput; type ExchangeResponseResult = Omit, 'capture'> & { readonly capture: StructuredResponseCaptureOutcome; }; +type SubmitMessageParams = Static; +type SubmitMessageResult = Omit, 'capture'> & { + readonly capture: StructuredResponseCaptureOutcome; +}; export const sessionRpcMethods: readonly RpcMethodDefinition[] = [ { @@ -408,7 +435,29 @@ export const sessionRpcMethods: readonly RpcMethodDefinition[] return handleSubmitExchangeResponse(jsonRpcRequestId(request), request.params, context); }, }, + { + method: 'session.submitMessage', + access: 'write', + description: + 'Append an ordinary user message to the selected session and run synchronous explicit-text capture, or record an explicit interruption while a structured exchange is pending.', + paramsSchema: SubmitMessageParamsSchema, + resultSchema: SubmitMessageResultSchema, + examples: [ + { + jsonrpc: '2.0', + id: 12, + method: 'session.submitMessage', + params: { + text: 'Goal: Keep ordinary messages on the same selected-spec capture path.', + }, + }, + ], + async handle(context, request) { + return handleSubmitMessage(jsonRpcRequestId(request), request.params, context); + }, + }, ]; + async function handleSessionProjection( requestId: JsonRpcId, rawParams: unknown, @@ -488,6 +537,87 @@ async function handleTriggerExchange( return createJsonRpcSuccess(requestId, result); } +async function handleSubmitMessage( + requestId: JsonRpcId, + rawParams: unknown, + options: { + coordinator: DefaultWorkspaceCoordinator; + cwd: string; + productUpdates?: ProductUpdatePublisher; + getGraphRuntime: () => Promise; + }, +): Promise { + if (!Value.Check(SubmitMessageParamsSchema, rawParams)) { + return createJsonRpcFailure(requestId, -32602, 'Invalid params'); + } + const params = Value.Parse(SubmitMessageParamsSchema, rawParams) as SubmitMessageParams; + + 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 && params.interruption !== true) { + return createJsonRpcFailure( + requestId, + -32009, + 'Pending structured exchange requires session.submitExchangeResponse unless this message is an explicit interruption', + ); + } + + const messageId = + params.interruption === true + ? state.session.manager.appendCustomMessageEntry('brunch.session_interruption', params.text, true, { + interruption: true, + }) + : state.session.manager.appendMessage(ordinaryUserMessage(params.text)); + flushSessionEntries(state.session.manager, state.session.file); + + const capture = + params.interruption === true + ? ({ + status: 'no_capture', + reason: 'explicit interruptions are transcript-visible only and do not run synchronous capture', + } as const) + : captureExplicitTextFacts({ + specId: target.envelope.binding.specId, + text: params.text, + source: `session_message:${messageId}`, + commandExecutor: (await options.getGraphRuntime()).commandExecutor, + }); + + const result: SubmitMessageResult = { + status: 'accepted', + messageId, + text: params.text, + interruption: params.interruption === true, + capture, + }; + + publishSelectedSessionUpdates(options.productUpdates, state, target.envelope.binding.specId); + if (capture.status === 'captured') { + options.productUpdates?.publish( + graphMutationProductUpdates({ specId: target.envelope.binding.specId, lsn: capture.lsn }), + ); + } + return createJsonRpcSuccess(requestId, result); +} + async function handleSubmitExchangeResponse( requestId: JsonRpcId, rawParams: unknown, @@ -600,6 +730,14 @@ function flushSessionEntries(manager: unknown, sessionFile: string): void { flushable.setSessionFile(sessionFile); } +function ordinaryUserMessage(text: string) { + return { + role: 'user' as const, + content: text, + timestamp: 0 as const, + }; +} + type SessionProjectionParamsParseResult = | { ok: true; diff --git a/src/rpc/methods/workspace.ts b/src/rpc/methods/workspace.ts index 41cd47e80..a7306151a 100644 --- a/src/rpc/methods/workspace.ts +++ b/src/rpc/methods/workspace.ts @@ -1,7 +1,7 @@ import { Type, type Static } from 'typebox'; import { Value } from 'typebox/value'; -import { workspaceSnapshotFromState } from '../../projections/workspace/workspace-snapshot.js'; +import { projectWorkspaceState } from '../../projections/workspace/workspace-state.js'; import type { SpecSessionActivationDecision, WorkspaceActivationState, @@ -56,7 +56,7 @@ const WorkspaceActivationParamsSchema = Type.Object( type WorkspaceActivationParams = Static; -const WorkspaceSnapshotResultSchema = Type.Object( +const WorkspaceStateResultSchema = Type.Object( { status: Type.String(), cwd: Type.String(), @@ -98,20 +98,20 @@ const WorkspaceActivationResultSchema = Type.Union([ export const workspaceRpcMethods: readonly RpcMethodDefinition[] = [ { - method: 'workspace.snapshot', + method: 'workspace.state', access: 'read', description: - 'Return the current Brunch workspace/spec/session snapshot for the invocation cwd without changing activation state.', + 'Return the current Brunch workspace/spec/session state for the invocation cwd without changing activation state.', paramsSchema: NoParamsSchema, - resultSchema: WorkspaceSnapshotResultSchema, - examples: [{ jsonrpc: '2.0', id: 2, method: 'workspace.snapshot' }], + resultSchema: WorkspaceStateResultSchema, + examples: [{ jsonrpc: '2.0', id: 2, method: 'workspace.state' }], 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)); + return createJsonRpcSuccess(requestId, projectWorkspaceState(state)); }, }, { @@ -168,7 +168,7 @@ export const workspaceRpcMethods: readonly RpcMethodDefinition return createJsonRpcFailure(requestId, -32602, 'Invalid params'); } const state = await context.coordinator.activateWorkspace(decision.value); - const response = workspaceActivationSnapshotFromState(state); + const response = workspaceActivationResultFromState(state); if (context.productUpdates && state.status === 'ready') { context.productUpdates.publish( selectedSessionProductUpdates({ specId: state.spec.id, sessionId: state.session.id }), @@ -193,7 +193,7 @@ function workspaceSelectionStateFromInventory( }; } -function workspaceActivationSnapshotFromState(state: WorkspaceActivationState) { +function workspaceActivationResultFromState(state: WorkspaceActivationState) { if (state.status === 'cancelled') { return { status: 'cancelled' as const, @@ -206,7 +206,7 @@ function workspaceActivationSnapshotFromState(state: WorkspaceActivationState) { }; } - return workspaceSnapshotFromState(state); + return projectWorkspaceState(state); } type WorkspaceActivationParamsParseResult = diff --git a/src/rpc/product-updates.ts b/src/rpc/product-updates.ts index b4b284c00..2bfb06dcf 100644 --- a/src/rpc/product-updates.ts +++ b/src/rpc/product-updates.ts @@ -1,7 +1,7 @@ export const BRUNCH_UPDATED_METHOD = 'brunch.updated'; export type ProductUpdateTopic = - | 'workspace.snapshot' + | 'workspace.state' | 'workspace.selectionState' | 'session.pendingExchange' | 'session.exchanges' @@ -72,7 +72,7 @@ export function selectedSessionProductUpdates(target?: { readonly sessionId?: string; }): readonly ProductUpdate[] { return [ - productUpdate('workspace.snapshot', target), + productUpdate('workspace.state', target), productUpdate('session.pendingExchange', target), productUpdate('session.exchanges', target), productUpdate('session.runtimeState', target), diff --git a/src/rpc/protocol.test.ts b/src/rpc/protocol.test.ts index c0296bc5f..727d9b09a 100644 --- a/src/rpc/protocol.test.ts +++ b/src/rpc/protocol.test.ts @@ -15,22 +15,22 @@ describe('JSON-RPC protocol helpers', () => { isJsonRpcRequest({ jsonrpc: '2.0', id: 'abc', - method: 'workspace.snapshot', + method: 'workspace.state', }), ).toBe(true); - expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'workspace.snapshot' })).toBe(true); + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'workspace.state' })).toBe(true); expect( isJsonRpcRequest({ jsonrpc: '2.0', id: null, - method: 'workspace.snapshot', + method: 'workspace.state', }), ).toBe(true); expect( isJsonRpcRequest({ jsonrpc: '2.0', id: { bad: true }, - method: 'workspace.snapshot', + method: 'workspace.state', }), ).toBe(false); expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1 })).toBe(false); @@ -64,7 +64,7 @@ describe('JSON-RPC protocol helpers', () => { ).resolves.toEqual(createJsonRpcParseError()); await expect( - dispatchJsonRpcMessage('{"jsonrpc":"2.0","id":7,"method":"workspace.snapshot"}', { + dispatchJsonRpcMessage('{"jsonrpc":"2.0","id":7,"method":"workspace.state"}', { async handle() { throw new Error('boom'); }, @@ -77,9 +77,9 @@ describe('JSON-RPC protocol helpers', () => { }); it('parses protocol messages without attaching product semantics', () => { - expect(parseJsonRpcMessage('{"jsonrpc":"2.0","id":1,"method":"workspace.snapshot"}')).toEqual({ + expect(parseJsonRpcMessage('{"jsonrpc":"2.0","id":1,"method":"workspace.state"}')).toEqual({ ok: true, - value: { jsonrpc: '2.0', id: 1, method: 'workspace.snapshot' }, + value: { jsonrpc: '2.0', id: 1, method: 'workspace.state' }, }); expect(parseJsonRpcMessage('not json')).toEqual({ ok: false, diff --git a/src/rpc/web-host.test.ts b/src/rpc/web-host.test.ts index 53c9dd234..d6cdbd4c9 100644 --- a/src/rpc/web-host.test.ts +++ b/src/rpc/web-host.test.ts @@ -194,10 +194,10 @@ describe('web host', () => { coordinator: createWorkspaceSessionCoordinator({ cwd }), }); try { - const snapshot = await websocketRpc(host.url, { + const state = await websocketRpc(host.url, { jsonrpc: '2.0', id: 1, - method: 'workspace.snapshot', + method: 'workspace.state', }); const exchanges = await websocketRpc(host.url, { jsonrpc: '2.0', @@ -205,7 +205,7 @@ describe('web host', () => { method: 'session.exchanges', }); - expect(snapshot).toMatchObject({ + expect(state).toMatchObject({ jsonrpc: '2.0', id: 1, result: { status: 'ready', spec: { title: 'Web spec' } }, @@ -294,7 +294,7 @@ describe('web host', () => { id: 16, result: { methods: expect.arrayContaining([ - expect.objectContaining({ method: 'workspace.snapshot' }), + expect.objectContaining({ method: 'workspace.state' }), expect.objectContaining({ method: 'workspace.selectionState' }), expect.objectContaining({ method: 'session.pendingExchange' }), expect.objectContaining({ method: 'session.exchanges' }), @@ -444,8 +444,8 @@ describe('web host', () => { }); try { const responses = await websocketRpcBatch(host.url, [ - { jsonrpc: '2.0', id: 10, method: 'workspace.snapshot' }, - { jsonrpc: '2.0', id: 11, method: 'workspace.snapshot' }, + { jsonrpc: '2.0', id: 10, method: 'workspace.state' }, + { jsonrpc: '2.0', id: 11, method: 'workspace.state' }, ]); expect(responses).toHaveLength(2); @@ -482,7 +482,7 @@ describe('web host', () => { websocketRpc(host.url, { jsonrpc: '2.0', id: 12, - method: 'workspace.snapshot', + method: 'workspace.state', }), ).resolves.toMatchObject({ jsonrpc: '2.0', id: 12 }); } finally { @@ -500,7 +500,7 @@ describe('web host', () => { const response = await websocketRpc(host.url, { jsonrpc: '2.0', id: 13, - method: 'workspace.snapshot', + method: 'workspace.state', }); expect(response).toEqual({ @@ -528,7 +528,7 @@ describe('web host', () => { JSON.stringify({ jsonrpc: '2.0', id: 14, - method: 'workspace.snapshot', + method: 'workspace.state', }), ); @@ -606,7 +606,7 @@ describe('web host', () => { it('does not expose product read endpoints over HTTP GET', async () => { const host = await startWebHost({ cwd: '/tmp/brunch-project', port: 0 }); try { - const response = await fetch(`${host.url}/workspace.snapshot`); + const response = await fetch(`${host.url}/workspace.state`); expect(response.status).toBe(404); } finally { diff --git a/src/rpc/websocket.ts b/src/rpc/websocket.ts index ebe53a848..b2ddfee99 100644 --- a/src/rpc/websocket.ts +++ b/src/rpc/websocket.ts @@ -1,6 +1,6 @@ import type { Server as HttpServer } from 'node:http'; -import { WebSocketServer, type RawData, type WebSocket } from 'ws'; +import { WebSocket, WebSocketServer, type RawData } from 'ws'; import type { RpcHandlers } from './handlers.js'; import { createProductUpdateNotification, type ProductUpdatePublisher } from './product-updates.js'; @@ -101,7 +101,7 @@ export function attachWebRpcTransport(options: { } function sendIfOpen(client: WebSocket, message: string): void { - if (client.readyState !== client.OPEN) return; + if (!isWebSocketOpen(client)) return; try { client.send(message); } catch { @@ -111,6 +111,16 @@ export function attachWebRpcTransport(options: { } } +/** + * A client can receive a frame only while its connection is OPEN. Read the + * readiness state against the runtime `WebSocket.OPEN` constant from `ws` + * rather than the per-instance `client.OPEN`, so the contract names the shared + * protocol constant instead of relying on each socket instance carrying it. + */ +function isWebSocketOpen(client: WebSocket): boolean { + return client.readyState === WebSocket.OPEN; +} + async function handleMessage(handlers: RpcHandlers, data: RawData) { const message = websocketMessageToString(data); return dispatchJsonRpcMessage(message, handlers); diff --git a/src/scripts/README.md b/src/scripts/README.md index 00fff5c13..52906f492 100644 --- a/src/scripts/README.md +++ b/src/scripts/README.md @@ -6,7 +6,11 @@ SPEC decisions: D52-L Local executable utilities and script-facing helpers that are not product domain layers. -Current utilities: none. Print-mode snapshot projection/rendering moved to `projections/workspace/` and `renderers/workspace/`; `app/` now calls those shared seams directly. +Current utilities: + +- `render-preview.ts` — writes reviewable renderer previews from seeded fixtures without changing product runtime code. + +Print-mode workspace-state projection/rendering moved to `projections/workspace/` and `renderers/workspace/`; `app/` now calls those shared seams directly. ## Does not own diff --git a/src/scripts/render-preview.ts b/src/scripts/render-preview.ts new file mode 100644 index 000000000..a8fbed524 --- /dev/null +++ b/src/scripts/render-preview.ts @@ -0,0 +1,113 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { renderNeighborhoodPreview } from '../graph/render-preview.js'; + +type RendererName = 'graph-neighborhood'; + +interface CliOptions { + readonly renderer: RendererName; + readonly set: string; + readonly fixture: string; + readonly anchorCode: string; + readonly outputPath?: string; + readonly hops?: number; +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_OUTPUT_DIR = resolve(HERE, '../renderers/graph/__previews__'); + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const rendered = render(options); + const outputPath = options.outputPath ?? resolve(DEFAULT_OUTPUT_DIR, defaultPreviewFileName(options)); + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, rendered, 'utf8'); + + console.log(outputPath); +} + +function render(options: CliOptions): string { + switch (options.renderer) { + case 'graph-neighborhood': + return renderNeighborhoodPreview({ + set: options.set, + fixture: options.fixture, + anchorCode: options.anchorCode, + ...(options.hops === undefined ? {} : { hops: options.hops }), + }); + } +} + +function defaultPreviewFileName(options: CliOptions): string { + switch (options.renderer) { + case 'graph-neighborhood': + return `neighborhood-${options.fixture}-${options.anchorCode}.preview.md`; + } +} + +function parseArgs(argv: string[]): CliOptions { + if (argv.length < 4) { + throw new Error( + 'Usage: npm run render -- graph-neighborhood [--output ] [--hops ]', + ); + } + + const [rendererValue, setValue, fixtureValue, anchorCodeValue, ...rest] = argv; + const renderer = rendererValue; + const set = setValue; + const fixture = fixtureValue; + const anchorCode = anchorCodeValue; + if (!renderer || !set || !fixture || !anchorCode) { + throw new Error( + 'Usage: npm run render -- graph-neighborhood [--output ] [--hops ]', + ); + } + if (renderer !== 'graph-neighborhood') { + throw new Error(`Unknown renderer "${renderer}". Supported renderers: graph-neighborhood`); + } + + let outputPath: string | undefined; + let hops: number | undefined; + + for (let index = 0; index < rest.length; index += 1) { + const flag = rest[index]; + const value = rest[index + 1]; + + if (flag === '--output') { + if (!value) throw new Error('--output requires a path'); + outputPath = resolve(process.cwd(), value); + index += 1; + continue; + } + + if (flag === '--hops') { + if (!value) throw new Error('--hops requires a numeric value'); + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`--hops must be a positive integer; received "${value}"`); + } + hops = parsed; + index += 1; + continue; + } + + throw new Error(`Unknown flag "${flag}"`); + } + + return { + renderer, + set, + fixture, + anchorCode, + ...(outputPath === undefined ? {} : { outputPath }), + ...(hops === undefined ? {} : { hops }), + }; +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/src/session/README.md b/src/session/README.md index 6ff2f124d..5cbd1af48 100644 --- a/src/session/README.md +++ b/src/session/README.md @@ -37,17 +37,17 @@ plus the coordination logic for workspace/spec/session lifecycle. - **LSN staleness tracking** — Pi extension records current LSN at session start, checks at `prepareNextTurn`, injects `worldUpdate` with optional - re-snapshot when stale. + context refresh when stale. ## Does NOT own -- Graph state, CommandExecutor, graph snapshots — those live in `graph/`. +- Graph state, CommandExecutor, graph queries — those live in `graph/`. - Prompt composition, context building — those live in `.pi/agents/`. - Pi extension registration — those live in `.pi/extensions/`. ## Imported by -- `.pi/agents/contexts/` — for session/transcript snapshots. +- `.pi/agents/contexts/` — for session/transcript context reads. - `projections/session/` — for reusable transcript-context DTO projection. - `renderers/session/` — for reusable transcript markdown rendering. - `rpc/` — for session.* and workspace.* RPC handlers. diff --git a/src/session/jsonl-session-viability.test.ts b/src/session/jsonl-session-viability.test.ts index b779b603d..079d1cb82 100644 --- a/src/session/jsonl-session-viability.test.ts +++ b/src/session/jsonl-session-viability.test.ts @@ -52,7 +52,7 @@ describe('Pi JSONL transcript viability', () => { const { file, manager } = createPersistedSession(); const customEntries = [ ['brunch.lens_switch', { lens: 'verification-design', reason: 'test' }], - ['brunch.mention', { entityId: 'node-1', snapshottedLsn: 7, title: 'Known node' }], + ['brunch.mention', { entityId: 'node-1', seenLsn: 7, title: 'Known node' }], ['brunch.mention_staleness_hint', { entityId: 'node-1', seenLsn: 7, currentLsn: 9 }], [ 'brunch.continuity', diff --git a/src/session/runtime-state.test.ts b/src/session/runtime-state.test.ts index bae176774..86b05cfcc 100644 --- a/src/session/runtime-state.test.ts +++ b/src/session/runtime-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { projectSessionRuntimeState } from '../projections/session/runtime-state.js'; import { NonLinearTranscriptError, type BrunchSessionEnvelope } from './brunch-session-envelope.js'; import { + AGENT_STRATEGY_IDS, BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, type BrunchAgentState, @@ -43,6 +44,26 @@ function runtimeEntry(id: string, state: BrunchAgentState, parentId = 'binding-1 } describe('session runtime-state projection', () => { + it('accepts freestyle as a real strategy id in runtime state parsing', () => { + expect(AGENT_STRATEGY_IDS).toContain('freestyle'); + + const freestyle: BrunchAgentState = { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'freestyle', + agentLens: 'intent', + agentGoal: 'grounding-advance', + }; + + expect( + projectSessionRuntimeState(envelope([runtimeEntry('runtime-freestyle', freestyle)])), + ).toMatchObject({ + agent: { + strategy: 'freestyle', + }, + }); + }); + it('returns flattened defaults for an explicit linear session with no runtime entries', () => { expect(projectSessionRuntimeState(envelope())).toEqual({ status: 'ready', @@ -92,7 +113,7 @@ describe('session runtime-state projection', () => { type: 'custom', parentId: 'runtime-1', customType: 'brunch.mention', - data: { entityId: 'node-1', handle: 'D12', title: 'Decision seam', snapshottedLsn: 7 }, + data: { entityId: 'node-1', handle: 'D12', title: 'Decision seam', seenLsn: 7 }, } as never, { id: 'file-mention-1', diff --git a/src/session/runtime-state.ts b/src/session/runtime-state.ts index 1851639cc..e76ab5754 100644 --- a/src/session/runtime-state.ts +++ b/src/session/runtime-state.ts @@ -4,6 +4,7 @@ export type OperationalModeId = 'elicit'; export type AgentRoleId = 'elicitor'; export type AutoAxisSelection = 'auto'; export type AgentStrategyId = + | 'freestyle' | 'step-wise-decision-tree' | 'step-wise-disambiguate' | 'propose-graph' @@ -55,7 +56,8 @@ export const DEFAULT_BRUNCH_AGENT_STATE: BrunchAgentState = { }; const OPERATIONAL_MODE_IDS: readonly OperationalModeId[] = ['elicit']; -const AGENT_STRATEGY_IDS: readonly AgentStrategyId[] = [ +export const AGENT_STRATEGY_IDS: readonly AgentStrategyId[] = [ + 'freestyle', 'step-wise-decision-tree', 'step-wise-disambiguate', 'propose-graph', diff --git a/src/session/structured-exchange-loop.test.ts b/src/session/structured-exchange-loop.test.ts index c2abbc636..67d4ae062 100644 --- a/src/session/structured-exchange-loop.test.ts +++ b/src/session/structured-exchange-loop.test.ts @@ -274,4 +274,82 @@ describe('structured exchange loop helpers', () => { ], }); }); + + it('round-trips present_candidates provenance so answers capture as candidates', () => { + const envelope: BrunchSessionEnvelope = { + header: header as unknown as BrunchSessionEnvelope['header'], + binding, + entries: [ + header, + bindingEntry, + { + id: 'present-candidates-1', + type: 'message', + parentId: 'binding-1', + timestamp: 0, + message: { + role: 'toolResult', + toolCallId: 'present-call-1', + toolName: 'present_candidates', + content: [ + { + type: 'text', + text: [ + '## Pick a candidate', + '', + '### 1. Candidate A', + '', + '', + ].join('\n'), + }, + ], + details: { + schema: 'brunch.structured_exchange.present', + v: 1, + exchange_id: 'cand', + tool_meta: { curr: 'present_candidates', next: 'request_choice' }, + display: { heading: 'Pick a candidate' }, + candidates: [ + { + id: 'cand-a', + title: 'Candidate A', + user_rubric: { + core_bet: 'try A', + best_fit: 'small teams', + cost_complexity: 'low', + covers_well: 'most cases', + main_risks: 'few', + lock_in_constraints: 'none', + }, + meta_rubric: {}, + graph_refs: [], + }, + ], + }, + isError: false, + }, + }, + ] as unknown as BrunchSessionEnvelope['entries'], + }; + + const pending = pendingExchangeFromEnvelope(envelope); + expect(pending).toMatchObject({ + exchangeId: 'cand', + mode: 'single-select', + respondsToPresentTool: 'present_candidates', + }); + + const accepted = acceptedResponseFromParams(pending!, { + exchangeId: 'cand', + answer: { optionId: 'cand-a' }, + }); + expect(accepted).toMatchObject({ + ok: true, + toolResultMessage: { + details: { + tool_meta: { prev: 'present_candidates', curr: 'request_choice', next: 'capture_candidate' }, + }, + }, + }); + }); }); diff --git a/src/session/structured-exchange-loop.ts b/src/session/structured-exchange-loop.ts index a446d86f1..cc57e6d98 100644 --- a/src/session/structured-exchange-loop.ts +++ b/src/session/structured-exchange-loop.ts @@ -2,14 +2,14 @@ import * as z from 'zod'; import type { PresentDetails } from '../.pi/extensions/exchanges/schemas/index.js'; import { isStructuredExchangePresentDetails } from '../.pi/extensions/exchanges/shared/recovery.js'; -import { projectPresentOptions } from '../projections/structured-exchange/present-options.js'; -import { projectPresentQuestion } from '../projections/structured-exchange/present-question.js'; -import { projectRequestAnswer } from '../projections/structured-exchange/request-answer.js'; -import { projectRequestChoice } from '../projections/structured-exchange/request-choice.js'; -import { projectRequestChoices } from '../projections/structured-exchange/request-choices.js'; -import { projectRequestReview } from '../projections/structured-exchange/request-review.js'; -import { formatPresentOptions } from '../renderers/structured-exchange/present-options.js'; -import { formatPresentQuestion } from '../renderers/structured-exchange/present-question.js'; +import { projectPresentOptions } from '../projections/exchanges/present-options.js'; +import { projectPresentQuestion } from '../projections/exchanges/present-question.js'; +import { projectRequestAnswer } from '../projections/exchanges/request-answer.js'; +import { projectRequestChoice } from '../projections/exchanges/request-choice.js'; +import { projectRequestChoices } from '../projections/exchanges/request-choices.js'; +import { projectRequestReview } from '../projections/exchanges/request-review.js'; +import { formatPresentOptions } from '../renderers/exchanges/present-options.js'; +import { formatPresentQuestion } from '../renderers/exchanges/present-question.js'; import type { BrunchSessionEnvelope } from './brunch-session-envelope.js'; import { projectLinearSessionExchangeProjection } from './exchange-projection.js'; @@ -34,6 +34,11 @@ export const zPendingStructuredExchange = z ), note: z.object({ allowed: z.boolean() }).strict(), reviewSet: z.record(z.string(), z.unknown()).optional(), + // Which present tool opened a single-select exchange. Candidate lists and + // option lists both answer via request_choice but capture differently + // (capture_candidate vs capture_choice), so the provenance must round-trip + // rather than be assumed. Absent ⇒ present_options. + respondsToPresentTool: z.enum(['present_options', 'present_candidates']).optional(), }) .strict(); export const PendingStructuredExchangeSchema = z.toJSONSchema(zPendingStructuredExchange, { @@ -309,7 +314,7 @@ export function acceptedResponseFromParams( content: [{ type: 'text', text: choiceResponseMarkdown([choice], params.note) }], details: projectRequestChoice({ exchangeId: pending.exchangeId, - respondsToPresentTool: 'present_options', + respondsToPresentTool: pending.respondsToPresentTool ?? 'present_options', status: 'answered', choice: { id: choice.id, label: choice.label, kind: choiceKind(choice.id) }, comment, @@ -448,15 +453,17 @@ function pendingExchangeFromStructuredPresent( }; } + const mode = + details.tool_meta.next === 'request_choices' + ? 'multi-select' + : details.tool_meta.curr === 'present_question' + ? 'text' + : 'single-select'; + return { exchangeId: details.exchange_id, lens: 'intent', - mode: - details.tool_meta.next === 'request_choices' - ? 'multi-select' - : details.tool_meta.curr === 'present_question' - ? 'text' - : 'single-select', + mode, prompt, ...(detailsText.length > 0 ? { details: detailsText } : {}), options: @@ -464,6 +471,11 @@ function pendingExchangeFromStructuredPresent( ? parsePendingOptions(details.options, markdown) : parsePendingOptions(undefined, markdown), note: { allowed: true }, + // Preserve which present tool opened a single-select exchange so the answer + // captures as the matching tool (candidate vs choice). + ...(mode === 'single-select' && details.tool_meta.curr === 'present_candidates' + ? { respondsToPresentTool: 'present_candidates' as const } + : {}), }; } diff --git a/src/session/workspace-context.test.ts b/src/session/workspace-context.test.ts new file mode 100644 index 000000000..e8631e0f4 --- /dev/null +++ b/src/session/workspace-context.test.ts @@ -0,0 +1,138 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { openWorkspaceCommandExecutor } from '../graph/index.js'; +import { seedFixture, type SeedFixture } from '../graph/seed-fixtures.js'; +import { createSessionBindingData } from './session-binding.js'; +import { inspectWorkspaceCwdInventory, inspectWorkspaceOverview } from './workspace-context.js'; + +describe('inspectWorkspaceCwdInventory', () => { + it('returns a gitignore-aware kickoff inventory with session and markdown sizes', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-workspace-context-')); + await mkdir(join(cwd, '.brunch', 'sessions'), { recursive: true }); + await mkdir(join(cwd, 'src', 'nested'), { recursive: true }); + await mkdir(join(cwd, 'ignored-dir'), { recursive: true }); + + await writeFile(join(cwd, '.gitignore'), ['ignored-dir/', 'ignored.md'].join('\n')); + await writeFile(join(cwd, 'README.md'), '# Workspace\nA note\n'); + await writeFile(join(cwd, 'ignored.md'), '# Ignore me\n'); + await writeFile(join(cwd, 'src', 'index.ts'), 'export {}\n'); + await writeFile(join(cwd, 'src', 'nested', 'guide.md'), 'Nested guide\n'); + await writeFile(join(cwd, 'ignored-dir', 'secret.txt'), 'hidden\n'); + + const sessionFile = join(cwd, '.brunch', 'sessions', 'session-1.jsonl'); + await writeFile( + sessionFile, + [ + JSON.stringify({ type: 'session', id: 'session-1', cwd }), + JSON.stringify({ + id: 'binding-1', + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId: 7 }), + }), + ].join('\n') + '\n', + ); + + const inventory = await inspectWorkspaceCwdInventory(cwd); + + expect(inventory.status).toBe('ready'); + expect(inventory.hasBrunchDir).toBe(true); + expect(inventory.sessionFiles).toEqual([ + { file: 'session-1.jsonl', lineCount: 3, byteCount: expect.any(Number) }, + ]); + expect(inventory.topLevelEntries).toEqual([ + { name: '.brunch', kind: 'directory', fileCount: 1 }, + { name: '.gitignore', kind: 'file', fileCount: 1 }, + { name: 'README.md', kind: 'file', fileCount: 1 }, + { name: 'src', kind: 'directory', fileCount: 2 }, + ]); + expect(inventory.markdownFiles).toEqual([ + { path: 'README.md', lineCount: 3, byteCount: expect.any(Number) }, + { path: 'src/nested/guide.md', lineCount: 2, byteCount: expect.any(Number) }, + ]); + }); + + it('returns a coherent fresh-workspace inventory when .brunch is absent', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-workspace-context-')); + await writeFile(join(cwd, 'README.md'), 'Fresh workspace\n'); + + const inventory = await inspectWorkspaceCwdInventory(cwd); + + expect(inventory.hasBrunchDir).toBe(false); + expect(inventory.sessionFiles).toEqual([]); + expect(inventory.topLevelEntries).toEqual([{ name: 'README.md', kind: 'file', fileCount: 1 }]); + }); + + it('returns a workspace overview with spec node counts and session turn counts', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'brunch-workspace-overview-')); + const executor = await openWorkspaceCommandExecutor(cwd); + const alpha = seedFixture(executor, await loadFixture('alpha-grounding', 'workspace-spread')); + const beta = seedFixture(executor, await loadFixture('beta-commitments', 'workspace-spread')); + + await mkdir(join(cwd, '.brunch', 'sessions'), { recursive: true }); + await writeBoundSession(cwd, 'alpha-session', alpha.specId, [{ type: 'user', id: 'u1' }]); + await writeBoundSession(cwd, 'beta-session', beta.specId, [ + { type: 'user', id: 'u1' }, + { type: 'assistant', id: 'a1' }, + ]); + + const overview = await inspectWorkspaceOverview(cwd); + + expect(overview.specs).toEqual([ + { id: alpha.specId, title: 'Alpha Grounding', nodeCount: 4, sessionCount: 1 }, + { id: beta.specId, title: 'Beta Commitments', nodeCount: 5, sessionCount: 1 }, + ]); + expect(overview.sessions).toEqual([ + { + id: 'alpha-session', + file: 'alpha-session.jsonl', + specId: alpha.specId, + specTitle: 'Alpha Grounding', + turnCount: 1, + readinessGrade: 'grounding_onboarding', + }, + { + id: 'beta-session', + file: 'beta-session.jsonl', + specId: beta.specId, + specTitle: 'Beta Commitments', + turnCount: 2, + readinessGrade: 'commitments_ready', + }, + ]); + }); +}); + +async function loadFixture(slug: string, set = 'bilal-port'): Promise { + const fixturePath = fileURLToPath(new URL(`../../.fixtures/seeds/${set}/${slug}.json`, import.meta.url)); + return JSON.parse(await import('node:fs/promises').then(({ readFile }) => readFile(fixturePath, 'utf8'))); +} + +async function writeBoundSession( + cwd: string, + sessionId: string, + specId: number, + entries: Array<{ type: 'user' | 'assistant'; id: string }>, +): Promise { + await writeFile( + join(cwd, '.brunch', 'sessions', `${sessionId}.jsonl`), + [ + JSON.stringify({ type: 'session', id: sessionId, cwd }), + JSON.stringify({ + id: `${sessionId}-binding`, + type: 'custom', + parentId: null, + customType: 'brunch.session_binding', + data: createSessionBindingData({ specId }), + }), + ...entries.map((entry) => JSON.stringify(entry)), + ].join('\n') + '\n', + ); +} diff --git a/src/session/workspace-context.ts b/src/session/workspace-context.ts new file mode 100644 index 000000000..8e1d60888 --- /dev/null +++ b/src/session/workspace-context.ts @@ -0,0 +1,341 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { basename, join, relative, resolve, sep } from 'node:path'; + +import { openWorkspaceGraphRuntime, type ReadinessGrade } from '../graph/index.js'; +import { inspectCanonicalSessionFiles } from './workspace-session-coordinator/boot-session-store.js'; + +export interface WorkspaceSessionFileInventory { + readonly file: string; + readonly lineCount: number; + readonly byteCount: number; +} + +export interface WorkspaceTreeEntryInventory { + readonly name: string; + readonly kind: 'file' | 'directory'; + readonly fileCount: number; +} + +export interface WorkspaceMarkdownFileInventory { + readonly path: string; + readonly lineCount: number; + readonly byteCount: number; +} + +export interface WorkspaceCwdInventory { + readonly status: 'ready'; + readonly cwd: string; + readonly hasBrunchDir: boolean; + readonly sessionFiles: readonly WorkspaceSessionFileInventory[]; + readonly topLevelEntries: readonly WorkspaceTreeEntryInventory[]; + readonly markdownFiles: readonly WorkspaceMarkdownFileInventory[]; +} + +export interface WorkspaceSpecOverview { + readonly id: number; + readonly title: string; + readonly nodeCount: number; + readonly sessionCount: number; +} + +export interface WorkspaceSessionOverview { + readonly id: string; + readonly file: string; + readonly specId: number; + readonly specTitle: string; + readonly turnCount: number; + readonly readinessGrade: ReadinessGrade; +} + +export interface WorkspaceOverview { + readonly status: 'ready'; + readonly cwd: string; + readonly specs: readonly WorkspaceSpecOverview[]; + readonly sessions: readonly WorkspaceSessionOverview[]; +} + +interface GitignoreRule { + readonly negated: boolean; + readonly directoryOnly: boolean; + readonly rooted: boolean; + readonly regex: RegExp; +} + +const BRUNCH_DIR = '.brunch'; +const DEFAULT_IGNORED_TOP_LEVEL = new Set(['.git']); + +export async function inspectWorkspaceCwdInventory(cwd: string): Promise { + const resolvedCwd = resolve(cwd); + const shouldIgnore = await createGitignoreMatcher(resolvedCwd); + const topLevelEntries = await collectTopLevelEntries(resolvedCwd, shouldIgnore); + const markdownFiles = await collectMarkdownFiles(resolvedCwd, shouldIgnore); + const sessionFiles = await collectSessionFiles(resolvedCwd); + + return { + status: 'ready', + cwd: resolvedCwd, + hasBrunchDir: topLevelEntries.some((entry) => entry.name === BRUNCH_DIR), + sessionFiles, + topLevelEntries, + markdownFiles, + }; +} + +export async function inspectWorkspaceOverview(cwd: string): Promise { + const resolvedCwd = resolve(cwd); + const graph = await openWorkspaceGraphRuntime(resolvedCwd); + const specs = graph.commandExecutor + .listSpecs() + .map((spec) => ({ + id: spec.id, + title: spec.name, + readinessGrade: spec.readinessGrade, + nodeCount: graph.forSpec(spec.id).getGraphOverview().nodeCount, + })) + .sort((left, right) => left.title.localeCompare(right.title)); + const specsById = new Map(specs.map((spec) => [spec.id, spec])); + const sessions = await inspectCanonicalSessionFiles(resolvedCwd); + const availableSessions = await Promise.all( + sessions + .filter((session) => session.available) + .map(async (session) => { + const spec = specsById.get(session.specId); + if (!spec) { + return null; + } + const entries = await readJsonl(session.file); + return { + id: session.id, + file: basename(session.file), + specId: session.specId, + specTitle: spec.title, + turnCount: countTurnEntries(entries), + readinessGrade: spec.readinessGrade, + } satisfies WorkspaceSessionOverview; + }), + ); + const sessionsBySpecId = new Map(); + const visibleSessions = availableSessions + .filter((session): session is WorkspaceSessionOverview => session != null) + .sort((left, right) => left.file.localeCompare(right.file)); + + for (const session of visibleSessions) { + sessionsBySpecId.set(session.specId, (sessionsBySpecId.get(session.specId) ?? 0) + 1); + } + + return { + status: 'ready', + cwd: resolvedCwd, + specs: specs.map((spec) => ({ + id: spec.id, + title: spec.title, + nodeCount: spec.nodeCount, + sessionCount: sessionsBySpecId.get(spec.id) ?? 0, + })), + sessions: visibleSessions, + }; +} + +async function collectTopLevelEntries( + cwd: string, + shouldIgnore: (relativePath: string, isDirectory: boolean) => boolean, +): Promise { + const entries = await readdir(cwd, { withFileTypes: true }); + const inventories: WorkspaceTreeEntryInventory[] = []; + + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (DEFAULT_IGNORED_TOP_LEVEL.has(entry.name)) { + continue; + } + + const relativePath = entry.name; + if (shouldIgnore(relativePath, entry.isDirectory())) { + continue; + } + + const fileCount = entry.isDirectory() + ? await countVisibleFiles(join(cwd, entry.name), cwd, shouldIgnore) + : 1; + + inventories.push({ + name: entry.name, + kind: entry.isDirectory() ? 'directory' : 'file', + fileCount, + }); + } + + return inventories; +} + +async function countVisibleFiles( + directory: string, + cwd: string, + shouldIgnore: (relativePath: string, isDirectory: boolean) => boolean, +): Promise { + let fileCount = 0; + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const path = join(directory, entry.name); + const relativePath = toRelativePath(cwd, path); + if (shouldIgnore(relativePath, entry.isDirectory())) { + continue; + } + fileCount += entry.isDirectory() ? await countVisibleFiles(path, cwd, shouldIgnore) : 1; + } + + return fileCount; +} + +async function collectMarkdownFiles( + cwd: string, + shouldIgnore: (relativePath: string, isDirectory: boolean) => boolean, +): Promise { + const inventories: WorkspaceMarkdownFileInventory[] = []; + await walkVisibleFiles(cwd, cwd, shouldIgnore, async (filePath) => { + if (!isMarkdownLike(filePath)) { + return; + } + const content = await readFile(filePath, 'utf8'); + inventories.push({ + path: toRelativePath(cwd, filePath), + lineCount: countLines(content), + byteCount: Buffer.byteLength(content), + }); + }); + return inventories.sort((left, right) => left.path.localeCompare(right.path)); +} + +async function walkVisibleFiles( + directory: string, + cwd: string, + shouldIgnore: (relativePath: string, isDirectory: boolean) => boolean, + onFile: (filePath: string) => Promise, +): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(directory, entry.name); + const relativePath = toRelativePath(cwd, path); + if (DEFAULT_IGNORED_TOP_LEVEL.has(entry.name) && directory === cwd) { + continue; + } + if (shouldIgnore(relativePath, entry.isDirectory())) { + continue; + } + if (entry.isDirectory()) { + await walkVisibleFiles(path, cwd, shouldIgnore, onFile); + continue; + } + await onFile(path); + } +} + +async function collectSessionFiles(cwd: string): Promise { + const sessions = await inspectCanonicalSessionFiles(cwd); + const inventories: WorkspaceSessionFileInventory[] = []; + for (const session of sessions) { + const content = await readFile(session.file, 'utf8'); + inventories.push({ + file: basename(session.file), + lineCount: countLines(content), + byteCount: Buffer.byteLength(content), + }); + } + return inventories.sort((left, right) => left.file.localeCompare(right.file)); +} + +async function readJsonl(file: string): Promise { + const content = await readFile(file, 'utf8'); + return content + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as unknown); +} + +async function createGitignoreMatcher( + cwd: string, +): Promise<(relativePath: string, isDirectory: boolean) => boolean> { + const gitignorePath = join(cwd, '.gitignore'); + try { + const content = await readFile(gitignorePath, 'utf8'); + const rules = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map(parseGitignoreRule); + + return (relativePath, isDirectory) => { + const normalizedPath = normalizeRelativePath(relativePath); + let ignored = false; + for (const rule of rules) { + if (rule.directoryOnly && !isDirectory) { + continue; + } + const candidates = rule.rooted ? [normalizedPath] : [normalizedPath, basename(normalizedPath)]; + if (!candidates.some((candidate) => rule.regex.test(candidate))) { + continue; + } + ignored = !rule.negated; + } + return ignored; + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return () => false; + } + throw error; + } +} + +function parseGitignoreRule(pattern: string): GitignoreRule { + const negated = pattern.startsWith('!'); + const rawPattern = negated ? pattern.slice(1) : pattern; + const directoryOnly = rawPattern.endsWith('/'); + const normalized = directoryOnly ? rawPattern.slice(0, -1) : rawPattern; + const rooted = normalized.startsWith('/'); + const body = rooted ? normalized.slice(1) : normalized; + + return { + negated, + directoryOnly, + rooted, + regex: globToRegExp(body), + }; +} + +function globToRegExp(glob: string): RegExp { + const escaped = glob + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '::DOUBLE_STAR::') + .replace(/\*/g, '[^/]*') + .replace(/::DOUBLE_STAR::/g, '.*') + .replace(/\?/g, '[^/]'); + return new RegExp(`^${escaped}$`); +} + +function toRelativePath(cwd: string, path: string): string { + return normalizeRelativePath(relative(cwd, path)); +} + +function normalizeRelativePath(path: string): string { + return path.split(sep).join('/'); +} + +function isMarkdownLike(path: string): boolean { + const name = basename(path).toLowerCase(); + return name.endsWith('.md') || name === 'readme' || name.startsWith('readme.'); +} + +function countLines(content: string): number { + if (content.length === 0) { + return 0; + } + return content.split(/\r?\n/).length; +} + +function countTurnEntries(entries: readonly unknown[]): number { + return entries.filter((entry) => { + const type = (entry as { type?: unknown }).type; + return type === 'user' || type === 'assistant'; + }).length; +} diff --git a/src/web/README.md b/src/web/README.md index 95b6b4b2d..f49d0c746 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -33,13 +33,13 @@ web/ query-keys.ts method-shaped product query keys: - workspace.snapshot + workspace.state session.runtimeState graph.overview graph.nodeNeighborhood queries/ - workspace.ts -> workspace.snapshot query options + workspace.ts -> workspace.state query options session.ts -> session.runtimeState query options graph.ts -> graph overview/neighborhood query options @@ -51,7 +51,7 @@ web/ root.tsx root subscription + `/` workspace/session proof route spec.tsx - `/spec/$specId` loader primes workspace.snapshot + graph.overview + `/spec/$specId` loader primes workspace.state + graph.overview features/graph/GraphOverview.tsx read-only selected-spec graph projection @@ -155,7 +155,7 @@ web/ queries/ workspace.ts - workspaceSnapshotQueryOptions(rpc) + workspaceStateQueryOptions(rpc) workspaceSelectionStateQueryOptions(rpc) session.ts @@ -222,7 +222,7 @@ Keys should mirror Brunch product resources, not database tables: ```pseudo queryKeys = { workspace: { - snapshot: ['workspace.snapshot'], + state: ['workspace.state'], selectionState: ['workspace.selectionState'], }, @@ -265,9 +265,9 @@ Method names follow `src/rpc/README.md`. The TUI-started web sidecar is read-onl ```pseudo current implemented hooks: - workspace.snapshot - workspaceSnapshotQueryOptions(rpc) - query key: ['workspace.snapshot'] + workspace.state + workspaceStateQueryOptions(rpc) + query key: ['workspace.state'] route loader: root and spec routes session.runtimeState @@ -305,7 +305,7 @@ planned read hooks: planned mutation hooks (not sidecar-accepted today): workspace.activate activateWorkspaceMutationOptions(rpc) - On success: invalidate workspace.snapshot, workspace.selectionState, session/graph keys for selected resources. + On success: invalidate workspace.state, workspace.selectionState, session/graph keys for selected resources. session.triggerExchange triggerExchangeMutationOptions(rpc) @@ -337,8 +337,8 @@ useBrunchUpdateInvalidation(rpc, queryClient) subscribe to server notifications once at app/root level for each notification: - if topic == workspace.snapshot: - invalidate queryKeys.workspace.snapshot + if topic == workspace.state: + invalidate queryKeys.workspace.state if topic == session.pendingExchange: invalidate exact pendingExchange key @@ -382,7 +382,7 @@ feature component ```pseudo ProposeGraphExchange route/panel required: - workspace.snapshot + workspace.state session.pendingExchange(specId, sessionId) context panels: diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index cc922b309..dd0095839 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { WorkspaceSnapshot } from '../projections/workspace/workspace-snapshot.js'; +import type { WorkspaceState } from '../projections/workspace/workspace-state.js'; import { BrunchWebApp, createBrunchWebRuntime } from './app.js'; import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from './rpc-client.js'; @@ -12,7 +12,7 @@ interface RpcCall { params?: unknown; } -const readySnapshot: WorkspaceSnapshot = { +const readyState: WorkspaceState = { status: 'ready', cwd: '/tmp/brunch-project', spec: { id: 1, title: 'Web spec' }, @@ -23,7 +23,7 @@ const readySnapshot: WorkspaceSnapshot = { }, }; -const selectSpecSnapshot: WorkspaceSnapshot = { +const selectSpecState: WorkspaceState = { status: 'select_spec', cwd: '/tmp/brunch-project', spec: null, @@ -32,7 +32,7 @@ const selectSpecSnapshot: WorkspaceSnapshot = { chatMode: 'select-spec', }, }; -const selectedSpecWithoutSessionSnapshot: WorkspaceSnapshot = { +const selectedSpecWithoutSessionState: WorkspaceState = { status: 'select_spec', cwd: '/tmp/brunch-project', spec: { id: 2, title: 'Spec without session' }, @@ -91,20 +91,20 @@ const populatedGraphOverview = { lsn: 1, }; function rpcClient(options?: { - snapshot?: WorkspaceSnapshot; + state?: WorkspaceState; graphOverview?: typeof emptyGraphOverview | typeof populatedGraphOverview; calls?: RpcCall[]; listeners?: Set; close?: ReturnType; }): WebSocketRpcClient { - const snapshot = options?.snapshot ?? readySnapshot; + const state = options?.state ?? readyState; const calls = options?.calls; const listeners = options?.listeners ?? new Set(); return { async request(method: string, params?: unknown): Promise { calls?.push(params === undefined ? { method } : { method, params }); - if (method === 'workspace.snapshot') { - return snapshot as T; + if (method === 'workspace.state') { + return state as T; } if (method === 'session.runtimeState') { throw new Error('session.runtimeState is not implemented in this test client'); @@ -128,7 +128,7 @@ afterEach(() => { }); describe('Brunch React web app', () => { - it('renders workspace chrome from workspace.snapshot via the RPC client', async () => { + it('renders workspace chrome from workspace.state via the RPC client', async () => { const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() }); render(); @@ -148,7 +148,7 @@ describe('Brunch React web app', () => { 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: 'workspace.state' }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); }); @@ -229,7 +229,7 @@ describe('Brunch React web app', () => { render(); expect(await screen.findByText('Invalid spec id.')).toBeTruthy(); - expect(calls).toContainEqual({ method: 'workspace.snapshot' }); + expect(calls).toContainEqual({ method: 'workspace.state' }); expect(calls.some((call) => call.method === 'graph.overview')).toBe(false); }); @@ -257,7 +257,7 @@ describe('Brunch React web app', () => { const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ - snapshot: selectedSpecWithoutSessionSnapshot, + state: selectedSpecWithoutSessionState, calls, graphOverview: emptyGraphOverview, }), @@ -267,7 +267,7 @@ describe('Brunch React web app', () => { 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: 'workspace.state' }); expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 2 } }); expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); }); @@ -275,13 +275,13 @@ describe('Brunch React web app', () => { it('does not request session projection when no session is selected', async () => { const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ - rpcClient: rpcClient({ snapshot: selectSpecSnapshot, calls }), + rpcClient: rpcClient({ state: selectSpecState, calls }), }); render(); expect(await screen.findByText('No Brunch session selected.')).toBeTruthy(); - expect(calls).toEqual([{ method: 'workspace.snapshot' }]); + expect(calls).toEqual([{ method: 'workspace.state' }]); }); it('keeps one router and QueryClient across BrunchWebApp re-renders', async () => { diff --git a/src/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx index 8713ccc5c..248c8071f 100644 --- a/src/web/features/graph/GraphOverview.tsx +++ b/src/web/features/graph/GraphOverview.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import type { GraphOverview } from '../../../graph/snapshot.js'; +import type { GraphOverview } from '../../../graph/queries.js'; export function GraphOverviewPanel(options: { overview: GraphOverview }) { const { overview } = options; diff --git a/src/web/queries/graph.ts b/src/web/queries/graph.ts index 8d74318bc..0705efd80 100644 --- a/src/web/queries/graph.ts +++ b/src/web/queries/graph.ts @@ -1,6 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; -import type { GraphOverview, NeighborhoodResult } from '../../graph/snapshot.js'; +import type { GraphOverview, NeighborhoodResult } from '../../graph/queries.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; diff --git a/src/web/queries/workspace.ts b/src/web/queries/workspace.ts index e0423d44e..63defdfb5 100644 --- a/src/web/queries/workspace.ts +++ b/src/web/queries/workspace.ts @@ -1,12 +1,12 @@ import { queryOptions } from '@tanstack/react-query'; -import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; +import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; -export function workspaceSnapshotQueryOptions(rpcClient: WebSocketRpcClient) { +export function workspaceStateQueryOptions(rpcClient: WebSocketRpcClient) { return queryOptions({ - queryKey: queryKeys.workspace.snapshot(), - queryFn: () => rpcClient.request('workspace.snapshot'), + queryKey: queryKeys.workspace.state(), + queryFn: () => rpcClient.request('workspace.state'), }); } diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts index 57dbe09b6..407cefdac 100644 --- a/src/web/query-keys.ts +++ b/src/web/query-keys.ts @@ -1,6 +1,6 @@ export const queryKeys = { workspace: { - snapshot: () => ['workspace.snapshot'] as const, + state: () => ['workspace.state'] as const, }, session: { runtimeState: (target: { specId: number; sessionId: string }) => diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx index 33bab0b63..727aa0cbb 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -2,8 +2,8 @@ import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query'; import { Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; import type { ReactNode } from 'react'; -import type { WorkspaceSnapshot } from '../../projections/workspace/workspace-snapshot.js'; -import { workspaceSnapshotQueryOptions } from '../queries/workspace.js'; +import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; +import { workspaceStateQueryOptions } from '../queries/workspace.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; @@ -23,22 +23,21 @@ export const rootRoute = createRootRouteWithContext()({ export const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: ({ context }) => - context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)), - component: WorkspaceSnapshotPage, + loader: ({ context }) => context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)), + component: WorkspaceStatePage, }); -export function sessionProjectionTargetFromSnapshot( - snapshot: WorkspaceSnapshot, +export function sessionProjectionTargetFromState( + state: WorkspaceState, viewedSpecId?: number, ): SessionProjectionTarget | null { - if (!snapshot.session || !snapshot.spec) { + if (!state.session || !state.spec) { return null; } - if (viewedSpecId !== undefined && snapshot.spec.id !== viewedSpecId) { + if (viewedSpecId !== undefined && state.spec.id !== viewedSpecId) { return null; } - return { sessionId: snapshot.session.id, specId: snapshot.spec.id }; + return { sessionId: state.session.id, specId: state.spec.id }; } function RootLayout() { @@ -47,23 +46,23 @@ function RootLayout() { return ; } -function WorkspaceSnapshotPage() { +function WorkspaceStatePage() { const { rpcClient } = indexRoute.useRouteContext(); - const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); + const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); return (

Brunch workspace

- - + +
); } -export function WorkspaceChrome(options: { snapshot: WorkspaceSnapshot; fallbackSpecId?: number }) { - const { snapshot } = options; +export function WorkspaceChrome(options: { state: WorkspaceState; fallbackSpecId?: number }) { + const { state } = options; const specLabel = - snapshot.spec?.title ?? + state.spec?.title ?? (options.fallbackSpecId === undefined ? 'No spec selected' : `Spec ${options.fallbackSpecId}`); return (
cwd
-
{snapshot.cwd}
+
{state.cwd}
spec
@@ -81,37 +80,37 @@ export function WorkspaceChrome(options: { snapshot: WorkspaceSnapshot; fallback
session
- {snapshot.session?.id ?? 'No session selected'} + {state.session?.id ?? 'No session selected'}
phase
-
{snapshot.chrome.phase}
+
{state.chrome.phase}
chat mode
-
{snapshot.chrome.chatMode}
+
{state.chrome.chatMode}
); } -export function SessionPanel(options: { snapshot: WorkspaceSnapshot; viewedSpecId?: number }) { +export function SessionPanel(options: { state: WorkspaceState; viewedSpecId?: number }) { let content: ReactNode; - if (!options.snapshot.session || !options.snapshot.spec) { + if (!options.state.session || !options.state.spec) { content =

No Brunch session selected.

; - } else if (options.viewedSpecId !== undefined && options.snapshot.spec.id !== options.viewedSpecId) { + } else if (options.viewedSpecId !== undefined && options.state.spec.id !== options.viewedSpecId) { content = ( <>

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

-

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

+

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

); } else { content = ( <> -

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

-

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

+

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

+

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

); } diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx index 3a9fbd55c..93a860863 100644 --- a/src/web/routes/spec.tsx +++ b/src/web/routes/spec.tsx @@ -3,7 +3,7 @@ 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 { workspaceStateQueryOptions } from '../queries/workspace.js'; import { rootRoute, SessionPanel, WorkspaceChrome } from './root.js'; export const specRoute = createRoute({ @@ -12,10 +12,10 @@ export const specRoute = createRoute({ loader: ({ context, params }) => { const specId = parseSpecRouteId(params.specId); if (specId === undefined) { - return context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)); + return context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)); } return Promise.all([ - context.queryClient.ensureQueryData(workspaceSnapshotQueryOptions(context.rpcClient)), + context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)), context.queryClient.ensureQueryData(graphOverviewQueryOptions(context.rpcClient, specId)), ]); }, @@ -31,11 +31,11 @@ function SpecRoutePage() { function InvalidSpecRoutePage() { const { rpcClient } = specRoute.useRouteContext(); - const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); + const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); return (

Brunch workspace

- +

Invalid spec id.

@@ -45,15 +45,15 @@ function InvalidSpecRoutePage() { function ValidSpecRoutePage({ specId }: { specId: number }) { const { rpcClient } = specRoute.useRouteContext(); - const { data: snapshot } = useSuspenseQuery(workspaceSnapshotQueryOptions(rpcClient)); + const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, specId)); return (

Brunch workspace

- + - +
); } diff --git a/src/web/rpc-client.test.ts b/src/web/rpc-client.test.ts index b8cbce5e9..5ad74084e 100644 --- a/src/web/rpc-client.test.ts +++ b/src/web/rpc-client.test.ts @@ -47,7 +47,7 @@ function rpcClient() { 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 first = client.request('workspace.state'); const second = client.request('session.exchanges'); expect(FakeWebSocket.instances).toHaveLength(1); @@ -65,8 +65,8 @@ describe('browser WebSocket RPC client', () => { it('resolves concurrent requests by response id, not response order', async () => { const client = rpcClient(); - const first = client.request('workspace.snapshot'); - const second = client.request('workspace.snapshot'); + const first = client.request('workspace.state'); + const second = client.request('workspace.state'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -81,7 +81,7 @@ describe('browser WebSocket RPC client', () => { const client = rpcClient(); const notifications: unknown[] = []; client.subscribe((notification) => notifications.push(notification)); - const request = client.request('workspace.snapshot'); + const request = client.request('workspace.state'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -93,9 +93,9 @@ describe('browser WebSocket RPC client', () => { params: { topics: ['session.runtimeState'] }, }), ); - socket.emit('message', JSON.stringify({ jsonrpc: '2.0', id: 1, result: 'snapshot' })); + socket.emit('message', JSON.stringify({ jsonrpc: '2.0', id: 1, result: 'state' })); - await expect(request).resolves.toBe('snapshot'); + await expect(request).resolves.toBe('state'); expect(notifications).toEqual([ { jsonrpc: '2.0', @@ -120,7 +120,7 @@ describe('browser WebSocket RPC client', () => { it('rejects JSON-RPC failures with code and message', async () => { const client = rpcClient(); - const request = client.request('workspace.snapshot'); + const request = client.request('workspace.state'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -142,7 +142,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 first = client.request('workspace.state'); const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; @@ -151,14 +151,12 @@ describe('browser WebSocket RPC client', () => { await expect(first).rejects.toThrow('Brunch WebSocket RPC protocol failure'); await expect(second).rejects.toThrow('Brunch WebSocket RPC protocol failure'); - await expect(client.request('workspace.snapshot')).rejects.toThrow( - 'Brunch WebSocket RPC protocol failure', - ); + await expect(client.request('workspace.state')).rejects.toThrow('Brunch WebSocket RPC protocol failure'); }); it('rejects all pending requests and later calls on invalid response frames', async () => { const client = rpcClient(); - const first = client.request('workspace.snapshot'); + const first = client.request('workspace.state'); const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; @@ -167,14 +165,12 @@ describe('browser WebSocket RPC client', () => { await expect(first).rejects.toThrow('Brunch WebSocket RPC protocol failure'); await expect(second).rejects.toThrow('Brunch WebSocket RPC protocol failure'); - await expect(client.request('workspace.snapshot')).rejects.toThrow( - 'Brunch WebSocket RPC protocol failure', - ); + await expect(client.request('workspace.state')).rejects.toThrow('Brunch WebSocket RPC protocol failure'); }); it('rejects all pending requests and later calls on unknown response IDs', async () => { const client = rpcClient(); - const first = client.request('workspace.snapshot'); + const first = client.request('workspace.state'); const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; @@ -183,14 +179,12 @@ describe('browser WebSocket RPC client', () => { await expect(first).rejects.toThrow('Brunch WebSocket RPC protocol failure'); await expect(second).rejects.toThrow('Brunch WebSocket RPC protocol failure'); - await expect(client.request('workspace.snapshot')).rejects.toThrow( - 'Brunch WebSocket RPC protocol failure', - ); + await expect(client.request('workspace.state')).rejects.toThrow('Brunch WebSocket RPC protocol failure'); }); it('rejects all pending requests on socket close', async () => { const client = rpcClient(); - const first = client.request('workspace.snapshot'); + const first = client.request('workspace.state'); const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; @@ -203,7 +197,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 first = client.request('workspace.state'); const second = client.request('session.exchanges'); const socket = FakeWebSocket.instances[0]!; @@ -213,14 +207,12 @@ describe('browser WebSocket RPC client', () => { await expect(first).rejects.toThrow('Brunch WebSocket RPC connection failed'); await expect(second).rejects.toThrow('Brunch WebSocket RPC connection failed'); - await expect(client.request('workspace.snapshot')).rejects.toThrow( - 'Brunch WebSocket RPC connection failed', - ); + await expect(client.request('workspace.state')).rejects.toThrow('Brunch WebSocket RPC connection failed'); }); it('exposes close and rejects later requests', async () => { const client = rpcClient(); - const pending = client.request('workspace.snapshot'); + const pending = client.request('workspace.state'); const socket = FakeWebSocket.instances[0]!; socket.emit('open'); @@ -228,6 +220,6 @@ describe('browser WebSocket RPC client', () => { expect(socket.closed).toBe(true); await expect(pending).rejects.toThrow('Brunch WebSocket RPC client closed'); - await expect(client.request('workspace.snapshot')).rejects.toThrow('Brunch WebSocket RPC client closed'); + await expect(client.request('workspace.state')).rejects.toThrow('Brunch WebSocket RPC client closed'); }); }); diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts index 8c0dcac00..ad27d070b 100644 --- a/src/web/subscriptions/brunch-updates.ts +++ b/src/web/subscriptions/brunch-updates.ts @@ -53,8 +53,8 @@ export function invalidateBrunchUpdate( } function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate): void { - if (update.topic === 'workspace.snapshot') { - invalidateExact(queryClient, queryKeys.workspace.snapshot()); + if (update.topic === 'workspace.state') { + invalidateExact(queryClient, queryKeys.workspace.state()); return; } if (update.topic === 'graph.overview' && typeof update.specId === 'number') { @@ -77,8 +77,8 @@ function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate } function invalidateTopic(queryClient: QueryClient, topic: string): void { - if (topic === 'workspace.snapshot') { - invalidateExact(queryClient, queryKeys.workspace.snapshot()); + if (topic === 'workspace.state') { + invalidateExact(queryClient, queryKeys.workspace.state()); return; } if (topic === 'session.runtimeState') { @@ -100,7 +100,7 @@ function invalidateExact(queryClient: QueryClient, queryKey: QueryKey): void { function isProductUpdate(value: unknown): value is ProductUpdate { if (!isRecord(value)) return false; - if (value.topic === 'workspace.snapshot') return true; + if (value.topic === 'workspace.state') return true; if (value.topic === 'graph.overview') return typeof value.specId === 'number'; if (value.topic === 'graph.nodeNeighborhood') { return typeof value.specId === 'number' && typeof value.nodeId === 'number';