diff --git a/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-snapshot.json b/.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-overview.json similarity index 100% rename from .fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-snapshot.json rename to .fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-overview.json 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 36095a7cd..eff0af5dc 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 @@ -72,6 +72,6 @@ "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" + "graphOverviewJson": "runs/fixture-curation/fixture-curation-2026-06-05T104440Z/graph-overview.json" } } diff --git a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-overview.json similarity index 99% rename from .fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json rename to .fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-overview.json index 8ffe2bdca..64594eb3f 100644 --- a/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-snapshot.json +++ b/.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-overview.json @@ -1075,4 +1075,3 @@ "edgeCount": 10, "lsn": 4 } - 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 61c617085..a6437c838 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 @@ -85,7 +85,7 @@ ], "productUpdates": [ { - "topic": "workspace.snapshot", + "topic": "workspace.state", "specId": 1, "sessionId": "019e9d3e-7f21-76ae-bbc2-2955f779cdac" }, @@ -121,6 +121,6 @@ "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" + "graphOverviewJson": "runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/graph-overview.json" } } diff --git a/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts b/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts index 41e0b22d9..726f8919f 100644 --- a/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts +++ b/.fixtures/seeds/bilal-port/duplicate-edge-policy.ts @@ -14,7 +14,7 @@ * counted so the porter can keep duplicate-drop stats visible. */ -export type SeedPortEdgeOrigin = 'source' | 'synthetic'; + type SeedPortEdgeOrigin = 'source' | 'synthetic'; /** The fields that identify an edge for duplicate detection. */ export interface SeedEdgeIdentity { diff --git a/.fixtures/seeds/brunch-self/README.md b/.fixtures/seeds/brunch-self/README.md new file mode 100644 index 000000000..d0e18e39e --- /dev/null +++ b/.fixtures/seeds/brunch-self/README.md @@ -0,0 +1,30 @@ +# `.fixtures/seeds/brunch-self/` + +A **faithful** spec graph hand-derived from this repository's own planning prose +(`memory/SPEC.md` + `memory/PLAN.md`), as opposed to the synthetic +coverage/edge-spread fixtures. + +Purpose: + +- prove the whole loop end-to-end: real prose → graph fixture → the real + propose-graph validator (`seedFixture` → `CommandExecutor`) → renderers +- give the renderers a realistic, all-planes anchor to project from, with + meaningful titles and rationales instead of synthetic placeholders +- serve as the worked example / template for porting other projects' spec/plan + docs into structurally-legal seed graphs + +Coverage (a by-product of being faithful, not the goal): + +- every node kind across all four planes (intent / oracle / design / plan) +- every edge category (dependency, proof, support, realization, boundary, + composition, association, supersession), including both proof/support stances +- one supersession lineage (per-strategy offer-first supersedes the retired + universal per-turn ritual) + +Contents: + +- `spec-graph.json` — one `planning_ready` spec describing Brunch itself. + +Structural legality is enforced by the seed loader: `spec-graph` is committed +through `CommandExecutor` by `src/renderers/graph/previews.test.ts`, which fails +if any node/edge is structurally illegal. diff --git a/.fixtures/seeds/brunch-self/spec-graph.json b/.fixtures/seeds/brunch-self/spec-graph.json new file mode 100644 index 000000000..e3a661d02 --- /dev/null +++ b/.fixtures/seeds/brunch-self/spec-graph.json @@ -0,0 +1,104 @@ +{ + "spec": { + "slug": "brunch-self", + "name": "Brunch (self-described spec graph)", + "readiness_grade": "planning_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Build Brunch as a local spec-elicitation product layered on Pi without forking it", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 2, "plane": "intent", "kind": "goal", "title": "Surface cross-session graph changes to the agent coherently at turn boundaries", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 3, "plane": "intent", "kind": "thesis", "title": "Offer-first structured exchange elicits better spec truth than free-form chat", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 4, "plane": "intent", "kind": "thesis", "title": "Pi's linear JSONL transcript can be the single canonical session substrate", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 5, "plane": "intent", "kind": "term", "title": "Spec", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "A user-created elicitation subject: one graph of intent/oracle/design/plan truth with its own spec-local LSN clock.", "aliases": ["selected spec"] } }, + { "local_id": 6, "plane": "intent", "kind": "term", "title": "Session exchange", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The unit spanning one structured offer-and-response (or a plain user message) projected from the linear transcript." } }, + { "local_id": 7, "plane": "intent", "kind": "term", "title": "Lens", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The topical-focus axis of the session agent (intent / design / oracle).", "aliases": ["topical focus"] } }, + { "local_id": 8, "plane": "intent", "kind": "term", "title": "Strategy", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The reusable interaction-shape axis of the session agent.", "aliases": ["interaction shape"] } }, + { "local_id": 9, "plane": "intent", "kind": "term", "title": "Readiness grade", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "A forward gate over a spec, advancing grounding to elicitation to commitment to planning.", "aliases": ["grade"] } }, + { "local_id": 10, "plane": "intent", "kind": "context", "title": "Pi supplies the TUI harness, JSONL sessions, and extension hooks Brunch builds on", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 11, "plane": "intent", "kind": "context", "title": "Stakeholders want the TUI and web to share one data plane, not two", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 12, "plane": "intent", "kind": "context", "title": "A minority view wants Brunch to fork Pi for deeper control", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 13, "plane": "intent", "kind": "requirement", "title": "All durable graph mutations route through one CommandExecutor authority", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 14, "plane": "intent", "kind": "requirement", "title": "A public RPC agent-as-user can drive structured exchanges without speaking raw Pi RPC", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 15, "plane": "intent", "kind": "requirement", "title": "Graph context reads support a compact overview and a node-neighborhood detail view", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 16, "plane": "intent", "kind": "requirement", "title": "Spec/session selection is a reusable hierarchical decision model", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 17, "plane": "intent", "kind": "requirement", "title": "Every session turn must follow the offer-first present/request ritual", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 18, "plane": "intent", "kind": "requirement", "title": "Offer-first applies per strategy, not as a universal per-turn session invariant", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 19, "plane": "intent", "kind": "assumption", "title": "Pi linear JSONL sessions suffice as transcript truth for the POC", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 20, "plane": "intent", "kind": "assumption", "title": "Local POC graph and session sizes stay small enough to defer performance budgets", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 21, "plane": "intent", "kind": "constraint", "title": "Brunch must not fork Pi", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 22, "plane": "intent", "kind": "constraint", "title": "A Brunch-launched Pi runtime must not load ambient user/project .pi resources", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 23, "plane": "intent", "kind": "constraint", "title": "The browser must not require a second primary data plane", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 24, "plane": "intent", "kind": "invariant", "title": "One spec-local LSN per commit; exactly one graph_clock row per spec", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 25, "plane": "intent", "kind": "invariant", "title": "commitGraph batch validation is all-or-nothing", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 26, "plane": "intent", "kind": "invariant", "title": "Same-spec supersession edges form an acyclic directed graph", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 27, "plane": "intent", "kind": "invariant", "title": "Node kind is a per-plane closed enum validated by the CommandExecutor", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 28, "plane": "intent", "kind": "decision", "title": "Adopt a single CommandExecutor mutation authority", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "Route every durable mutation through one CommandExecutor command boundary", "rejected": ["Let callers write the graph store directly", "Per-feature mutation helpers"], "rationale": "One authority centralizes validation, audit, LSN allocation, and coherence triggering." } }, + { "local_id": 29, "plane": "intent", "kind": "decision", "title": "Split the session agent into orthogonal Strategy and Lens axes", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "Two orthogonal axes: Strategy (interaction shape) and Lens (topical focus)", "rejected": ["A single flat free-text elicitation-lens catalogue"], "rationale": "Orthogonal axes compose cleanly and keep routing legible." } }, + { "local_id": 30, "plane": "intent", "kind": "decision", "title": "Compose prompts as a thin runtime header plus a gated resource manifest", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "Thin runtime header + gated manifest; bodies loaded on demand by read", "rejected": ["Eager concatenation of every objective pack"], "rationale": "Projection over a state machine keeps prompts small and legal per turn." } }, + { "local_id": 31, "plane": "intent", "kind": "decision", "title": "Add freestyle as a structure-optional strategy that AUTO must never select", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "freestyle is an explicit user pin only; AUTO omits it", "rejected": ["Let AUTO enter freestyle", "Make freestyle a new operational mode"], "rationale": "Spontaneous AUTO entry would silently abandon the offer-first product thesis." } }, + { "local_id": 32, "plane": "intent", "kind": "criterion", "title": "After TUI interaction, .brunch/ exists with exactly one session_binding per session", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 33, "plane": "intent", "kind": "criterion", "title": "Dry-run validation at proposal time matches real-run validation at acceptance", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 34, "plane": "intent", "kind": "example", "title": "An offline / network-outage scenario the offline-first stance must withstand", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 35, "plane": "intent", "kind": "example", "title": "A proposal that fails dry-run never surfaces as a reviewable review set", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 36, "plane": "oracle", "kind": "check", "title": "Architectural boundary test: no db/ imports outside graph/", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 37, "plane": "oracle", "kind": "check", "title": "commit-graph-batch structural tests: kind, stance, self-loop, acyclic supersession", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 38, "plane": "oracle", "kind": "validation_method", "title": "Deterministic public-RPC parity probe (scripted agent-as-user)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 39, "plane": "oracle", "kind": "validation_method", "title": "Transcript-backed probe runs with executable postcondition checkers", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 40, "plane": "oracle", "kind": "evidence", "title": "FE-744 public-RPC parity run: session.jsonl + transcript.md + report.json", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 41, "plane": "oracle", "kind": "evidence", "title": "FE-809 project-graph review-cycle approval run with explicit-basis readback", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 42, "plane": "oracle", "kind": "obligation", "title": "Structural invariants stay hard gates; behavioral metrics are tracked as fitness, not gated", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 43, "plane": "design", "kind": "module", "title": "CommandExecutor — the graph mutation authority", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 44, "plane": "design", "kind": "module", "title": ".pi/agents/compose — runtime header plus gated prompt-resource manifest", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 45, "plane": "design", "kind": "module", "title": "graph/queries — typed read layer (overview and neighborhood)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 46, "plane": "design", "kind": "module", "title": "renderers/graph — projects typed graph reads into model-facing text", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 47, "plane": "design", "kind": "interface", "title": "Public Brunch JSON-RPC session.* methods", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 48, "plane": "design", "kind": "interface", "title": "commit_graph agent-facing tool schema", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 49, "plane": "plan", "kind": "milestone", "title": "M0 — Workspace and session bootstrap with the first probe oracle", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 50, "plane": "plan", "kind": "milestone", "title": "M3 — Public RPC and structured-exchange parity", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 51, "plane": "plan", "kind": "milestone", "title": "M5 — Graph context read and render projection", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 52, "plane": "plan", "kind": "frontier", "title": "Graph read/render projection context layer", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 53, "plane": "plan", "kind": "frontier", "title": "Structured-exchange public-RPC parity", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 54, "plane": "plan", "kind": "slice", "title": "node-neighborhood renderer with anchor-relative projection", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 55, "plane": "plan", "kind": "slice", "title": "consolidate edge-category policy; add label and direction projections", "basis": "explicit", "source": "memory/PLAN.md" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 5, "target_local_id": 13, "basis": "explicit", "rationale": "The one-authority requirement is stated over the Spec's mutation surface." }, + { "category": "dependency", "source_local_id": 5, "target_local_id": 16, "basis": "explicit", "rationale": "Selection resolves a Spec, so it depends on the Spec definition." }, + { "category": "dependency", "source_local_id": 43, "target_local_id": 47, "basis": "explicit", "rationale": "The public JSON-RPC surface drives mutations through the CommandExecutor." }, + { "category": "dependency", "source_local_id": 45, "target_local_id": 46, "basis": "explicit", "rationale": "Renderers consume the typed read layer." }, + { "category": "dependency", "source_local_id": 45, "target_local_id": 44, "basis": "explicit", "rationale": "Prompt composition pushes context built from the typed reads." }, + + { "category": "realization", "source_local_id": 13, "target_local_id": 43, "basis": "explicit", "rationale": "CommandExecutor implements the one-authority requirement." }, + { "category": "realization", "source_local_id": 14, "target_local_id": 47, "basis": "explicit", "rationale": "The session.* RPC methods realize the public-RPC requirement." }, + { "category": "realization", "source_local_id": 15, "target_local_id": 45, "basis": "explicit", "rationale": "The query layer implements the overview/neighborhood read requirement." }, + { "category": "realization", "source_local_id": 15, "target_local_id": 46, "basis": "explicit", "rationale": "The renderers implement the render half of the read requirement." }, + { "category": "realization", "source_local_id": 15, "target_local_id": 54, "basis": "explicit", "rationale": "The neighborhood-renderer slice establishes the detail-view requirement." }, + { "category": "realization", "source_local_id": 24, "target_local_id": 13, "basis": "explicit", "rationale": "The one-authority requirement expresses the spec-local LSN invariant." }, + + { "category": "boundary", "source_local_id": 21, "target_local_id": 1, "basis": "explicit", "rationale": "The no-fork constraint bounds how the build-over-Pi goal may be met." }, + { "category": "boundary", "source_local_id": 22, "target_local_id": 44, "basis": "explicit", "rationale": "The sealing constraint bounds what prompt composition may load." }, + { "category": "boundary", "source_local_id": 23, "target_local_id": 14, "basis": "explicit", "rationale": "The single-data-plane constraint bounds the public-RPC requirement." }, + + { "category": "composition", "source_local_id": 50, "target_local_id": 53, "basis": "explicit", "rationale": "M3 contains the public-RPC parity frontier." }, + { "category": "composition", "source_local_id": 51, "target_local_id": 52, "basis": "explicit", "rationale": "M5 contains the read/render projection frontier." }, + { "category": "composition", "source_local_id": 52, "target_local_id": 54, "basis": "explicit", "rationale": "The read/render frontier contains the neighborhood-renderer slice." }, + { "category": "composition", "source_local_id": 52, "target_local_id": 55, "basis": "explicit", "rationale": "The read/render frontier contains the edge-policy projection slice." }, + + { "category": "association", "source_local_id": 7, "target_local_id": 8, "basis": "explicit", "rationale": "Lens and Strategy are orthogonal peer axes of the session agent." }, + { "category": "association", "source_local_id": 3, "target_local_id": 31, "basis": "explicit", "rationale": "The freestyle decision sits in tension with the offer-first thesis." }, + + { "category": "supersession", "source_local_id": 18, "target_local_id": 17, "basis": "explicit", "rationale": "Per-strategy offer-first supersedes the universal per-turn ritual requirement." }, + + { "category": "proof", "source_local_id": 36, "target_local_id": 13, "stance": "for", "basis": "explicit", "rationale": "The boundary test witnesses the one-authority requirement." }, + { "category": "proof", "source_local_id": 37, "target_local_id": 25, "stance": "for", "basis": "explicit", "rationale": "The commit-batch tests witness all-or-nothing batch validation." }, + { "category": "proof", "source_local_id": 37, "target_local_id": 27, "stance": "for", "basis": "explicit", "rationale": "The commit-batch tests witness per-plane kind validation." }, + { "category": "proof", "source_local_id": 40, "target_local_id": 14, "stance": "for", "basis": "explicit", "rationale": "The FE-744 run witnesses the public-RPC requirement." }, + { "category": "proof", "source_local_id": 41, "target_local_id": 33, "stance": "for", "basis": "explicit", "rationale": "The FE-809 run witnesses dry-run / real-run validation parity." }, + + { "category": "support", "source_local_id": 10, "target_local_id": 1, "stance": "for", "basis": "explicit", "rationale": "Pi's harness motivates building Brunch over Pi." }, + { "category": "support", "source_local_id": 11, "target_local_id": 23, "stance": "for", "basis": "explicit", "rationale": "The shared-data-plane preference motivates the single-plane constraint." }, + { "category": "support", "source_local_id": 2, "target_local_id": 15, "stance": "for", "basis": "explicit", "rationale": "The coherence goal motivates the overview/neighborhood read requirement." }, + { "category": "support", "source_local_id": 19, "target_local_id": 4, "stance": "for", "basis": "explicit", "rationale": "The JSONL-suffices assumption supports the single-substrate thesis." }, + { "category": "support", "source_local_id": 12, "target_local_id": 21, "stance": "against", "basis": "explicit", "rationale": "The fork-Pi minority view argues against the no-fork constraint." } + ] +} diff --git a/.fixtures/seeds/dumpchat/README.md b/.fixtures/seeds/dumpchat/README.md new file mode 100644 index 000000000..04bb9e5fa --- /dev/null +++ b/.fixtures/seeds/dumpchat/README.md @@ -0,0 +1,46 @@ +# `.fixtures/seeds/dumpchat/` + +A spec graph hand-derived from the **dumpchat** project +(`/Users/lunelson/Code/lunelson/dumpchat`), a WXT browser extension that exports +ChatGPT / Claude / Perplexity conversations to Markdown via each platform's +native per-turn copy buttons. + +Faithful vs. projected: + +- **intent** plane — substantially **faithful** to `docs/SPEC.md` and `README.md`: + the copy-button thesis, the four-step extraction flow, index alternation, + depth filtering, selector-stability constraints, and the Verify Export + diagnostics requirement are all drawn from real prose. +- **design** plane — **faithful**: nodes map to actual modules + (`dumpchat.content.ts`, `lib/dumpchat/extraction.ts`, `config.ts`, `sites/*`) + and the `SiteConfig` type contract. +- **oracle** plane — **mixed**: the `extraction.test.ts` check and the in-page + Verify Export run / diagnostics JSON are real; the per-platform re-verification + obligation is **projected**. +- **plan** plane — **substantially projected**: the source has no plan doc, so + milestone / frontier / slice nodes are plausible projections from the intent, + marked `source: "projected"`. + +`readiness_grade` is `commitments_ready`: the source spec commits firmly to +decisions, invariants, and selector policy, but carries no explicit plan. + +Coverage (a by-product of being faithful, not the goal): + +- all four planes (intent / oracle / design / plan) and every node kind used in + the intent plane +- every edge category (dependency, realization, boundary, composition, + association, supersession, proof, support), including both proof/support + stances +- one supersession lineage: depth-based separation and the modal-depth filter + decision supersede the retired `button.closest("pre, code")` check + +Contents: + +- `spec-graph.json` — 41 nodes / 33 edges (40 / 31 in active context after the + superseded predecessor is hidden). + +Validate with: + +``` +npx tsx src/graph/validate-fixture.ts dumpchat/spec-graph +``` diff --git a/.fixtures/seeds/dumpchat/spec-graph.json b/.fixtures/seeds/dumpchat/spec-graph.json new file mode 100644 index 000000000..847a449c9 --- /dev/null +++ b/.fixtures/seeds/dumpchat/spec-graph.json @@ -0,0 +1,95 @@ +{ + "spec": { + "slug": "dumpchat", + "name": "Dumpchat (chat-export browser extension spec graph)", + "readiness_grade": "commitments_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Export AI chat conversations to a Markdown file from supported platforms", "basis": "explicit", "source": "README.md" }, + { "local_id": 2, "plane": "intent", "kind": "goal", "title": "Keep new-platform support cheap: one primary selector plus text fallbacks per site", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 3, "plane": "intent", "kind": "thesis", "title": "Per-turn copy buttons are the only reliable, load-bearing selectors for extraction", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 4, "plane": "intent", "kind": "thesis", "title": "Index-based alternation from a user-first conversation reliably classifies turn roles", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 5, "plane": "intent", "kind": "term", "title": "copyButtonSelector", "basis": "explicit", "source": "docs/SPEC.md", "detail": { "definition": "The per-platform CSS selector matching each conversation turn's native copy button; the single required, load-bearing selector for extraction.", "aliases": ["copy button selector"] } }, + { "local_id": 6, "plane": "intent", "kind": "term", "title": "SiteConfig", "basis": "explicit", "source": "docs/SPEC.md", "detail": { "definition": "The per-platform selector bundle (copy/message/title/edit selectors plus a conversation-path matcher) keyed by Site.", "aliases": ["site config"] } }, + { "local_id": 7, "plane": "intent", "kind": "term", "title": "Diagnostic report", "basis": "explicit", "source": "README.md", "detail": { "definition": "The JSON health report produced by Verify Export, recording selector counts, extraction results, detected issues, and a health level.", "aliases": ["diagnostics report"] } }, + { "local_id": 8, "plane": "intent", "kind": "context", "title": "Target platforms are ChatGPT, Claude, and Perplexity conversation pages", "basis": "explicit", "source": "README.md" }, + { "local_id": 9, "plane": "intent", "kind": "context", "title": "Chat-platform DOMs change frequently and break structural selectors", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 10, "plane": "intent", "kind": "requirement", "title": "Extraction must succeed when copy buttons are present and fail clearly when they are absent", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 11, "plane": "intent", "kind": "requirement", "title": "Turn-level copy buttons must be separated from nested code-block copy buttons", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 12, "plane": "intent", "kind": "requirement", "title": "Each extracted turn must be classified as a user or assistant turn", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 13, "plane": "intent", "kind": "requirement", "title": "Clipboard capture must fall back to DOM text extraction when it fails", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 14, "plane": "intent", "kind": "requirement", "title": "Adding a platform must require only a working copyButtonSelector and text fallbacks", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 15, "plane": "intent", "kind": "requirement", "title": "Export must download Markdown with title, source URL, timestamp, and XML-style turn markers", "basis": "explicit", "source": "README.md" }, + { "local_id": 16, "plane": "intent", "kind": "requirement", "title": "Verify Export must produce a downloadable JSON diagnostics report and an in-page health badge", "basis": "explicit", "source": "README.md" }, + { "local_id": 17, "plane": "intent", "kind": "requirement", "title": "(Retired) Discard code-block copy buttons by testing button.closest(\"pre, code\")", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 18, "plane": "intent", "kind": "assumption", "title": "Conversations always start with a user turn", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 19, "plane": "intent", "kind": "assumption", "title": "Turn-level copy buttons sit at a consistent DOM depth across all matched buttons", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 20, "plane": "intent", "kind": "constraint", "title": "Prefer data-testid attributes; avoid Tailwind/utility classes as selectors", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 21, "plane": "intent", "kind": "constraint", "title": "Avoid deeply structural selectors that depend on nesting or sibling relationships", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 22, "plane": "intent", "kind": "invariant", "title": "Even-indexed copy buttons are user turns; odd-indexed are assistant turns", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 23, "plane": "intent", "kind": "invariant", "title": "The depth filter and alternation logic are platform-agnostic; only the copyButtonSelector and text fallbacks are platform-specific", "basis": "explicit", "source": "docs/SPEC.md" }, + { "local_id": 24, "plane": "intent", "kind": "decision", "title": "Use native copy buttons as the primary extraction path", "basis": "explicit", "source": "docs/SPEC.md", "detail": { "chosen_option": "Extract turn text by clicking each turn's native copy button and reading the clipboard", "rejected": ["Scrape message DOM nodes as the primary path", "Call platform or private APIs"], "rationale": "Native copy actions are explicitly maintained by each platform and yield clean, canonical message text." } }, + { "local_id": 25, "plane": "intent", "kind": "decision", "title": "Filter turn buttons by modal DOM depth instead of button.closest(\"pre, code\")", "basis": "explicit", "source": "docs/SPEC.md", "detail": { "chosen_option": "Keep copy buttons whose DOM depth is within a small tolerance of the modal depth across all matches", "rejected": ["Test each button with button.closest('pre, code')", "Maintain per-platform code-block exclusion selectors"], "rationale": "A platform-agnostic depth filter discards nested code-block copy buttons without brittle structural checks." } }, + { "local_id": 26, "plane": "intent", "kind": "decision", "title": "Classify turn roles primarily by index alternation, with hints only confirming", "basis": "explicit", "source": "docs/SPEC.md", "detail": { "chosen_option": "Classify turns by button index parity (even=user, odd=assistant) and use platform hints only to confirm", "rejected": ["Rely on data-testid or aria role hints as the primary classifier"], "rationale": "Conversations always start with a user turn, so index parity is universal while hints are platform-specific and often absent." } }, + { "local_id": 27, "plane": "intent", "kind": "criterion", "title": "On a supported page, every classified turn yields non-empty extracted text via clipboard or fallback", "basis": "explicit", "source": "projected" }, + { "local_id": 28, "plane": "intent", "kind": "example", "title": "A code-block copy button nested deep inside an assistant response that must be discarded", "basis": "explicit", "source": "docs/SPEC.md" }, + + { "local_id": 29, "plane": "oracle", "kind": "check", "title": "extraction.test.ts: jsdom unit tests over filterByConsistentDepth and index alternation", "basis": "explicit", "source": "entrypoints/lib/dumpchat/extraction.test.ts" }, + { "local_id": 30, "plane": "oracle", "kind": "validation_method", "title": "In-page Verify Export diagnostics run against a live conversation page", "basis": "explicit", "source": "README.md" }, + { "local_id": 31, "plane": "oracle", "kind": "evidence", "title": "Downloaded diagnostics JSON (schema chat-export-diagnostics 1.0.0) with counts and health level", "basis": "explicit", "source": "README.md" }, + { "local_id": 32, "plane": "oracle", "kind": "obligation", "title": "Selectors must be re-verified per platform after each frontend change", "basis": "explicit", "source": "projected" }, + + { "local_id": 33, "plane": "design", "kind": "module", "title": "dumpchat.content.ts — content script injecting UI and orchestrating export/verify", "basis": "explicit", "source": "entrypoints/dumpchat.content.ts" }, + { "local_id": 34, "plane": "design", "kind": "module", "title": "lib/dumpchat/extraction.ts — four-step extraction and diagnostic report builder", "basis": "explicit", "source": "entrypoints/lib/dumpchat/extraction.ts" }, + { "local_id": 35, "plane": "design", "kind": "module", "title": "lib/dumpchat/config.ts — SITE_CONFIG, detectSite, isConversationPage", "basis": "explicit", "source": "entrypoints/lib/dumpchat/config.ts" }, + { "local_id": 36, "plane": "design", "kind": "module", "title": "lib/dumpchat/sites/* — per-platform site modules (chatgpt, claude, perplexity)", "basis": "explicit", "source": "entrypoints/lib/dumpchat/sites" }, + { "local_id": 37, "plane": "design", "kind": "interface", "title": "SiteConfig — the per-platform selector-bundle type contract", "basis": "explicit", "source": "entrypoints/lib/dumpchat/types.ts" }, + + { "local_id": 38, "plane": "plan", "kind": "milestone", "title": "M1 — Core extraction loop across the three launch platforms", "basis": "explicit", "source": "projected" }, + { "local_id": 39, "plane": "plan", "kind": "frontier", "title": "Platform-agnostic extraction core (depth filter + alternation + capture)", "basis": "explicit", "source": "projected" }, + { "local_id": 40, "plane": "plan", "kind": "slice", "title": "filterByConsistentDepth modal-depth tolerance filter", "basis": "explicit", "source": "projected" }, + { "local_id": 41, "plane": "plan", "kind": "slice", "title": "Clipboard capture with DOM-text fallback per turn", "basis": "explicit", "source": "projected" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 35, "target_local_id": 34, "basis": "explicit", "rationale": "extraction.ts depends on config.ts for detectSite and SITE_CONFIG." }, + { "category": "dependency", "source_local_id": 34, "target_local_id": 33, "basis": "explicit", "rationale": "The content script depends on extraction.ts to collect export data." }, + { "category": "dependency", "source_local_id": 37, "target_local_id": 36, "basis": "explicit", "rationale": "Per-platform site modules depend on the SiteConfig type contract." }, + { "category": "dependency", "source_local_id": 5, "target_local_id": 10, "basis": "explicit", "rationale": "The extraction-works requirement depends on the copyButtonSelector concept." }, + { "category": "dependency", "source_local_id": 19, "target_local_id": 25, "basis": "explicit", "rationale": "The modal-depth filter decision depends on the consistent-depth assumption." }, + { "category": "dependency", "source_local_id": 18, "target_local_id": 26, "basis": "explicit", "rationale": "The index-alternation decision depends on the user-first assumption." }, + + { "category": "realization", "source_local_id": 10, "target_local_id": 34, "basis": "explicit", "rationale": "extraction.ts realizes the extraction-works requirement." }, + { "category": "realization", "source_local_id": 16, "target_local_id": 34, "basis": "explicit", "rationale": "extraction.ts builds the diagnostic report, realizing the Verify Export requirement." }, + { "category": "realization", "source_local_id": 23, "target_local_id": 34, "basis": "explicit", "rationale": "extraction.ts hosts the platform-agnostic depth/alternation logic stated by the invariant." }, + { "category": "realization", "source_local_id": 14, "target_local_id": 36, "basis": "explicit", "rationale": "The per-platform site modules realize the cheap-platform-extension requirement." }, + { "category": "realization", "source_local_id": 11, "target_local_id": 40, "basis": "explicit", "rationale": "The depth-filter slice realizes the separate-turn-buttons requirement." }, + { "category": "realization", "source_local_id": 13, "target_local_id": 41, "basis": "explicit", "rationale": "The clipboard-fallback slice realizes the DOM-text-fallback requirement." }, + + { "category": "boundary", "source_local_id": 20, "target_local_id": 5, "basis": "explicit", "rationale": "The data-testid constraint bounds how copyButtonSelector may be chosen." }, + { "category": "boundary", "source_local_id": 20, "target_local_id": 37, "basis": "explicit", "rationale": "The data-testid constraint bounds the selectors allowed in a SiteConfig." }, + { "category": "boundary", "source_local_id": 21, "target_local_id": 37, "basis": "explicit", "rationale": "The anti-structural-selector constraint bounds the SiteConfig selector bundle." }, + + { "category": "composition", "source_local_id": 38, "target_local_id": 39, "basis": "explicit", "rationale": "M1 contains the platform-agnostic extraction-core frontier." }, + { "category": "composition", "source_local_id": 39, "target_local_id": 40, "basis": "explicit", "rationale": "The extraction-core frontier contains the depth-filter slice." }, + { "category": "composition", "source_local_id": 39, "target_local_id": 41, "basis": "explicit", "rationale": "The extraction-core frontier contains the clipboard-fallback slice." }, + + { "category": "association", "source_local_id": 5, "target_local_id": 6, "basis": "explicit", "rationale": "copyButtonSelector and SiteConfig are peer per-platform configuration concepts." }, + { "category": "association", "source_local_id": 24, "target_local_id": 26, "basis": "explicit", "rationale": "Native-copy and index-alternation are peer extraction design choices." }, + { "category": "association", "source_local_id": 29, "target_local_id": 30, "basis": "explicit", "rationale": "The jsdom unit check and the live diagnostics run are peer verification methods." }, + + { "category": "supersession", "source_local_id": 11, "target_local_id": 17, "basis": "explicit", "rationale": "Depth-based separation supersedes the brittle closest('pre, code') check." }, + { "category": "supersession", "source_local_id": 25, "target_local_id": 17, "basis": "explicit", "rationale": "The modal-depth filter decision supersedes the closest('pre, code') approach." }, + + { "category": "proof", "source_local_id": 29, "target_local_id": 22, "stance": "for", "basis": "explicit", "rationale": "The alternation unit tests witness the even=user / odd=assistant invariant." }, + { "category": "proof", "source_local_id": 29, "target_local_id": 11, "stance": "for", "basis": "explicit", "rationale": "The filterByConsistentDepth tests witness separation of turn-level and code-block buttons." }, + { "category": "proof", "source_local_id": 31, "target_local_id": 16, "stance": "for", "basis": "explicit", "rationale": "The downloaded diagnostics JSON evidences the Verify Export requirement." }, + { "category": "proof", "source_local_id": 31, "target_local_id": 10, "stance": "for", "basis": "explicit", "rationale": "The diagnostics counts evidence that extraction succeeds when copy buttons are present." }, + + { "category": "support", "source_local_id": 3, "target_local_id": 10, "stance": "for", "basis": "explicit", "rationale": "The copy-buttons-are-reliable thesis motivates the extraction-works requirement." }, + { "category": "support", "source_local_id": 4, "target_local_id": 12, "stance": "for", "basis": "explicit", "rationale": "The alternation thesis motivates the classify-each-turn requirement." }, + { "category": "support", "source_local_id": 18, "target_local_id": 4, "stance": "for", "basis": "explicit", "rationale": "The user-first assumption supports the alternation thesis." }, + { "category": "support", "source_local_id": 9, "target_local_id": 20, "stance": "for", "basis": "explicit", "rationale": "Frequently-changing DOMs motivate preferring stable data-testid selectors." }, + { "category": "support", "source_local_id": 28, "target_local_id": 11, "stance": "for", "basis": "explicit", "rationale": "The deep code-block example illustrates why turn buttons must be separated from code-block buttons." }, + { "category": "support", "source_local_id": 32, "target_local_id": 3, "stance": "against", "basis": "explicit", "rationale": "The ongoing per-platform re-verification obligation tempers the claim that copy-button selectors are permanently reliable." } + ] +} diff --git a/.fixtures/seeds/edge-spread/hub-neighborhood.json b/.fixtures/seeds/edge-spread/hub-neighborhood.json new file mode 100644 index 000000000..977f803e1 --- /dev/null +++ b/.fixtures/seeds/edge-spread/hub-neighborhood.json @@ -0,0 +1,39 @@ +{ + "spec": { + "slug": "hub-neighborhood", + "name": "Hub Neighborhood", + "readiness_grade": "commitments_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "requirement", "title": "Stage 2 configuration-space requirement (hub anchor)", "basis": "explicit", "source": "fixture" }, + { "local_id": 2, "plane": "intent", "kind": "assumption", "title": "Local-only execution assumption", "basis": "explicit", "source": "fixture" }, + { "local_id": 3, "plane": "intent", "kind": "invariant", "title": "No network call invariant", "basis": "explicit", "source": "fixture" }, + { "local_id": 4, "plane": "intent", "kind": "constraint", "title": "No cloud dependencies constraint", "basis": "explicit", "source": "fixture" }, + { "local_id": 5, "plane": "intent", "kind": "decision", "title": "Two-stage split decision", "basis": "explicit", "source": "fixture", "detail": { "chosen_option": "Split fan-in into Stage 1 extraction and Stage 2 solving", "rejected": ["Keep a single fan-in stage"], "rationale": "A hard schema boundary keeps solver outputs out of Stage 1." } }, + { "local_id": 6, "plane": "design", "kind": "module", "title": "SQLite configuration store module", "basis": "explicit", "source": "fixture" }, + { "local_id": 7, "plane": "plan", "kind": "slice", "title": "Persist configuration spaces slice", "basis": "explicit", "source": "fixture" }, + { "local_id": 8, "plane": "intent", "kind": "criterion", "title": "Airplane-mode acceptance criterion", "basis": "explicit", "source": "fixture" }, + { "local_id": 9, "plane": "intent", "kind": "example", "title": "Network-outage counterexample", "basis": "explicit", "source": "fixture" }, + { "local_id": 10, "plane": "intent", "kind": "context", "title": "Stakeholder offline-first preference", "basis": "explicit", "source": "fixture" }, + { "local_id": 11, "plane": "intent", "kind": "context", "title": "Conflicting always-connected note", "basis": "explicit", "source": "fixture" }, + { "local_id": 12, "plane": "plan", "kind": "frontier", "title": "Configuration-space data frontier", "basis": "explicit", "source": "fixture" }, + { "local_id": 13, "plane": "intent", "kind": "requirement", "title": "Revised configuration-space requirement (successor)", "basis": "explicit", "source": "fixture" }, + { "local_id": 14, "plane": "intent", "kind": "goal", "title": "Offline-first product goal", "basis": "explicit", "source": "fixture" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 2, "target_local_id": 1, "basis": "explicit" }, + { "category": "realization", "source_local_id": 3, "target_local_id": 1, "basis": "explicit" }, + { "category": "boundary", "source_local_id": 4, "target_local_id": 1, "basis": "explicit" }, + { "category": "dependency", "source_local_id": 1, "target_local_id": 5, "basis": "explicit" }, + { "category": "realization", "source_local_id": 1, "target_local_id": 6, "basis": "explicit" }, + { "category": "realization", "source_local_id": 1, "target_local_id": 7, "basis": "explicit" }, + { "category": "proof", "source_local_id": 8, "target_local_id": 1, "stance": "for", "basis": "explicit" }, + { "category": "proof", "source_local_id": 9, "target_local_id": 1, "stance": "against", "basis": "explicit" }, + { "category": "support", "source_local_id": 10, "target_local_id": 1, "stance": "for", "basis": "explicit" }, + { "category": "support", "source_local_id": 11, "target_local_id": 1, "stance": "against", "basis": "explicit" }, + { "category": "composition", "source_local_id": 12, "target_local_id": 1, "basis": "explicit" }, + { "category": "supersession", "source_local_id": 13, "target_local_id": 1, "basis": "explicit" }, + { "category": "association", "source_local_id": 1, "target_local_id": 14, "basis": "explicit" }, + { "category": "association", "source_local_id": 2, "target_local_id": 4, "basis": "explicit" } + ] +} diff --git a/.fixtures/seeds/fable/README.md b/.fixtures/seeds/fable/README.md new file mode 100644 index 000000000..211539413 --- /dev/null +++ b/.fixtures/seeds/fable/README.md @@ -0,0 +1,47 @@ +# `.fixtures/seeds/fable/` + +A spec graph hand-derived from the **fable** project +(`/Users/lunelson/Code/lunelson/fable`), a Vite-native component workbench +(React-first) positioned as a thin successor to Ladle on Vite 8. + +Faithful vs. projected: + +- **intent** plane — substantially **faithful** to `memory/SPEC.md`: the + delegate-to-Vite and contact-surface theses, the lexicon terms (Ladle + watermark, normalized story graph, false-thinness, no-React invariant), the + config-composition / architecture-split / URL-backed-shell requirements, the + Vite-8-only and no-merge constraints, the Shape D / window-event / config + composition decisions, and the acceptance criteria are all drawn from real + prose. +- **oracle** plane — **faithful**: nodes map to the actual probe harness in + `tools/verify.ts` (no-React and boundary seed-checks, manifest-no-story-import + guard, Playwright probes, the mount-id marker oracle, the six probe tiers) and + the recorded spike / slice-5c evidence. +- **design** plane — **faithful**: the five spec modules (Workbench Core, Vite + Host Binding, React Adapter, controls + source-view capabilities) and the two + interfaces (Framework Adapter Contract, window-event protocol). +- **plan** plane — **faithful** to `memory/ROADMAP.md`: milestones, frontiers, + and slices map to the real done/pending roadmap slices (config spike, walking + skeleton, manifest parity, preview mode, source view, watermark audit). + +`readiness_grade` is `planning_ready`: the source carries a committed SPEC plus +an ordered ROADMAP of done and pending slices. + +Coverage (a by-product of being faithful, not the goal): + +- every node kind across all four planes (intent / oracle / design / plan) +- every edge category (dependency, proof, support, realization, boundary, + composition, association, supersession), including both proof/support stances +- one supersession lineage: manifest-backed controls defaults (slice 4a) + supersede the earlier client-side `mod.args` reading (slice 3b) + +Contents: + +- `spec-graph.json` — 67 nodes / 37 edges (66 / 36 in active context after the + superseded predecessor is hidden). + +Validate with: + +``` +npx tsx src/graph/validate-fixture.ts fable/spec-graph +``` diff --git a/.fixtures/seeds/fable/spec-graph.json b/.fixtures/seeds/fable/spec-graph.json new file mode 100644 index 000000000..05362d54b --- /dev/null +++ b/.fixtures/seeds/fable/spec-graph.json @@ -0,0 +1,140 @@ +{ + "spec": { + "slug": "fable", + "name": "Fable (Vite-native component workbench)", + "readiness_grade": "planning_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Build a Vite-native component workbench with a small deep core and explicit framework adapters", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 2, "plane": "intent", "kind": "goal", "title": "Let a user run the workbench against an unmodified Vite 8 config", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 3, "plane": "intent", "kind": "thesis", "title": "Delegating the hard parts to Vite 8 (Rolldown, Oxc, Environment API) beats wrapping it", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 4, "plane": "intent", "kind": "thesis", "title": "Thinness is measured by the Vite contact surface, not by LoC or feature count", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 5, "plane": "intent", "kind": "term", "title": "Ladle watermark", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The invariant bundle of Ladle behaviors the successor must preserve: canonical story identity, metadata coherence, URL-backed shell state, lazy story loading, preview/full-shell mode, provider/decorator/args semantics, metadata export, and optional capability gating." } }, + { "local_id": 6, "plane": "intent", "kind": "term", "title": "Vite contact surface", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The enumerable set of Vite APIs, plugin hooks, config keys, and conventions the workbench depends on. The measurable dimension of 'thin'; tracked as a reviewable artifact in docs/vite-contact-surface.md.", "aliases": ["contact surface"] } }, + { "local_id": 7, "plane": "intent", "kind": "term", "title": "Normalized story graph", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The canonical internal model of stories, identities, hierarchy, provenance, metadata, and lazy runtime handles, authoritative across dev runtime, build outputs, and metadata export." } }, + { "local_id": 8, "plane": "intent", "kind": "term", "title": "Shell state", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "URL-backed workbench UI state such as selected story, mode, theme, source visibility, width, and controls.", "aliases": ["shell"] } }, + { "local_id": 9, "plane": "intent", "kind": "term", "title": "Capability plugin", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "An optional shell feature layered over the story graph and shell state; disabled capabilities impose no bundle, startup, or conceptual cost.", "aliases": ["capability"] } }, + { "local_id": 10, "plane": "intent", "kind": "term", "title": "False-thinness", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The anti-pattern of calling a tool a 'thin wrapper' while smuggling a full SPA, runtime, and shell through a plugin API. Thinness applies to the Vite contact surface, not to the workbench's own product scope." } }, + { "local_id": 11, "plane": "intent", "kind": "term", "title": "No-React invariant", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "The workbench depends on zero React packages; React is resolved at runtime from the user's node_modules, and static import of react/react-dom from product source is forbidden by ESLint and proven by a seed-check." } }, + + { "local_id": 12, "plane": "intent", "kind": "context", "title": "Vite 8 just shipped and Ladle does not integrate well with it", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 13, "plane": "intent", "kind": "context", "title": "The Vite framework-plugin ecosystem standardized on a thin-adapter-over-Vite pattern", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 14, "plane": "intent", "kind": "context", "title": "Ladle is treated as the characterization corpus and current-state recovery (companion liftout)", "basis": "explicit", "source": "memory/LIFTOUT.md" }, + + { "local_id": 15, "plane": "intent", "kind": "requirement", "title": "Workbench consumes user Vite config via Vite's own resolution primitives, with no hand-rolled merge", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 16, "plane": "intent", "kind": "requirement", "title": "Architecture is explicitly split into core, Vite host binding, and framework adapter responsibilities", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 17, "plane": "intent", "kind": "requirement", "title": "Story discovery produces stable canonical IDs, hierarchical navigation data, provenance, and metadata", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 18, "plane": "intent", "kind": "requirement", "title": "The shell is URL-backed and can reproduce selected story and shell state from the URL", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 19, "plane": "intent", "kind": "requirement", "title": "Provide a machine-readable manifest/metadata surface coherent with runtime story identity", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 20, "plane": "intent", "kind": "requirement", "title": "Optional capabilities can be enabled or disabled without becoming mandatory core cost", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 21, "plane": "intent", "kind": "requirement", "title": "Story transitions must not leak story-scoped controls or other transient story-local state", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 22, "plane": "intent", "kind": "requirement", "title": "Controls defaults come from the manifest, not from mod.args on the dynamically-imported story module", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 23, "plane": "intent", "kind": "requirement", "title": "Controls defaults are read client-side from the dynamically-imported story module's args export", "basis": "explicit", "source": "memory/ROADMAP.md" }, + + { "local_id": 24, "plane": "intent", "kind": "assumption", "title": "Vite 8's Rolldown, Oxc, and Environment API are sufficient to make a genuinely thin workbench possible", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 25, "plane": "intent", "kind": "assumption", "title": "Vite's single-environment composition primitives suffice to layer over arbitrary user configs (validated)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 26, "plane": "intent", "kind": "assumption", "title": "Dev catalog reads can be memoized without making story metadata stale (validated)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 27, "plane": "intent", "kind": "assumption", "title": "A normalized story graph can stay framework-agnostic even when authoring syntax is adapter-specific", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 28, "plane": "intent", "kind": "constraint", "title": "The workbench supports Vite 8.x only; Vite 5/6/7 are never supported", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 29, "plane": "intent", "kind": "constraint", "title": "No hand-rolled Vite config merging; insufficient primitives are filed upstream, not worked around", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 30, "plane": "intent", "kind": "constraint", "title": "The workbench depends on zero React packages, enforced at the import site", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 31, "plane": "intent", "kind": "constraint", "title": "Preserve the user's Vite root; do not relocate root into node_modules the way Ladle does", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 32, "plane": "intent", "kind": "invariant", "title": "core/ must not import host, adapter, or cli (Shape D internal boundary)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 33, "plane": "intent", "kind": "invariant", "title": "Only the host mutates URL state; capabilities propose changes but cannot apply them", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 34, "plane": "intent", "kind": "invariant", "title": "Disabled capabilities are unreachable even by manual import", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 35, "plane": "intent", "kind": "decision", "title": "Adopt Shape D: single package with lint-enforced internal module split", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "One package (packages/fable/) with internal core/host/adapter/capabilities/cli directories whose import boundaries are ESLint-enforced", "rejected": ["Shape B monorepo of separate packages up front", "Flat single module with no enforced boundaries"], "rationale": "Single publish and one package.json keep shipping simple while lint boundaries keep the import graph already-correct for a future monorepo split." } }, + { "local_id": 36, "plane": "intent", "kind": "decision", "title": "Compose user config through Vite's own primitives, not a merge layer", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "loadConfigFromFile to obtain the user config, spread into inline config passed to createServer with configFile:false, append workbench plugins, preserve user root", "rejected": ["Hand-rolled merge of user config and workbench config into a third object", "Relocating root into node_modules like Ladle"], "rationale": "Vite's resolver and plugin-composition rules keep the contact surface small and the workbench robust to Vite updates." } }, + { "local_id": 37, "plane": "intent", "kind": "decision", "title": "Use a window-event protocol as the single host-capability seam", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "fable:story-change and fable:shell-mode-change (host to capability) plus fable:navigate-args (capability to host), with strict listen/dispatch asymmetry", "rejected": ["A shared shell-state module graph crossing the capability boundary", "Direct capability mutation of URL state"], "rationale": "Events are a single typed seam that keeps capabilities decoupled while only the host applies state changes." } }, + { "local_id": 38, "plane": "intent", "kind": "decision", "title": "Map virtual ids to real on-disk files for static browser product code", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "Static browser modules (browser-entry, url-codec, each capability runtime) live as real .ts files whose virtual:fable/ resolves to the file path so Vite's TS transform runs", "rejected": ["Keep all browser code on the \\0virtual: escape hatch", "Inline everything as string templates"], "rationale": "Real files get type-checking and the default TS transform; only computed modules and the no-React adapter runtime stay string-template generators, a principled asymmetry." } }, + + { "local_id": 39, "plane": "intent", "kind": "criterion", "title": "A working Vite 8 React project runs the workbench with no config edits and sees its stories discovered, rendered, and navigable", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 40, "plane": "intent", "kind": "criterion", "title": "Dev, build, and preview manifests agree on identity, hierarchy, provenance, args defaults, and metadata", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 41, "plane": "intent", "kind": "example", "title": "A user with aliases, a CSS pipeline, a custom plugin, and define runs fable dev against their unmodified config", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 42, "plane": "intent", "kind": "example", "title": "A concurrent-render race where nav clicks outpace dynamic imports and two render() calls race on active", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 43, "plane": "oracle", "kind": "check", "title": "no-react-seed-check proves ESLint rejects a deliberate static import 'react' in product source", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 44, "plane": "oracle", "kind": "check", "title": "boundary-seed-check proves ESLint catches a deliberate core-imports-host violation", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 45, "plane": "oracle", "kind": "check", "title": "Static guard: manifest/catalog generation does not import or Vite-load story modules", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 46, "plane": "oracle", "kind": "check", "title": "Static guard: preview mode does not grow the documented Vite contact surface", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 47, "plane": "oracle", "kind": "validation_method", "title": "Playwright headless Chromium browser probes for end-to-end DOM behavior", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 48, "plane": "oracle", "kind": "validation_method", "title": "Mount-id marker convention: compare data-mount-id across URL changes to assert remount vs rerender", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 49, "plane": "oracle", "kind": "validation_method", "title": "Six-tier probe harness in tools/verify.ts: static, browser, dynamic, SSR, build, preview", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 50, "plane": "oracle", "kind": "evidence", "title": "vite-config-composition spike: 5 fixtures pass, no-merge audit passes, contact surface enumerated", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 51, "plane": "oracle", "kind": "evidence", "title": "Slice 5c probes: live preview-mode transition reconciliation and dev catalog freshness pass", "basis": "explicit", "source": "memory/ROADMAP.md" }, + + { "local_id": 52, "plane": "oracle", "kind": "obligation", "title": "docs/vite-contact-surface.md is reviewed at PR time; new Vite-internal dependencies require explicit justification", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 53, "plane": "design", "kind": "module", "title": "Workbench Core — normalized story graph, shell state, metadata contracts, capability orchestration", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 54, "plane": "design", "kind": "module", "title": "Vite Host Binding — integrates the core into Vite dev/build/preview", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 55, "plane": "design", "kind": "module", "title": "React Adapter — first concrete adapter; generator for the virtual:fable/adapter module", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 56, "plane": "design", "kind": "module", "title": "Controls capability plugin — capabilities/controls/runtime.ts", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 57, "plane": "design", "kind": "module", "title": "Source view capability plugin — renders #fable-source from manifest provenance", "basis": "explicit", "source": "memory/ROADMAP.md" }, + + { "local_id": 58, "plane": "design", "kind": "interface", "title": "Framework Adapter Contract (RenderProps, StoryMount, MountFn)", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 59, "plane": "design", "kind": "interface", "title": "Window-event capability protocol (fable:story-change / shell-mode-change / navigate-args)", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 60, "plane": "plan", "kind": "milestone", "title": "Walking skeleton: one React story rendered end-to-end against an unmodified config", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 61, "plane": "plan", "kind": "milestone", "title": "Manifest and metadata parity across dev, build, and preview", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 62, "plane": "plan", "kind": "milestone", "title": "Live shell mode and capability maturity", "basis": "explicit", "source": "memory/ROADMAP.md" }, + + { "local_id": 63, "plane": "plan", "kind": "frontier", "title": "Adapter contract plus capability-plugin model", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 64, "plane": "plan", "kind": "frontier", "title": "Ladle watermark gap audit", "basis": "explicit", "source": "memory/ROADMAP.md" }, + + { "local_id": 65, "plane": "plan", "kind": "slice", "title": "Vite config composition spike (5 fixtures, no-merge audit)", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 66, "plane": "plan", "kind": "slice", "title": "URL-backed preview mode", "basis": "explicit", "source": "memory/ROADMAP.md" }, + { "local_id": 67, "plane": "plan", "kind": "slice", "title": "Source view capability", "basis": "explicit", "source": "memory/ROADMAP.md" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 2, "target_local_id": 15, "basis": "explicit", "rationale": "The unmodified-config goal depends on consuming user config through Vite's own primitives." }, + { "category": "dependency", "source_local_id": 7, "target_local_id": 17, "basis": "explicit", "rationale": "Story discovery produces the normalized story graph, so the requirement depends on the graph definition." }, + { "category": "dependency", "source_local_id": 54, "target_local_id": 53, "basis": "explicit", "rationale": "The Vite host binding consumes the core's normalized outputs." }, + { "category": "dependency", "source_local_id": 55, "target_local_id": 58, "basis": "explicit", "rationale": "The React adapter depends on the framework adapter contract." }, + { "category": "dependency", "source_local_id": 56, "target_local_id": 59, "basis": "explicit", "rationale": "The controls capability depends on the window-event protocol." }, + { "category": "dependency", "source_local_id": 57, "target_local_id": 59, "basis": "explicit", "rationale": "The source-view capability depends on the window-event protocol." }, + + { "category": "realization", "source_local_id": 15, "target_local_id": 54, "basis": "explicit", "rationale": "The Vite host binding implements the no-merge config-consumption requirement." }, + { "category": "realization", "source_local_id": 16, "target_local_id": 53, "basis": "explicit", "rationale": "The Workbench Core realizes the core/host/adapter split." }, + { "category": "realization", "source_local_id": 17, "target_local_id": 53, "basis": "explicit", "rationale": "The core's story graph implements the canonical-discovery requirement." }, + { "category": "realization", "source_local_id": 18, "target_local_id": 66, "basis": "explicit", "rationale": "The preview-mode slice establishes URL-backed shell state." }, + { "category": "realization", "source_local_id": 19, "target_local_id": 61, "basis": "explicit", "rationale": "The manifest-parity milestone realizes the machine-readable manifest requirement." }, + { "category": "realization", "source_local_id": 20, "target_local_id": 56, "basis": "explicit", "rationale": "The controls capability realizes the optional-capability requirement." }, + { "category": "realization", "source_local_id": 21, "target_local_id": 55, "basis": "explicit", "rationale": "The React adapter implements no-leak story transitions via unmount/remount." }, + + { "category": "boundary", "source_local_id": 28, "target_local_id": 1, "basis": "explicit", "rationale": "The Vite-8-only constraint bounds how the workbench goal may be met." }, + { "category": "boundary", "source_local_id": 29, "target_local_id": 15, "basis": "explicit", "rationale": "The no-merge constraint bounds the config-consumption requirement." }, + { "category": "boundary", "source_local_id": 30, "target_local_id": 55, "basis": "explicit", "rationale": "The no-React constraint bounds how the React adapter may import React." }, + { "category": "boundary", "source_local_id": 31, "target_local_id": 54, "basis": "explicit", "rationale": "The preserve-root constraint bounds how the host binding serves assets." }, + + { "category": "composition", "source_local_id": 60, "target_local_id": 65, "basis": "explicit", "rationale": "The walking-skeleton milestone contains the config-composition spike." }, + { "category": "composition", "source_local_id": 62, "target_local_id": 66, "basis": "explicit", "rationale": "The live-shell milestone contains the preview-mode slice." }, + { "category": "composition", "source_local_id": 62, "target_local_id": 67, "basis": "explicit", "rationale": "The live-shell milestone contains the source-view slice." }, + { "category": "composition", "source_local_id": 63, "target_local_id": 56, "basis": "explicit", "rationale": "The adapter/capability frontier delivers the controls capability." }, + { "category": "composition", "source_local_id": 63, "target_local_id": 58, "basis": "explicit", "rationale": "The adapter/capability frontier produces the adapter contract." }, + + { "category": "association", "source_local_id": 5, "target_local_id": 14, "basis": "explicit", "rationale": "The Ladle watermark term and the Ladle-as-corpus context are peer framings of the same prior art." }, + { "category": "association", "source_local_id": 3, "target_local_id": 4, "basis": "explicit", "rationale": "The delegate-to-Vite thesis and the contact-surface thinness thesis are peer commitments." }, + { "category": "association", "source_local_id": 10, "target_local_id": 4, "basis": "explicit", "rationale": "The false-thinness anti-pattern sharpens the contact-surface thinness thesis." }, + + { "category": "supersession", "source_local_id": 22, "target_local_id": 23, "basis": "explicit", "rationale": "Manifest-backed controls defaults supersede the earlier client-side mod.args reading." }, + + { "category": "proof", "source_local_id": 43, "target_local_id": 30, "stance": "for", "basis": "explicit", "rationale": "The no-React seed-check witnesses the zero-React-dependency constraint." }, + { "category": "proof", "source_local_id": 44, "target_local_id": 32, "stance": "for", "basis": "explicit", "rationale": "The boundary seed-check witnesses the core-must-not-import-host invariant." }, + { "category": "proof", "source_local_id": 45, "target_local_id": 19, "stance": "for", "basis": "explicit", "rationale": "The no-story-import guard witnesses manifest-generation independence." }, + { "category": "proof", "source_local_id": 47, "target_local_id": 21, "stance": "for", "basis": "explicit", "rationale": "The Playwright probes witness no-leak story transitions." }, + { "category": "proof", "source_local_id": 50, "target_local_id": 25, "stance": "for", "basis": "explicit", "rationale": "The config-composition spike witnesses the single-env composition assumption." }, + { "category": "proof", "source_local_id": 51, "target_local_id": 26, "stance": "for", "basis": "explicit", "rationale": "The slice 5c probes witness the dev-catalog memoization-without-staleness assumption." }, + + { "category": "support", "source_local_id": 12, "target_local_id": 1, "stance": "for", "basis": "explicit", "rationale": "Vite 8's arrival and Ladle's poor fit motivate building the new workbench." }, + { "category": "support", "source_local_id": 13, "target_local_id": 3, "stance": "for", "basis": "explicit", "rationale": "The ecosystem's thin-adapter pattern motivates the delegate-to-Vite thesis." }, + { "category": "support", "source_local_id": 24, "target_local_id": 3, "stance": "for", "basis": "explicit", "rationale": "The Vite-8-is-sufficient assumption underwrites the delegate-to-Vite thesis." }, + { "category": "support", "source_local_id": 2, "target_local_id": 15, "stance": "for", "basis": "explicit", "rationale": "The unmodified-config goal motivates the no-merge consumption requirement." }, + { "category": "support", "source_local_id": 42, "target_local_id": 21, "stance": "against", "basis": "explicit", "rationale": "The latent concurrent-render race argues against the clean no-leak transition guarantee." } + ] +} diff --git a/.fixtures/seeds/rd-loop/README.md b/.fixtures/seeds/rd-loop/README.md new file mode 100644 index 000000000..b07a37277 --- /dev/null +++ b/.fixtures/seeds/rd-loop/README.md @@ -0,0 +1,35 @@ +# `.fixtures/seeds/rd-loop/` + +A **faithful** spec graph hand-derived from the `rd-loop` harness's prose docs +(`README.md`, `concept-1-a.md`, `concept-1-b.md`, `concept-2-b.md`, and the +frontier-governance addendum), as opposed to the synthetic coverage fixtures. + +Source project: `harnesses/rd-loop` — a bash loop that wraps Amp in fresh +contexts, persistent disk state, budgets, gates, and an isolated adversary to +govern autonomous R&D as a controlled epistemic process. The docs argue toward +a Geolog/Datalog-style change-governance substrate: warranted action, not +correct action. + +Purpose: + +- prove the loop end-to-end: real prose → graph fixture → the real + propose-graph validator (`seedFixture` → `CommandExecutor`) → renderers +- give a second realistic all-planes anchor alongside `brunch-self/` + +Coverage (a by-product of being faithful): + +- every node kind across all four planes (intent / oracle / design / plan) +- every edge category (dependency, realization, boundary, composition, + association, supersession, proof, support), including both proof/support + stances +- one supersession lineage (the role-dissolution decision supersedes the + single-executor assumption) + +Contents: + +- `spec-graph.json` — one `planning_ready` spec describing `rd-loop`. + +Most nodes map directly to doc prose; the two plan-plane **frontier** nodes are +`source: "projected"` because the planning decomposition is synthesized from +the docs' forward-looking POC/evolution path. Validate with +`npx tsx src/graph/validate-fixture.ts rd-loop/spec-graph`. diff --git a/.fixtures/seeds/rd-loop/spec-graph.json b/.fixtures/seeds/rd-loop/spec-graph.json new file mode 100644 index 000000000..3ce1409b8 --- /dev/null +++ b/.fixtures/seeds/rd-loop/spec-graph.json @@ -0,0 +1,107 @@ +{ + "spec": { + "slug": "rd-loop", + "name": "RD-Loop (frontier-governance harness for autonomous R&D)", + "readiness_grade": "planning_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Govern autonomous R&D as a controlled epistemic process, not a single agent trying hard", "basis": "explicit", "source": "concept-2-b.md" }, + { "local_id": 2, "plane": "intent", "kind": "goal", "title": "Advance the epistemic frontier by retiring uncertainty rather than authoring longer plans", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 3, "plane": "intent", "kind": "goal", "title": "Materialize a Geolog/Datalog-style change-governance substrate as a runnable POC", "basis": "explicit", "source": "addendum-frontier-governance.md" }, + { "local_id": 4, "plane": "intent", "kind": "thesis", "title": "End-to-end planning works only in a minority of cases with stable goals and known terrain", "basis": "explicit", "source": "concept-1-b.md" }, + { "local_id": 5, "plane": "intent", "kind": "thesis", "title": "A governance substrate's distinctive value is governing dynamic change, not storing static artifacts", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 6, "plane": "intent", "kind": "thesis", "title": "The right target is warranted action, not correct action", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 7, "plane": "intent", "kind": "thesis", "title": "Context isolation between roles prevents narrative momentum and self-assessment bias", "basis": "explicit", "source": "README.md" }, + { "local_id": 8, "plane": "intent", "kind": "term", "title": "Epistemic frontier", "basis": "explicit", "source": "concept-1-b.md", "detail": { "definition": "The boundary between the current epistemic state and the area where further work is not yet planable; progress consists of pushing it back by retiring uncertainty.", "aliases": ["planning horizon"] } }, + { "local_id": 9, "plane": "intent", "kind": "term", "title": "Warranted action", "basis": "explicit", "source": "concept-1-a.md", "detail": { "definition": "An action whose local neighborhood has the required shape — cited intent, capability, evidence, review — regardless of whether it ultimately turns out to be a good idea." } }, + { "local_id": 10, "plane": "intent", "kind": "term", "title": "Tile-edge", "basis": "explicit", "source": "concept-1-a.md", "detail": { "definition": "A required local match condition on a fact: a tile snaps into the picture only if its edges find matching neighbor-edges, mirroring a geometric-logic sequent." } }, + { "local_id": 11, "plane": "intent", "kind": "term", "title": "Belief funnel", "basis": "explicit", "source": "addendum-frontier-governance.md", "detail": { "definition": "The governed progression hypothesised to predicted to witnessed to corroborated to claimed to settled, parallel to the action funnel, with admissibility rules at each promotion." } }, + { "local_id": 12, "plane": "intent", "kind": "term", "title": "Contention", "basis": "explicit", "source": "concept-1-a.md", "detail": { "definition": "A first-class fact linking two contradicting claims so disagreement is admitted as structure rather than suppressed by a quiet overwrite.", "aliases": ["contention object"] } }, + { "local_id": 13, "plane": "intent", "kind": "term", "title": "Mandate", "basis": "explicit", "source": "README.md", "detail": { "definition": "The human-authored, run-immutable commander's-intent declaring end-state, standing orders, escalation rules, and energy budget.", "aliases": ["commander's intent"] } }, + { "local_id": 14, "plane": "intent", "kind": "term", "title": "Sensemaker", "basis": "explicit", "source": "concept-1-a.md", "detail": { "definition": "A proposed context-isolated role whose only write-authority is the situation/belief tables; it maintains the picture and never authors actions." } }, + { "local_id": 15, "plane": "intent", "kind": "context", "title": "rd-loop is a working bash harness invoking Amp in execute mode against persistent disk state", "basis": "explicit", "source": "README.md" }, + { "local_id": 16, "plane": "intent", "kind": "context", "title": "The concept assumes familiarity with Kleppmann's Geolog geometric-logic rationale", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 17, "plane": "intent", "kind": "context", "title": "Human organizational models (ICS, trauma teams, CIC/bridge) encode bounded autonomy under uncertainty", "basis": "explicit", "source": "concept-1-b.md" }, + { "local_id": 18, "plane": "intent", "kind": "requirement", "title": "The executor registers falsifiable predictions before each iteration; the gate audits whether they held", "basis": "explicit", "source": "README.md" }, + { "local_id": 19, "plane": "intent", "kind": "requirement", "title": "A proposed action must cite an active intent whose scope covers its objective", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 20, "plane": "intent", "kind": "requirement", "title": "An execution claim must cite an active capability covering action kind, resource, and phase", "basis": "explicit", "source": "concept-1-b.md" }, + { "local_id": 21, "plane": "intent", "kind": "requirement", "title": "A completion claim must be validated by a role distinct from the one that produced the artifact", "basis": "explicit", "source": "addendum-frontier-governance.md" }, + { "local_id": 22, "plane": "intent", "kind": "requirement", "title": "Every tool with a path-like argument must be gated independently within the turn", "basis": "explicit", "source": "README.md" }, + { "local_id": 23, "plane": "intent", "kind": "assumption", "title": "SQLite plus a hand-rolled axiom evaluator in Bun suffices as a less-efficient mimic for the POC", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 24, "plane": "intent", "kind": "assumption", "title": "Folding action and picture-maintenance into one executor is acceptable for small research loops", "basis": "explicit", "source": "concept-2-b.md" }, + { "local_id": 25, "plane": "intent", "kind": "constraint", "title": "mandate.md is human-authored and immutable during a run", "basis": "explicit", "source": "README.md" }, + { "local_id": 26, "plane": "intent", "kind": "constraint", "title": "The adversary never sees the executor's chain of thought, only artifacts and ledger entries", "basis": "explicit", "source": "README.md" }, + { "local_id": 27, "plane": "intent", "kind": "constraint", "title": "An energy budget bounds autonomy; exhaustion forces a mandatory halt", "basis": "explicit", "source": "README.md" }, + { "local_id": 28, "plane": "intent", "kind": "invariant", "title": "Constraint-checking and proof-checking are the same operation; every admitted fact carries a well-formedness proof", "basis": "explicit", "source": "addendum-frontier-governance.md" }, + { "local_id": 29, "plane": "intent", "kind": "invariant", "title": "A settled claim cannot be quietly overwritten; contradiction is admitted as a contention object", "basis": "explicit", "source": "addendum-frontier-governance.md" }, + { "local_id": 30, "plane": "intent", "kind": "invariant", "title": "Moving phase creates a new epoch; old capabilities do not automatically match the new epoch", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 31, "plane": "intent", "kind": "invariant", "title": "Failures are first-class facts, as citeable as successes", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 32, "plane": "intent", "kind": "decision", "title": "Govern via warranted-action admissibility rather than modeling the plan as the load-bearing object", "basis": "explicit", "source": "concept-1-a.md", "detail": { "chosen_option": "Encode admissibility axioms over warranted local neighborhoods that decide which writes become facts", "rejected": ["Model the plan as the load-bearing governing object", "Store specs and plans in the substrate as a rich document store"], "rationale": "A plan is advisory and an agent can ignore it; admissibility is constitutive, so an unwarranted write never becomes a fact." } }, + { "local_id": 33, "plane": "intent", "kind": "decision", "title": "Separate the three agent roles by context window", "basis": "explicit", "source": "README.md", "detail": { "chosen_option": "Run executor, gate, and adversary as separate Amp instances with isolated context windows", "rejected": ["A single agent that both does the work and judges it", "Shared conversation history across roles"], "rationale": "Isolation prevents narrative momentum and self-assessment bias; the adversary judges artifacts, not the executor's reasoning." } }, + { "local_id": 34, "plane": "intent", "kind": "decision", "title": "Dissolve the Gate into the substrate and add a Sensemaker role", "basis": "explicit", "source": "concept-1-a.md", "detail": { "chosen_option": "Let write-time substrate admissibility absorb the Gate's checklist and add a Sensemaker owning situation/belief writes", "rejected": ["Keep the Gate as a post-hoc agent", "Leave belief-picture maintenance inside the executor"], "rationale": "The judge becomes the schema; separating the doer from the picture-maintainer dissolves the narrative-momentum problem." } }, + { "local_id": 35, "plane": "intent", "kind": "criterion", "title": "A stale-situation-reference proposal is rejected with a diagnostic naming the cited claim and current epoch", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 36, "plane": "intent", "kind": "criterion", "title": "A blocked action surfaces the missing tile-edges that would unblock it, not a generic error", "basis": "explicit", "source": "addendum-frontier-governance.md" }, + { "local_id": 37, "plane": "intent", "kind": "example", "title": "The Grep bypass: Read and Bash blocked on isolation paths but Grep also accepted a path and succeeded", "basis": "explicit", "source": "README.md" }, + { "local_id": 38, "plane": "oracle", "kind": "check", "title": "Within-turn permission rules reject Read/Grep/Bash/edit_file on isolation-boundary paths", "basis": "explicit", "source": "README.md" }, + { "local_id": 39, "plane": "oracle", "kind": "check", "title": "The mode-guard delegate verifies skill choice against mode.state before execution", "basis": "explicit", "source": "README.md" }, + { "local_id": 40, "plane": "oracle", "kind": "validation_method", "title": "A three-tier boundary oracle adversarially probes the isolation surface", "basis": "explicit", "source": "README.md" }, + { "local_id": 41, "plane": "oracle", "kind": "evidence", "title": "The spike's tier-3 adversarial probe that discovered the Grep bypass", "basis": "explicit", "source": "README.md" }, + { "local_id": 42, "plane": "oracle", "kind": "obligation", "title": "Every execution claim creates an obligation to attach a result/outcome report", "basis": "explicit", "source": "concept-1-b.md" }, + { "local_id": 43, "plane": "design", "kind": "module", "title": "driver.sh — the five-phase loop driver (orient, execute, gate, stabilize, control)", "basis": "explicit", "source": "README.md" }, + { "local_id": 44, "plane": "design", "kind": "module", "title": "agents/executor — executor role config with settings.json permission rules and mode-guard.sh", "basis": "explicit", "source": "README.md" }, + { "local_id": 45, "plane": "design", "kind": "module", "title": "protocol/ — file schemas and templates for mandate, ledger, predictions, and sitrep", "basis": "explicit", "source": "README.md" }, + { "local_id": 46, "plane": "design", "kind": "module", "title": "Six-table SQLite fact store: agent, intent, capability, situation_claim, proposed_action, executed_action", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 47, "plane": "design", "kind": "interface", "title": "submit(fact) admissibility API returning admissible / blocked / requires_human with reasons", "basis": "explicit", "source": "concept-2-b.md" }, + { "local_id": 48, "plane": "design", "kind": "interface", "title": "mandate.md commander's-intent schema: end-state, standing orders, escalation rules, energy budget", "basis": "explicit", "source": "README.md" }, + { "local_id": 49, "plane": "plan", "kind": "milestone", "title": "Seed-crystal POC: invalid action becomes unrecordable", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 50, "plane": "plan", "kind": "frontier", "title": "From file protocol to typed fact protocol", "basis": "explicit", "source": "projected" }, + { "local_id": 51, "plane": "plan", "kind": "frontier", "title": "Role differentiation: separate sensemaking and planning from execution", "basis": "explicit", "source": "projected" }, + { "local_id": 52, "plane": "plan", "kind": "slice", "title": "Six-table fact store with three admissibility axioms as zero-row SQL checks", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 53, "plane": "plan", "kind": "slice", "title": "Sensemaker agent with INSERT-only authority on situation_claim and contention", "basis": "explicit", "source": "concept-1-a.md" }, + { "local_id": 54, "plane": "plan", "kind": "slice", "title": "Fact-schema spike translating one rd-loop iteration into typed facts and admissibility decisions", "basis": "explicit", "source": "concept-2-b.md" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 8, "target_local_id": 2, "basis": "explicit", "rationale": "The epistemic-frontier concept underpins the frontier-advancement goal." }, + { "category": "dependency", "source_local_id": 9, "target_local_id": 6, "basis": "explicit", "rationale": "The warranted-action definition is prerequisite to the warranted-not-correct thesis." }, + { "category": "dependency", "source_local_id": 46, "target_local_id": 47, "basis": "explicit", "rationale": "The six-table fact store is the prerequisite the submit(fact) API operates over." }, + { "category": "dependency", "source_local_id": 15, "target_local_id": 3, "basis": "explicit", "rationale": "The working rd-loop harness is the seed crystal the substrate POC grows from." }, + { "category": "dependency", "source_local_id": 13, "target_local_id": 19, "basis": "explicit", "rationale": "The mandate/intent definition is prerequisite to the cite-an-intent requirement." }, + + { "category": "realization", "source_local_id": 19, "target_local_id": 52, "basis": "explicit", "rationale": "The first admissibility axiom realizes the cite-an-intent requirement." }, + { "category": "realization", "source_local_id": 20, "target_local_id": 52, "basis": "explicit", "rationale": "The capability axiom realizes the cite-a-capability requirement." }, + { "category": "realization", "source_local_id": 22, "target_local_id": 44, "basis": "explicit", "rationale": "The executor settings.json realizes per-tool path gating." }, + { "category": "realization", "source_local_id": 18, "target_local_id": 43, "basis": "explicit", "rationale": "driver.sh realizes prediction registration and audit across the five phases." }, + { "category": "realization", "source_local_id": 28, "target_local_id": 46, "basis": "explicit", "rationale": "The six-table store plus axiom evaluator realizes constraint-checking-as-proof." }, + { "category": "realization", "source_local_id": 29, "target_local_id": 53, "basis": "explicit", "rationale": "The Sensemaker's contention writes realize the no-quiet-overwrite invariant." }, + + { "category": "boundary", "source_local_id": 27, "target_local_id": 1, "basis": "explicit", "rationale": "The energy-budget constraint bounds autonomous pursuit of the governance goal." }, + { "category": "boundary", "source_local_id": 25, "target_local_id": 44, "basis": "explicit", "rationale": "The immutable-mandate constraint bounds what the executor may modify." }, + { "category": "boundary", "source_local_id": 32, "target_local_id": 3, "basis": "explicit", "rationale": "The geometric-fragment governance decision bounds the substrate POC's logic." }, + + { "category": "composition", "source_local_id": 49, "target_local_id": 50, "basis": "explicit", "rationale": "The seed-crystal POC contains the file-to-fact-protocol frontier." }, + { "category": "composition", "source_local_id": 49, "target_local_id": 51, "basis": "explicit", "rationale": "The seed-crystal POC contains the role-differentiation frontier." }, + { "category": "composition", "source_local_id": 50, "target_local_id": 52, "basis": "explicit", "rationale": "The fact-protocol frontier contains the six-table axiom slice." }, + { "category": "composition", "source_local_id": 50, "target_local_id": 54, "basis": "explicit", "rationale": "The fact-protocol frontier contains the fact-schema spike slice." }, + { "category": "composition", "source_local_id": 51, "target_local_id": 53, "basis": "explicit", "rationale": "The role-differentiation frontier contains the Sensemaker slice." }, + + { "category": "association", "source_local_id": 10, "target_local_id": 28, "basis": "explicit", "rationale": "Tile-edge matching and constraint-checking-as-proof are two faces of the geometric-logic shape." }, + { "category": "association", "source_local_id": 17, "target_local_id": 14, "basis": "explicit", "rationale": "The ICS/trauma-team analogy and the Sensemaker role are peer expressions of staff differentiation." }, + { "category": "association", "source_local_id": 6, "target_local_id": 5, "basis": "explicit", "rationale": "Warranted-action and govern-change are peer pillars of the same reframe." }, + + { "category": "supersession", "source_local_id": 34, "target_local_id": 24, "basis": "explicit", "rationale": "The role-dissolution decision supersedes the single-executor assumption as the system scales." }, + + { "category": "proof", "source_local_id": 38, "target_local_id": 26, "stance": "for", "basis": "explicit", "rationale": "The isolation-path permission checks witness the adversary-blindness boundary." }, + { "category": "proof", "source_local_id": 41, "target_local_id": 22, "stance": "against", "basis": "explicit", "rationale": "Counterevidence: the initial permission set missed Grep, failing the path-gating requirement." }, + { "category": "proof", "source_local_id": 40, "target_local_id": 22, "stance": "for", "basis": "explicit", "rationale": "The three-tier adversarial probe witnesses the path-gating requirement once Grep is closed." }, + { "category": "proof", "source_local_id": 35, "target_local_id": 30, "stance": "for", "basis": "explicit", "rationale": "The stale-reference rejection criterion witnesses the phase-epoch invariant." }, + { "category": "proof", "source_local_id": 36, "target_local_id": 5, "stance": "for", "basis": "explicit", "rationale": "The blocked-action diagnostic witnesses the governing-change thesis." }, + { "category": "proof", "source_local_id": 42, "target_local_id": 31, "stance": "for", "basis": "explicit", "rationale": "The result-report obligation witnesses that failures are first-class facts." }, + + { "category": "support", "source_local_id": 4, "target_local_id": 2, "stance": "for", "basis": "explicit", "rationale": "The planning-breaks observation motivates the frontier-advancement goal." }, + { "category": "support", "source_local_id": 17, "target_local_id": 34, "stance": "for", "basis": "explicit", "rationale": "ICS and trauma-team patterns support adding a Sensemaker and dissolving the gate." }, + { "category": "support", "source_local_id": 16, "target_local_id": 32, "stance": "for", "basis": "explicit", "rationale": "Geolog's geometric-logic rationale supports the warranted-admissibility decision." }, + { "category": "support", "source_local_id": 23, "target_local_id": 3, "stance": "for", "basis": "explicit", "rationale": "The SQLite-suffices assumption supports the substrate-POC goal." }, + { "category": "support", "source_local_id": 7, "target_local_id": 33, "stance": "for", "basis": "explicit", "rationale": "The isolation-prevents-bias thesis supports the context-isolation decision." }, + { "category": "support", "source_local_id": 24, "target_local_id": 34, "stance": "against", "basis": "explicit", "rationale": "The single-executor-is-acceptable assumption argues against the role-dissolution decision." } + ] +} diff --git a/.fixtures/seeds/yamlbase/README.md b/.fixtures/seeds/yamlbase/README.md new file mode 100644 index 000000000..a58d4c26f --- /dev/null +++ b/.fixtures/seeds/yamlbase/README.md @@ -0,0 +1,42 @@ +# `.fixtures/seeds/yamlbase/` + +A **faithful** spec graph hand-derived from the **yamlbase** project's planning +prose (internal name "Dogbase"), modeled on `brunch-self/` as the worked +template. yamlbase is an agent-oriented local DB: a thin TypeScript CLI over +SQLite presenting a document store, with per-record JSON files +(`data//.json`) as Git-backed canonical storage and a +disposable, rebuildable SQLite index. + +Source docs (from `/Users/lunelson/Code/hashintel/yamlbase`): + +- `memory/SPEC.md` — problem, requirements, constraints, decisions, invariants, + domain terms, verification design +- `memory/PLAN.md` — inside-out slice sequence (skeleton → serializer → + json-store → config/schema → sqlite-index → sync → CLI → lock → doctor) +- `docs/sqlite-db-backed-by-json.md` — the design conversation behind the + document-store CLI, Drizzle-vs-Prisma, TypeScript-vs-Bash, per-record-vs-JSONL, + and pull/push command naming +- `docs/beads-dolt-assessment.md` — assessment of Dolt-backed storage + (steveyegge/beads), the rejected version-controlled-data-layer alternative + +Coverage (a by-product of being faithful): + +- all four planes — intent / oracle / design / plan — genuinely populated +- decision nodes carry `chosen_option` / `rejected` / `rationale`; term nodes + carry `definition` (+ aliases) +- both proof and support stances, including `against` edges sourced from the + Dolt assessment +- one supersession lineage (import/export naming supersedes pull/push naming) + +Projected (not explicit in the source): the plan-plane milestones (M1/M2) and +frontiers, which group the explicit PLAN.md slices for composition edges. These +nodes carry `source: "projected"`. + +Validate: + +``` +npx tsx src/graph/validate-fixture.ts yamlbase/spec-graph +``` + +This seeds the fixture through the real `CommandExecutor` mutation boundary, so +it passes only if every node/edge is structurally legal. diff --git a/.fixtures/seeds/yamlbase/spec-graph.json b/.fixtures/seeds/yamlbase/spec-graph.json new file mode 100644 index 000000000..7ea93663b --- /dev/null +++ b/.fixtures/seeds/yamlbase/spec-graph.json @@ -0,0 +1,156 @@ +{ + "spec": { + "slug": "yamlbase", + "name": "Yamlbase (Dogbase — agent-oriented local DB with Git-backed JSON storage)", + "readiness_grade": "planning_ready" + }, + "nodes": [ + { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Give agents a fast, queryable local DB whose data stays human-auditable and Git-trackable", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 2, "plane": "intent", "kind": "goal", "title": "Keep the SQLite index disposable and rebuildable from canonical JSON with a single command", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 3, "plane": "intent", "kind": "thesis", "title": "The system is an agent-oriented local DB cache with deterministic file sync, not a 'JSON database' or 'ORM'", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md" }, + { "local_id": 4, "plane": "intent", "kind": "thesis", "title": "A constrained document-store CLI keeps agent data access safer and more evolvable than raw SQL", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md" }, + { "local_id": 5, "plane": "intent", "kind": "thesis", "title": "Per-record JSON localizes Git conflicts where monolithic JSONL is merge-hot", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 6, "plane": "intent", "kind": "term", "title": "Derived-index architecture", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "JSON files are the source of truth; SQLite is a derived, disposable index. If SQLite and JSON disagree, JSON wins; if the index breaks, rebuild it.", "aliases": ["derived index"] } }, + { "local_id": 7, "plane": "intent", "kind": "term", "title": "Canonical JSON record", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "One per-record file at data//.json, checked into Git, that is the authoritative state for a record.", "aliases": ["source of truth", "canonical file"] } }, + { "local_id": 8, "plane": "intent", "kind": "term", "title": "Document-store CLI", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md", "detail": { "definition": "A small stable command surface (get/put/del/list/query) presented to agents that hides SQLite, migrations, indexing, and export machinery." } }, + { "local_id": 9, "plane": "intent", "kind": "term", "title": "Drift", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "definition": "A discrepancy between the canonical JSON files and the SQLite index, surfaced by the status command and repaired by rebuild." } }, + { "local_id": 10, "plane": "intent", "kind": "context", "title": "No off-the-shelf product combines a document-store CLI, SQLite query performance, and per-record JSON Git storage", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 11, "plane": "intent", "kind": "context", "title": "PocketBase proves a single command auto-running migrations at startup is practical UX", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md" }, + { "local_id": 12, "plane": "intent", "kind": "requirement", "title": "Agents interact via get/put/del/list/query; no raw SQL exposed by default", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 13, "plane": "intent", "kind": "requirement", "title": "Write path validates input, writes canonical JSON atomically, then updates the SQLite index (JSON-first)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 14, "plane": "intent", "kind": "requirement", "title": "The .db file is gitignored and fully rebuildable from canonical JSON via rebuild", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 15, "plane": "intent", "kind": "requirement", "title": "Deterministic serialization: stable key ordering, normalized whitespace, atomic temp-file + rename writes", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 16, "plane": "intent", "kind": "requirement", "title": "The status command reports drift between JSON files and the SQLite index", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 17, "plane": "intent", "kind": "requirement", "title": "Collections, fields, and indexes are defined in config; the Drizzle schema derives from config", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 18, "plane": "intent", "kind": "requirement", "title": "A single-writer file lock prevents concurrent CLI invocations from corrupting state", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 19, "plane": "intent", "kind": "assumption", "title": "better-sqlite3's prebuilt binary works across macOS/Linux without native compilation issues", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 20, "plane": "intent", "kind": "assumption", "title": "Per-record JSON performs acceptably in Git repos up to ~10k records per collection", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 21, "plane": "intent", "kind": "assumption", "title": "Atomic temp-file + rename writes prevent partial or corrupt JSON on crash", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 22, "plane": "intent", "kind": "constraint", "title": "Local-only: no remote sync, replication, or multi-machine writers", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 23, "plane": "intent", "kind": "constraint", "title": "No raw SQL exposure by default; direct SQL is an opt-in escape hatch at most", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 24, "plane": "intent", "kind": "constraint", "title": "No automatic Git operations; dogbase writes files and the user/agent commits them", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 25, "plane": "intent", "kind": "invariant", "title": "If SQLite and JSON disagree, JSON wins; SQLite is always a derived index", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 26, "plane": "intent", "kind": "invariant", "title": "Reads go SQLite-first (auto-rebuild if dirty); writes go JSON-first", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 27, "plane": "intent", "kind": "decision", "title": "Adopt the derived-index architecture", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "JSON files are canonical; SQLite is a derived, disposable index rebuilt from JSON", "rejected": ["SQLite-first: write SQLite then export to JSON", "Treat Git itself as the database/query engine"], "rationale": "Keeping JSON authoritative preserves a human-auditable Git trail while SQLite supplies indexed reads; an inverted authority would corrupt the audit story." } }, + { "local_id": 28, "plane": "intent", "kind": "decision", "title": "Use Drizzle + better-sqlite3 over Prisma", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md", "detail": { "chosen_option": "Drizzle ORM over better-sqlite3", "rejected": ["Prisma ORM"], "rationale": "Drizzle is more headless and CLI-composable with clean folder-based migrations; Prisma pulls toward 'ORM app architecture' which is the wrong fit for a thin custom CLI." } }, + { "local_id": 29, "plane": "intent", "kind": "decision", "title": "Build a TypeScript CLI rather than a single Bash script", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md", "detail": { "chosen_option": "TypeScript CLI (tsx in dev, compiled JS to ship), Bash only as a thin bootstrap shim", "rejected": ["A single executable Bash file"], "rationale": "Config parsing, schema-aware validation, deterministic import/export, and structured agent output make Bash brittle and hard to test." } }, + { "local_id": 30, "plane": "intent", "kind": "decision", "title": "Ship an npm-installed Node CLI rather than a single self-contained binary", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md", "detail": { "chosen_option": "Standard bin entry in package.json; npm-installed Node CLI", "rejected": ["A single cross-platform standalone binary"], "rationale": "better-sqlite3 is a native module, so standalone cross-platform binaries are painful; npm install handles native compatibility conventionally." } }, + { "local_id": 31, "plane": "intent", "kind": "decision", "title": "Store per-record JSON files rather than monolithic JSONL", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "One JSON file per record at data//.json", "rejected": ["A single monolithic JSONL file per collection as canonical state"], "rationale": "JSONL creates a hot file every write touches — merge-conflict hell; per-record JSON localizes conflicts and yields per-entity reviewable diffs." } }, + { "local_id": 32, "plane": "intent", "kind": "decision", "title": "Use db pull / db push as the JSON-to-SQLite sync command names", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md", "detail": { "chosen_option": "db pull imports canonical JSON into SQLite; db push exports records back to JSON", "rejected": ["import / export naming"], "rationale": "Early design framed sync as pull/push to mirror familiar VCS verbs." } }, + { "local_id": 33, "plane": "intent", "kind": "decision", "title": "Rename sync commands to import / export instead of pull / push", "basis": "explicit", "source": "memory/SPEC.md", "detail": { "chosen_option": "import / export command names for JSON-to-SQLite sync", "rejected": ["pull / push naming"], "rationale": "pull/push sounds remote; this is a local tool, so import/export better signals the local-only file-sync semantics." } }, + { "local_id": 34, "plane": "intent", "kind": "decision", "title": "Do not adopt Dolt at the data layer; use Git-backed JSON + disposable SQLite", "basis": "explicit", "source": "docs/beads-dolt-assessment.md", "detail": { "chosen_option": "Git-tracked per-record JSON as canonical storage with a disposable SQLite index", "rejected": ["Embedded/served Dolt as the version-controlled storage engine (as in steveyegge/beads)"], "rationale": "Dolt's cell-level MVCC and SQL-level commit history are powerful but heavyweight for a single-user local tool; branch/merge data semantics stay out of scope, revisited only if needed." } }, + { "local_id": 35, "plane": "intent", "kind": "criterion", "title": "Git diffs of data/ are clean — no spurious changes from serialization instability", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 36, "plane": "intent", "kind": "criterion", "title": "rebuild reconstructs SQLite entirely from canonical JSON — the DB is disposable", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 37, "plane": "intent", "kind": "criterion", "title": "Concurrent CLI invocations are safely locked — no corruption under concurrent access", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 38, "plane": "intent", "kind": "example", "title": "A monolithic JSONL hot-file merge conflict that per-record JSON avoids", "basis": "explicit", "source": "docs/sqlite-db-backed-by-json.md" }, + { "local_id": 39, "plane": "intent", "kind": "example", "title": "A kill-during-write crash that atomic temp-file + rename must survive", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 40, "plane": "oracle", "kind": "check", "title": "Serializer golden tests: given known objects, assert exact deterministic JSON output", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 41, "plane": "oracle", "kind": "check", "title": "Round-trip test: serialize → parse → serialize yields identical output", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 42, "plane": "oracle", "kind": "check", "title": "Drift tests: deleting or adding a JSON file makes status report drift", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 43, "plane": "oracle", "kind": "check", "title": "Concurrent-invocation stress test: two CLI calls cannot corrupt state", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 44, "plane": "oracle", "kind": "validation_method", "title": "Round-trip integration: put via CLI → verify JSON file → rebuild SQLite → query → assert identical record", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 45, "plane": "oracle", "kind": "validation_method", "title": "doctor command validates schema, round-trip stability, foreign keys, and export determinism", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 46, "plane": "oracle", "kind": "evidence", "title": "beads/Dolt assessment: six version-control capabilities a plain SQL database cannot provide", "basis": "explicit", "source": "docs/beads-dolt-assessment.md" }, + { "local_id": 47, "plane": "oracle", "kind": "obligation", "title": "Every slice must pass inner- and middle-loop tests before it is considered done", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 48, "plane": "design", "kind": "module", "title": "serializer.ts — deterministic JSON (stable keys, normalized whitespace, UTF-8)", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 49, "plane": "design", "kind": "module", "title": "json-store.ts — atomic, deterministic per-record file reads/writes", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 50, "plane": "design", "kind": "module", "title": "config.ts — load and validate config with zod", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 51, "plane": "design", "kind": "module", "title": "schema.ts — Drizzle schema and collection definitions generated from config", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 52, "plane": "design", "kind": "module", "title": "sqlite-index.ts — open SQLite (WAL, foreign keys, busy timeout, STRICT), run migrations, index ops", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 53, "plane": "design", "kind": "module", "title": "sync.ts — rebuild, reindex, and drift detection", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 54, "plane": "design", "kind": "module", "title": "lock.ts — single-writer file lock", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 55, "plane": "design", "kind": "interface", "title": "Agent-facing command surface: init/get/put/del/list/query/import/export/rebuild/status/doctor", "basis": "explicit", "source": "memory/SPEC.md" }, + { "local_id": 56, "plane": "design", "kind": "interface", "title": "config.yaml: collection, field, type, nullable, indexed, and primary-key definitions", "basis": "explicit", "source": "memory/SPEC.md" }, + + { "local_id": 57, "plane": "plan", "kind": "milestone", "title": "M1 — Walking skeleton and pure core modules", "basis": "explicit", "source": "projected" }, + { "local_id": 58, "plane": "plan", "kind": "milestone", "title": "M2 — Derived-index sync, CLI surface, and integrity safety", "basis": "explicit", "source": "projected" }, + { "local_id": 59, "plane": "plan", "kind": "frontier", "title": "Deterministic canonical storage (serializer + JSON store)", "basis": "explicit", "source": "projected" }, + { "local_id": 60, "plane": "plan", "kind": "frontier", "title": "Schema-driven SQLite index (config/schema + migrations)", "basis": "explicit", "source": "projected" }, + { "local_id": 61, "plane": "plan", "kind": "slice", "title": "Slice 1: Walking skeleton — prove build, test, and run end-to-end", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 62, "plane": "plan", "kind": "slice", "title": "Slice 2: Serializer — deterministic JSON serialization", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 63, "plane": "plan", "kind": "slice", "title": "Slice 3: JSON store — atomic per-record file CRUD", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 64, "plane": "plan", "kind": "slice", "title": "Slice 4: Config + Schema — zod config producing Drizzle schema", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 65, "plane": "plan", "kind": "slice", "title": "Slice 5: SQLite index + migrations — pragmas, auto-migrate, CRUD via Drizzle", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 66, "plane": "plan", "kind": "slice", "title": "Slice 6: Sync engine — rebuild from JSON and detect drift", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 67, "plane": "plan", "kind": "slice", "title": "Slice 7: CLI commands — wire core modules into the command surface", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 68, "plane": "plan", "kind": "slice", "title": "Slice 8: File lock — single-writer safety", "basis": "explicit", "source": "memory/PLAN.md" }, + { "local_id": 69, "plane": "plan", "kind": "slice", "title": "Slice 9: Doctor command — validate the whole system's integrity", "basis": "explicit", "source": "memory/PLAN.md" } + ], + "edges": [ + { "category": "dependency", "source_local_id": 6, "target_local_id": 25, "basis": "explicit", "rationale": "The JSON-wins invariant is stated over the derived-index concept." }, + { "category": "dependency", "source_local_id": 48, "target_local_id": 49, "basis": "explicit", "rationale": "json-store writes records through the deterministic serializer." }, + { "category": "dependency", "source_local_id": 50, "target_local_id": 51, "basis": "explicit", "rationale": "The Drizzle schema is generated from validated config." }, + { "category": "dependency", "source_local_id": 51, "target_local_id": 52, "basis": "explicit", "rationale": "The SQLite index is opened/migrated against the generated schema." }, + { "category": "dependency", "source_local_id": 49, "target_local_id": 53, "basis": "explicit", "rationale": "Sync scans canonical files through the JSON store." }, + { "category": "dependency", "source_local_id": 52, "target_local_id": 53, "basis": "explicit", "rationale": "Sync rebuilds and reindexes against the SQLite index." }, + { "category": "dependency", "source_local_id": 53, "target_local_id": 55, "basis": "explicit", "rationale": "CLI commands depend on the sync engine for rebuild/status." }, + { "category": "dependency", "source_local_id": 54, "target_local_id": 55, "basis": "explicit", "rationale": "Mutating commands are wrapped in lock acquisition." }, + + { "category": "dependency", "source_local_id": 61, "target_local_id": 62, "basis": "explicit", "rationale": "Slice 2 builds on the walking skeleton." }, + { "category": "dependency", "source_local_id": 62, "target_local_id": 63, "basis": "explicit", "rationale": "Slice 3 (JSON store) depends on the serializer slice." }, + { "category": "dependency", "source_local_id": 61, "target_local_id": 64, "basis": "explicit", "rationale": "Slice 4 builds on the walking skeleton." }, + { "category": "dependency", "source_local_id": 64, "target_local_id": 65, "basis": "explicit", "rationale": "Slice 5 (SQLite index) depends on config + schema." }, + { "category": "dependency", "source_local_id": 63, "target_local_id": 66, "basis": "explicit", "rationale": "Slice 6 (sync) depends on the JSON store." }, + { "category": "dependency", "source_local_id": 65, "target_local_id": 66, "basis": "explicit", "rationale": "Slice 6 (sync) depends on the SQLite index." }, + { "category": "dependency", "source_local_id": 66, "target_local_id": 67, "basis": "explicit", "rationale": "Slice 7 (CLI) wires together the sync engine." }, + { "category": "dependency", "source_local_id": 67, "target_local_id": 68, "basis": "explicit", "rationale": "Slice 8 (lock) wraps the wired CLI commands." }, + { "category": "dependency", "source_local_id": 67, "target_local_id": 69, "basis": "explicit", "rationale": "Slice 9 (doctor) validates the wired CLI system." }, + + { "category": "realization", "source_local_id": 15, "target_local_id": 48, "basis": "explicit", "rationale": "serializer.ts implements the deterministic-serialization requirement." }, + { "category": "realization", "source_local_id": 15, "target_local_id": 62, "basis": "explicit", "rationale": "Slice 2 establishes deterministic serialization." }, + { "category": "realization", "source_local_id": 13, "target_local_id": 49, "basis": "explicit", "rationale": "json-store implements the atomic JSON-first write path." }, + { "category": "realization", "source_local_id": 17, "target_local_id": 50, "basis": "explicit", "rationale": "config.ts implements the config-driven requirement." }, + { "category": "realization", "source_local_id": 17, "target_local_id": 51, "basis": "explicit", "rationale": "schema.ts derives the Drizzle schema from config." }, + { "category": "realization", "source_local_id": 14, "target_local_id": 53, "basis": "explicit", "rationale": "sync.ts implements rebuildable, disposable SQLite." }, + { "category": "realization", "source_local_id": 16, "target_local_id": 53, "basis": "explicit", "rationale": "sync.ts implements drift detection for status." }, + { "category": "realization", "source_local_id": 12, "target_local_id": 55, "basis": "explicit", "rationale": "The command surface realizes the document-store CLI requirement." }, + { "category": "realization", "source_local_id": 18, "target_local_id": 54, "basis": "explicit", "rationale": "lock.ts realizes the single-writer-safety requirement." }, + { "category": "realization", "source_local_id": 18, "target_local_id": 68, "basis": "explicit", "rationale": "Slice 8 establishes the file lock." }, + { "category": "realization", "source_local_id": 26, "target_local_id": 55, "basis": "explicit", "rationale": "The command surface enforces SQLite-first reads and JSON-first writes." }, + + { "category": "boundary", "source_local_id": 22, "target_local_id": 1, "basis": "explicit", "rationale": "The local-only constraint bounds the agent-DB goal." }, + { "category": "boundary", "source_local_id": 23, "target_local_id": 12, "basis": "explicit", "rationale": "The no-raw-SQL constraint bounds the command-surface requirement." }, + { "category": "boundary", "source_local_id": 23, "target_local_id": 55, "basis": "explicit", "rationale": "The no-raw-SQL constraint bounds what the command surface exposes." }, + { "category": "boundary", "source_local_id": 24, "target_local_id": 55, "basis": "explicit", "rationale": "The no-auto-Git constraint bounds the command surface to file writes only." }, + + { "category": "composition", "source_local_id": 57, "target_local_id": 61, "basis": "explicit", "rationale": "M1 contains the walking-skeleton slice." }, + { "category": "composition", "source_local_id": 57, "target_local_id": 59, "basis": "explicit", "rationale": "M1 contains the canonical-storage frontier." }, + { "category": "composition", "source_local_id": 57, "target_local_id": 60, "basis": "explicit", "rationale": "M1 contains the SQLite-index frontier." }, + { "category": "composition", "source_local_id": 59, "target_local_id": 62, "basis": "explicit", "rationale": "The canonical-storage frontier contains the serializer slice." }, + { "category": "composition", "source_local_id": 59, "target_local_id": 63, "basis": "explicit", "rationale": "The canonical-storage frontier contains the JSON-store slice." }, + { "category": "composition", "source_local_id": 60, "target_local_id": 64, "basis": "explicit", "rationale": "The SQLite-index frontier contains the config+schema slice." }, + { "category": "composition", "source_local_id": 60, "target_local_id": 65, "basis": "explicit", "rationale": "The SQLite-index frontier contains the sqlite-index slice." }, + { "category": "composition", "source_local_id": 58, "target_local_id": 66, "basis": "explicit", "rationale": "M2 contains the sync-engine slice." }, + { "category": "composition", "source_local_id": 58, "target_local_id": 67, "basis": "explicit", "rationale": "M2 contains the CLI-commands slice." }, + { "category": "composition", "source_local_id": 58, "target_local_id": 68, "basis": "explicit", "rationale": "M2 contains the file-lock slice." }, + { "category": "composition", "source_local_id": 58, "target_local_id": 69, "basis": "explicit", "rationale": "M2 contains the doctor-command slice." }, + + { "category": "association", "source_local_id": 25, "target_local_id": 26, "basis": "explicit", "rationale": "The JSON-wins and read/write-path invariants are peer foundations of the derived-index model." }, + { "category": "association", "source_local_id": 28, "target_local_id": 29, "basis": "explicit", "rationale": "The Drizzle stack choice and the TypeScript CLI choice are peer stack decisions." }, + { "category": "association", "source_local_id": 7, "target_local_id": 6, "basis": "explicit", "rationale": "Canonical JSON record and derived-index architecture are paired core concepts." }, + + { "category": "supersession", "source_local_id": 33, "target_local_id": 32, "basis": "explicit", "rationale": "import/export naming supersedes the earlier pull/push naming." }, + + { "category": "proof", "source_local_id": 40, "target_local_id": 15, "stance": "for", "basis": "explicit", "rationale": "Golden tests witness deterministic serialization." }, + { "category": "proof", "source_local_id": 40, "target_local_id": 35, "stance": "for", "basis": "explicit", "rationale": "Golden tests witness clean, stable data/ diffs." }, + { "category": "proof", "source_local_id": 41, "target_local_id": 35, "stance": "for", "basis": "explicit", "rationale": "The round-trip test witnesses that re-serialization produces no spurious diff." }, + { "category": "proof", "source_local_id": 42, "target_local_id": 16, "stance": "for", "basis": "explicit", "rationale": "Drift tests witness that status reports JSON/SQLite discrepancies." }, + { "category": "proof", "source_local_id": 43, "target_local_id": 37, "stance": "for", "basis": "explicit", "rationale": "The stress test witnesses safe locking under concurrent invocations." }, + { "category": "proof", "source_local_id": 44, "target_local_id": 36, "stance": "for", "basis": "explicit", "rationale": "The round-trip integration witnesses that rebuild reconstructs SQLite from JSON." }, + { "category": "proof", "source_local_id": 38, "target_local_id": 31, "stance": "for", "basis": "explicit", "rationale": "The JSONL hot-file example witnesses why per-record JSON is chosen." }, + { "category": "proof", "source_local_id": 39, "target_local_id": 21, "stance": "for", "basis": "explicit", "rationale": "The kill-during-write example witnesses the atomic-write assumption." }, + { "category": "proof", "source_local_id": 46, "target_local_id": 34, "stance": "against", "basis": "explicit", "rationale": "The Dolt assessment documents capabilities that argue against ruling Dolt out." }, + + { "category": "support", "source_local_id": 10, "target_local_id": 1, "stance": "for", "basis": "explicit", "rationale": "The off-the-shelf gap motivates building a bespoke agent DB." }, + { "category": "support", "source_local_id": 10, "target_local_id": 4, "stance": "for", "basis": "explicit", "rationale": "The off-the-shelf gap motivates owning the document-store CLI." }, + { "category": "support", "source_local_id": 11, "target_local_id": 4, "stance": "for", "basis": "explicit", "rationale": "PocketBase's auto-migrate UX motivates a single-CLI abstraction hiding migrations." }, + { "category": "support", "source_local_id": 3, "target_local_id": 27, "stance": "for", "basis": "explicit", "rationale": "The cache-with-file-sync thesis motivates the derived-index decision." }, + { "category": "support", "source_local_id": 4, "target_local_id": 12, "stance": "for", "basis": "explicit", "rationale": "The constrained-CLI thesis motivates the no-raw-SQL command requirement." }, + { "category": "support", "source_local_id": 5, "target_local_id": 31, "stance": "for", "basis": "explicit", "rationale": "The per-record-localizes-conflicts thesis motivates the per-record JSON decision." }, + { "category": "support", "source_local_id": 19, "target_local_id": 28, "stance": "for", "basis": "explicit", "rationale": "The better-sqlite3 portability assumption underpins the Drizzle + better-sqlite3 decision." }, + { "category": "support", "source_local_id": 20, "target_local_id": 31, "stance": "for", "basis": "explicit", "rationale": "The ~10k-records assumption supports per-record JSON being viable at expected scale." }, + { "category": "support", "source_local_id": 46, "target_local_id": 22, "stance": "against", "basis": "explicit", "rationale": "Dolt's multi-agent/federation capabilities argue against the strict local-only constraint." } + ] +} diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 000000000..024f713d9 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,432 @@ +# Handoff + +> Generated by `ln-handoff` at 2026-06-08T15:08:36Z. Read this file to resume work. +> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. + +## Goal + +Continue the context-pipeline PROJECT-stage redesign by fixing one level at a time: first graph PULL shape, then graph LLM renderings, then adapter/RPC/web consumers — without doing global reconciliation after each design move. + +## Session State + +- **Last completed skill**: `ln-design`-style walkthrough — produced a redesigned graph read surface and a level-by-level migration strategy. +- **Current skill**: `ln-handoff` — capturing volatile design state before compaction/new thread. +- **Flow position**: `grill → spec → plan → [design] → [oracles] → scope → [spike] → build → review → [refactor] → [sync]` + - Current actual mode is unusual: interactive design/build hybrid. The user explicitly wants conversational walk-throughs (`dev-talkthrough` + `ln-grill` style), not red/green TDD and not global verification at every step. +- **Handoff trigger**: context/compaction risk after a large graph read API pass; next thread must preserve working style and not restart broad survey. + +## In-flight work + +> CRITICAL: These artifacts exist only in the prior conversation, not on disk or not yet reconciled canonically. + +### Working style / process constraint + +The user explicitly corrected the earlier implementation pattern: + +```pseudo +wrong rhythm + design one graph API idea + migrate every downstream layer immediately + run full verification + fix every layer just to stay green + -> too slow, hides design thinking, churns layers that are still under discussion + +current desired rhythm + choose one level + walk/talk through actual API shape with human + make the level coherent + run focused tests/checks only enough to survey breakage + move to next level intentionally + do not globally reconcile every consumer until its layer is being designed +``` + +This means the next thread should **not** immediately chase all remaining drift or run `npm run verify` as first action. The next step is design review of graph renderings. + +### Graph PULL design now implemented in unstaged files + +The graph read surface has been collapsed to two core reads: + +```pseudo +src/graph/queries.ts + queryGraph(filter?, options?) -> GraphSlice + asks: "what subgraph matches this graph/node predicate?" + filter: GraphFilter = { + kinds?: NodeKind[] + bands?: ReadinessBand[] + hasEdge?: EdgePresenceFilter + lacksEdge?: EdgePresenceFilter + } + options: GraphReadOptions = { + visibility?: active | all + } + returns: GraphSlice = { + nodes: GraphNode[] + edges: GraphEdge[] + lsn: Lsn + } + + getNodes(selectors, options?) -> NodeNeighborhood[] + asks: "for these explicit node identities, what node-local context exists?" + selectors: + - { id: number } + - { code: string } + options: GetNodesOptions = { + hops?: number + visibility?: active | all + } + returns per selector: + found: + selector + status: found + node: GraphNode + related: GraphNode[] + edges: GraphEdge[] + not_found: + selector + status: not_found + related: [] + edges: [] +``` + +Prior variants/permutations were deleted at graph PULL level: + +```pseudo +removed/absorbed + getGraphOverview -> queryGraph() + getGraphSliceByKinds -> queryGraph({ kinds }) + getGraphSliceByReadinessBands -> queryGraph({ bands }) + getGraphGaps -> queryGraph({ ..., lacksEdge }) + getNodeNeighborhood -> getNodes([{ id/code }], { hops }) + getRelatedNodes -> not a graph PULL primitive; adapter/render concern if retained +``` + +### Important semantic decision: edge direction + +Edge direction is mechanical and relative to candidate node, not category semantics: + +```pseudo +edge A -> B + +for candidate A: + outgoing + +for candidate B: + incoming + +both: + source == candidate OR target == candidate +``` + +For `queryGraph({ lacksEdge: { categories: ['proof'], direction: 'incoming' } })`, meaning is: + +```pseudo +candidate node N matches iff: + no visible edge E exists where: + E.category == proof + E.targetId == N.id +``` + +Do not let graph PULL reinterpret category semantics. Higher layers/prompt prose may say “claims lacking proof,” but PULL stays mechanical. + +### Projection/rendering reclassification + +The user pushed back on a dedicated `projections/graph/neighborhood.ts` module: + +```pseudo +old assumption + projectNeighborhood exists as reusable PROJECT DTO + +new assessment + NodeNeighborhood already preserves domain shape + only observed consumer is LLM rendering / flattening + flattening + deduping is renderer prep, not reusable projection yet + +current unstaged state + src/projections/graph/neighborhood.ts + temporary empty topology marker with export {} + + src/renderers/graph/neighborhood.ts + owns NodeNeighborhood -> LLM text flattening +``` + +This means `src/projections/README.md` is stale: it still says `graph/neighborhood` is a `●` survivor needing invariant. The new disposition is likely `○` temporary marker or delete/retire after README reconciliation, depending on the next design decision. + +### Layer model agreed with user + +```pseudo +PULL / graph + queryGraph -> GraphSlice + getNodes -> NodeNeighborhood[] + +RPC / web + should relay full read shapes + graph.overview should be GraphSlice-ish + graph.nodeNeighborhood should be NodeNeighborhood + web decides its own rendering later + +Pi TUI toolResult.details + may persist full data shapes + details can be GraphSlice or NodeNeighborhood[] + +Pi LLM text + wants flattened renders + .pi/extensions/graph tools need option surfaces similar to PULL but text output is flat + .pi/agents/context should use the same render functions as tools where possible +``` + +### Rendering survey / next design focus + +The next thread should get set up to examine renderings of **GraphSlice** and **NodeNeighborhood**. + +Current `NodeNeighborhood` render path: + +```pseudo +src/renderers/graph/neighborhood.ts + formatNeighborhood(result: NodeNeighborhood, options?) -> string + +preview command used: + npm run render -- graph-neighborhood bilal-port code-health R1 --output /tmp/brunch-neighborhood-R1.md + +observed output: + [Selected-spec node context] + - anchor: [R1] intent/requirement: ... + - anchor body: ... + - neighbors: + - [T4] intent/term: ... + - [D11] intent/decision: ... + - edges: + - R1 -[dependency]-> D11 + - R1 -[dependency]-> T4 +``` + +Current `GraphSlice` render-ish path: + +```pseudo +src/.pi/extensions/graph/command-adapter.ts + formatGraphOverview(slice: GraphSlice, heading?) -> string +``` + +But this is a smell: + +```pseudo +problem + renderer-ish code lives in adapter layer + rendering full bilal-port/code-health emitted hundreds of nodes + too large for default LLM context/tool output +``` + +Concrete evidence from preview: + +```pseudo +formatGraphOverview(queryGraph(code-health)) + -> "Graph overview (LSN 2): 277 node(s), 446 edge(s)." + -> then prints a huge unbounded list of nodes/edges +``` + +Next design should compare GraphSlice render variants: + +```pseudo +GraphSlice render variants to discuss + compact-summary + counts, lsn, grouped counts by plane/kind, top N nodes + + grouped-list + nodes grouped by plane/kind, capped per group + + evidence/gaps slice + focused list for queryGraph({ lacksEdge ... }) + can use "gaps" as tool/render phrase without making it PULL API term + + full-debug + unbounded-ish, for fixtures/probes only, not LLM default +``` + +And NodeNeighborhood render variants: + +```pseudo +NodeNeighborhood render variants + anchor-context + current renderer, likely good starting point + + related-only + possible replacement for old read_graph related mode if retained + + multi-anchor-context + if getNodes returns >1 neighborhoods for tool/context use +``` + +### Concern discovered during review: `read_graph related` semantics + +Current `.pi/extensions/graph/index.ts` maps `related` mode by: + +```pseudo +getNodes(anchorCodes, { hops }) + -> collect all edge categories during traversal + -> filter resulting edges by edgeCategory/direction afterward +``` + +This is **not equivalent** to “follow only this edge category/direction,” especially for `hops > 1`. + +Bad case: + +```pseudo +A -[association]-> B -[dependency]-> C + +related(anchor=A, edgeCategory=dependency, hops=2) + current post-filter could surface dependency B->C + but there is no dependency path out of A +``` + +Decision pending: + +```pseudo +options + 1. retire/de-emphasize read_graph related mode for now + 2. constrain related mode to hops: 1 + 3. implement true category/direction traversal in graph PULL (but this reopens options we intentionally removed from getNodes) +``` + +Recommendation from prior assistant: retire or constrain `related` until deliberately redesigned. + +### Test deletion / coverage concern + +Large files were reduced sharply: + +```pseudo +src/.pi/__tests__/graph-tools.test.ts +src/graph/queries.test.ts +``` + +This may be fine because old read modes were removed/absorbed, but once tool/render modes are settled, add focused tests per surviving mode. Do not restore broad old tests blindly. + +## Review findings + +No formal `ln-review` was run. Informal review findings from the latest pass: + +| # | Finding | Status | Implications | +| --- | --- | --- | --- | +| 1 | `read_graph related` mode post-filters after unconstrained traversal, wrong for `hops > 1`. | `deferred` | Decide before locking graph tool rendering. Likely retire/constrain related. | +| 2 | `formatGraphOverview` is renderer-ish code in `.pi/extensions/graph/command-adapter.ts`. | `in-progress` | Move/design GraphSlice LLM render under `src/renderers/graph/` or another shared renderer seam. | +| 3 | Full GraphSlice rendering is unbounded and enormous for large fixtures (277 nodes / 446 edges in `code-health`). | `in-progress` | Need compact/grouped LLM render variants before relying on it in prompts/tools. | +| 4 | `src/projections/README.md` ledger is stale relative to `projections/graph/neighborhood.ts` becoming an empty marker. | `deferred` | Reconcile after graph rendering decision; do not prematurely lock a deleted/empty projection. | +| 5 | Reduced graph/tool tests may leave mode coverage thinner until new render/tool surface is finalized. | `deferred` | Add focused tests after mode decisions, not before. | + +## Diagnostic evidence + +- `npm run check` after the reported big pass was green per user report before handoff. +- Targeted tests after the reported big pass were green per user report: 12 files / 74 tests passed. +- Current unstaged file list is large and matches the reported big pass; no files are staged. +- Render preview evidence: + - `npm run render -- graph-neighborhood bilal-port code-health R1 --output /tmp/brunch-neighborhood-R1.md` succeeded. + - Output was compact and plausible for `NodeNeighborhood`. +- GraphSlice render evidence: + - Directly rendering `queryGraph` over `.fixtures/seeds/bilal-port/code-health.json` via `formatGraphOverview` produced `277 node(s), 446 edge(s)` and a huge list, proving current default GraphSlice LLM render is too verbose. + +## Decisions and assumptions + +| Item | Type | Status | Source | +| --- | --- | --- | --- | +| Work level-by-level; do not fix all downstream consumers at every pass. | `decision` | `volatile` | conversation | +| Graph PULL API is `queryGraph` + `getNodes`, not six separate read functions. | `decision` | `implemented unstaged` | conversation + code | +| `queryGraph()` with empty/default filter replaces `getGraph`/overview. | `decision` | `implemented unstaged` | conversation + code | +| `GraphFilter` has flat node predicates; no top-level `nodes` wrapper yet. | `decision` | `implemented unstaged` | conversation + code | +| `getNodes` returns `NodeNeighborhood[]`, preserving selector outcome and per-anchor local context. | `decision` | `implemented unstaged` | conversation + code | +| RPC/web should receive full data shapes; rendering belongs to web/UI or LLM renderers, not RPC. | `decision` | `volatile/partially implemented` | conversation | +| Pi `toolResult.details` can use full data shapes (`GraphSlice` / `NodeNeighborhood[]`). | `assumption` | `volatile` | conversation | +| Dedicated `projectNeighborhood` is not earned yet; flattening lives next to renderer. | `decision` | `implemented unstaged; docs stale` | conversation + code | +| `related` mode semantics are unresolved. | `assumption` | `volatile` | latest review | + +## Repo state + +- **Branch**: `ln/fe-811-ship-gate-residue-and-mentions` (ahead 6 of origin at handoff time) +- **Recent commits**: + - `2fdf2c89 first pass on realigning graph read functions` + - `4a61c73d Author projections ledger and correct PROJECT/PULL stage in PLAN` + - `9ecfe381 Reshape PLAN around dependency-ordered context-pipeline coverage trio` + - `12b16cf3 Add prompt-composition-golden-coverage frontier` + - `389982ef Correct coverage reclassification wording in PLAN` +- **Dirty files**: + - `src/.pi/__tests__/graph-tools.test.ts` + - `src/.pi/__tests__/prompting.test.ts` + - `src/.pi/agents/contexts/graph.test.ts` + - `src/.pi/agents/contexts/graph.ts` + - `src/.pi/agents/contexts/node.test.ts` + - `src/.pi/agents/contexts/node.ts` + - `src/.pi/brunch-pi-extensions.ts` + - `src/.pi/extensions/graph/command-adapter.ts` + - `src/.pi/extensions/graph/index.ts` + - `src/.pi/extensions/graph/tool-schemas.ts` + - `src/.pi/extensions/system-prompts/index.ts` + - `src/app/brunch-tui.test.ts` + - `src/app/brunch-tui.ts` + - `src/graph/export-fixtures.ts` + - `src/graph/index.ts` + - `src/graph/queries.test.ts` + - `src/graph/queries.ts` + - `src/graph/render-preview.ts` + - `src/graph/review-set.test.ts` + - `src/graph/spec-ownership.test.ts` + - `src/graph/workspace-store.ts` + - `src/probes/capture-response-to-graph-proof.ts` + - `src/probes/fixture-curation-loop.test.ts` + - `src/probes/fixture-curation-loop.ts` + - `src/probes/project-graph-review-cycle-proof.test.ts` + - `src/probes/project-graph-review-cycle-proof.ts` + - `src/probes/propose-graph-commit-proof.test.ts` + - `src/probes/propose-graph-commit-proof.ts` + - `src/probes/submit-message-capture-proof.ts` + - `src/projections/graph/neighborhood.ts` + - `src/renderers/graph/neighborhood.ts` + - `src/rpc/methods/graph.ts` + - `src/session/workspace-context.ts` + - `src/web/features/graph/GraphOverview.tsx` + - `src/web/queries/graph.ts` + - untracked: `src/graph/read-api.test.ts` +- **Test status**: + - Last known from user report after big pass: `npm run check` ✅ + - Last known targeted suite from user report: 12 files / 74 tests ✅ + - During handoff, no full verification was rerun; only render previews / file reads were performed. + +## Artifact status + +| Artifact | Exists | Current vs conversation | +| --- | --- | --- | +| `memory/SPEC.md` | yes | likely stale re: graph PULL API naming and projection disposition; do not update until design settles further | +| `memory/PLAN.md` | yes | stale re: `projection-shape-coverage` details: graph neighborhood projection no longer a `●` survivor as previously ledgered | +| `src/projections/README.md` | yes | stale: still says `graph/neighborhood` is real projection needing invariant; current code makes it empty topology marker | +| `src/graph/README.md` | yes | likely stale: observed read-shape ledger names old `getGraphOverview`, `getNodeNeighborhood`, slice/gaps/related functions | +| `memory/cards/` | yes | `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md`; unrelated live/stale status not assessed | +| `memory/REFACTOR.md` | no | n/a | + +## Next steps + +1. **Continue design walkthrough, not global reconciliation.** Start with `src/renderers/graph/` and design LLM renderings for `GraphSlice` and `NodeNeighborhood`. +2. Produce/compare concrete render outputs for at least: + - `NodeNeighborhood` current `R1` preview (`bilal-port/code-health`) + - a small/medium `GraphSlice` (maybe `workspace-spread/alpha-grounding`) + - the large `GraphSlice` (`bilal-port/code-health`) to pressure truncation/grouping +3. Decide GraphSlice renderer API and location. Likely target: + - `src/renderers/graph/slice.ts` or extend `src/renderers/graph/neighborhood.ts` only if naming stays acceptable + - migrate `formatGraphOverview` out of `.pi/extensions/graph/command-adapter.ts` once shape is approved +4. Decide fate of `read_graph related` mode before locking tool tests: + - retire, constrain to `hops: 1`, or redesign traversal separately +5. After renderer decisions, update `.pi/extensions/graph` and `.pi/agents/contexts/graph|node` to use shared renderer(s). +6. Only after graph render/tool shape is stable, reconcile docs/ledgers (`src/projections/README.md`, `src/graph/README.md`, `memory/PLAN.md`). + +## Retirement rule + +- Delete or overwrite this file once the volatile state above is absorbed into `memory/SPEC.md`, `memory/PLAN.md`, code, or a newer `HANDOFF.md`. + +## Open questions + +- Should `read_graph related` survive? If yes, should it be limited to `hops: 1`, or should graph PULL regain category/direction traversal? +- What are the canonical LLM render variants for `GraphSlice`? +- Should there be a separate renderer for prompt context vs tool output, or a shared renderer with small options? +- Should `projections/graph/neighborhood.ts` remain as a temporary marker, or be deleted when `src/projections/README.md` is reconciled? +- Should RPC `graph.overview` continue returning `nodeCount`/`edgeCount`, or strictly relay `GraphSlice` and let web count locally? + +## Resume prompt + +Paste this into a new session: + +> Read `HANDOFF.md` in the workspace root. We are in a level-by-level graph read/render redesign, not a global reconciliation pass. The graph PULL API has already been reshaped to `queryGraph(filter?, options?) -> GraphSlice` and `getNodes(selectors, options?) -> NodeNeighborhood[]` in unstaged files. The immediate next step is to review concrete LLM renderings of `GraphSlice` and `NodeNeighborhood`: start by reading `src/renderers/graph/neighborhood.ts`, `src/.pi/extensions/graph/command-adapter.ts`, and the handoff's rendering survey, then produce compact preview options for `GraphSlice` before touching docs or running full verification. diff --git a/docs/design/GRAPH_MODEL.md b/docs/design/GRAPH_MODEL.md index 3cc62e26f..e94c4851a 100644 --- a/docs/design/GRAPH_MODEL.md +++ b/docs/design/GRAPH_MODEL.md @@ -189,39 +189,54 @@ 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; query/projection builders use -this table to bucket edges; coherence triggers use the cascade -column. - -| | cascade on src change | recon_need on src change | criteria-help signal | projection effect | -| -------------- | :-------------------: | :----------------------: | :------------------: | ---------------------------------------------- | -| `dependency` | ✓ | ✓ | — | — | -| `proof` | — | advisory | ✓ | — | -| `support` | — | advisory | — | — | -| `realization` | — | advisory | — | — | -| `boundary` | — | ✓ | — | — | -| `composition` | — | — | — | — | -| `association` | — | — | — | — | -| `supersession` | — | — | — | hide predecessor from active context | +The category drives all policy. This is the **single** per-category +metadata table — materialized as `EDGE_CATEGORY_METADATA` in +[`src/graph/policy/category-policy.ts`](../../src/graph/policy/category-policy.ts). +It supersedes the earlier split where endpoint-role/reconciliation +metadata briefly lived in `schema/edges.ts` while a drifted +`CATEGORY_POLICY` lived alongside it (the two disagreed on impact +direction for `proof`/`support`). The `CommandExecutor` enforces +structural legality at write time; query/render builders use source +and target roles to label edges semantically; coherence and the +reconciliation flow use the impact columns. Mechanical `incoming` / +`outgoing` direction is only endpoint geometry for filters and traversal. + +| | Source role | Target role | Impact on source change | Impact on target change | criteria-help signal | projection effect | +| -------------- | ----------- | ----------- | ----------------------- | ----------------------- | :------------------: | ------------------------------------ | +| `dependency` | dependency | dependent | cascade → target | none | — | — | +| `proof` | oracle | claim | none | advisory → source | ✓ | — | +| `support` | support | claim | none | advisory → source | — | — | +| `realization` | abstract | concrete | advisory → target | none | — | — | +| `boundary` | boundary | subject | advisory → target | none | — | — | +| `composition` | whole | part | none | advisory → source | — | — | +| `association` | peer | peer | none | none | — | — | +| `supersession` | successor | predecessor | none | advisory → source | — | hide predecessor from active context | Legend: -- **cascade** — automatic block / mark stale on the dependent - (e.g. assumption invalidation cascade). -- **recon_need on src change** — generate a `ReconciliationNeed` - pointing at the edge. *Advisory* = generated only if a coherence - rule asks for it; the edge does not auto-cascade. -- **criteria-help** — used by the interviewer to suggest criteria - for the target node ("requirement with no `proof` incoming → - suggest criterion"). -- **projection effect** — how query/neighborhood builders treat - the edge in active-context views. - -Only `dependency` triggers automatic cascades. Other categories -surface as reconciliation needs at most; they do not auto-block +- **impact on source/target change** — if the named endpoint node + changes, how the *opposite* endpoint is affected: `cascade` (hard — + may auto block / mark-stale), `advisory` (soft — surface a + `ReconciliationNeed`), or `none`. The arrow names the impacted + (downstream) endpoint. A well-formed category drives impact in at + most one direction. +- **criteria-help** — used by the interviewer to suggest criteria for + the claim ("requirement with no incoming `proof` edge → suggest + criterion"). +- **projection effect** — how query/neighborhood builders treat the + edge in active-context views. + +Only `dependency` triggers a hard cascade. Other categories surface +as advisory reconciliation needs at most; they do not auto-block downstream items. +The impact columns are *not* aligned with source→target geometry: +for `dependency`/`realization`/`boundary` the source is upstream, but +for `proof`/`support`/`composition`/`supersession` the **target** is +upstream. The directional projection (below) derives upstream / +downstream / lateral from these columns so the reconciliation flow +never has to guess direction from the arrow. + ## Worked examples — same shape across planes ```text @@ -320,12 +335,20 @@ Examples: | `supersession(new req → old req)` | "supersedes prior" | "superseded by" | | `association(A ↔ B)` | "related to B" | "related to A" | -The lookup is a static table keyed on -`(category, source.kind, target.kind, perspective[, stance])`. It is -built by inverting the prior catalogue entries plus the `proof` rows -above. It lives separately from this document — the canonical -location for the table is TBD (likely -`src/graph/projection/labels.ts` when projection builders land). +The lookup is materialized as `edgeLabel()` in +[`src/graph/projection/labels.ts`](../../src/graph/projection/labels.ts). +It is a two-tier static table: + +- **Tier 1 (base)** keyed on `(category, anchorRole, stance)` — ~18 + cells covering every edge from the anchor's perspective. The + neighbor's `kind` is rendered separately, so headings never embed it. +- **Tier 2 (refine)** keyed on `(category, sourceKind, targetKind)` — + optional finer verbs where the neighbor's kind alone is too vague + (primarily the realization sub-types). Deliberately small; absence + falls back to the Tier-1 heading. + +The lookup cannot change category policy; it only renders the stored +edge readably from one endpoint's perspective. ### Realization sub-types — tuple-implied, not edge-encoded @@ -342,9 +365,29 @@ projection policy, split `realization` into siblings (see ## Context projections and bucketing -Context buckets come from category and endpoint role, not from the -derived label string. Callers must also choose which -projection they want: +Two projection axes are derived from the per-category metadata, never +from the rendered label string. Both are anchor-relative — they read +an edge from the perspective of one node: + +- **Semantic labels** ([`projection/labels.ts`](../../src/graph/projection/labels.ts)) + — direction-aware phrasing (`depends on`, `realizes`, `motivated + by`) from `(category, anchorRole, stance)` plus optional kind + refinement. Drives readable per-edge text. +- **Directional grouping** ([`projection/direction.ts`](../../src/graph/projection/direction.ts)) + — `upstream` / `downstream` / `lateral` plus `hard` / `soft` + strength, derived from the impact columns. Drives the + reconciliation flow (log downstream impacts when a node changes) and + the neighborhood section grouping. `relationFromAnchor` returns + `downstream` when the anchor sits at the upstream end (changing the + anchor impacts the neighbor) and `upstream` when it sits at the + downstream end. + +The two compose: the neighborhood renderer groups incident edges by +directional relation and labels each line semantically. A pure +semantic-bucket grouping (by category role rather than direction) is +an alternative view over the same two functions. + +Callers must also choose which projection they want: - **`graph_truth`** — accepted graph truth records. Superseded predecessors and their edges may still appear because they are @@ -371,32 +414,26 @@ relatedNodes({ overview({ projection: "graph_truth" | "active_context" }) ``` -A rendered neighborhood context of an intent node: +A rendered neighborhood context of an intent node, grouped by the +directional axis and labelled semantically (exact layout is still +being tuned; the projection contract is what is locked): ```text -anchor: R1 : requirement - -hard dependencies: - A1 depends on assumption - -support: - CTX2 motivated by context - -proof: - CR1 witnessed by criterion - EX1 witnessed by example - -realized by: - M1 realized by design module - SL1 established by plan slice - -boundaries: - CON1 bounded by constraint - -supersedes: - R0 supersedes prior requirement +[Selected-spec node context] +- anchor: [REQ1] intent/requirement: Stage 2 must compute three configuration spaces… +- upstream (review anchor if these change): + - depends on [A1] intent/assumption: Users run fully local… + - realizes [INV3] intent/invariant: No network call in the offline path… +- downstream (reconcile if anchor changes): + - required by [D11] intent/decision: Adopt the two-stage split… {hard} + - witnessed by [CR1] intent/criterion: Airplane-mode test passes… {soft} +- lateral (related): + - related to [CTX2] intent/context: Stakeholder preference… ``` +`{hard}` marks a `dependency` cascade; `{soft}` marks an advisory +reconciliation need. + ## Structural invariants - Edge categories are closed. Agents cannot submit arbitrary diff --git a/memory/PLAN.md b/memory/PLAN.md index 7bfd7c39d..5b3f9f529 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -27,12 +27,53 @@ 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` (the D65-L *substrate*) was the first such promotion and is landed; the prompt-resource body-depth pass is also built (1ca02e38). **The cross-cut is not yet exhausted:** its Seam 3a `"what to ask next" driver` row is still `partial · ●`, and the seam DoD holds a seam open while any `●` row is `partial`. That row — the *live per-turn elicitation-backlog driver* (read open entries → rank → select next question; capture-reflection grows/closes entries) — is a required elicitor capability that has escaped row-sized work, so per the cross-cut's own rule it is promoted here as the `elicitation-driver` frontier. It is buildable now (the FE-823 read-back exists) and is **not** POC-ship-critical (the POC delivery cut de-scopes elicitation quality), so it sequences as a coverage frontier, not a ship-gate blocker. +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` (the D65-L *substrate*) was the first such promotion and is landed; the prompt-resource body-depth pass is also built (1ca02e38). **The cross-cut is not yet exhausted:** its Seam 3a `"what to ask next" driver` row is still `partial · ●`, and the seam DoD holds a seam open while any `●` row is `partial`. That row — the *live per-turn elicitation-backlog driver* (read open entries → rank → select next question; capture-reflection grows/closes entries) — is a required elicitor capability that has escaped row-sized work, so per the cross-cut's own rule it is promoted here as the `elicitation-driver` frontier. It is buildable now (the FE-823 read-back exists) and is **not** POC-ship-critical (the POC delivery cut de-scopes elicitation quality). It is itself a bounded feature, not a coverage frontier; as the cross-cut's promoted closing row it sequences ahead of fresh coverage breadth, but it is not a ship-gate blocker. The `graph-observed-shapes` coverage frontier has now landed (the consumer-specific read-shape inventory is ratified in `src/graph/README.md` and guarded by a drift test). With `minimal-authority-shell` also done, the active delivery path is `poc-live-ship-gate` (now unblocked). The remaining coverage frontiers are being deliberately de-fogged rather than left parked, because "wait for a forcing function" can hide capability layers we simply never built. Each is reclassified: `runtime-affordances-and-legality` is mostly **buildable-now** — its core is one Brunch-owned `affordances(resolvedState)` derivation over legality/default tables that already exist, so it is being re-inventoried as a coverage ledger (only its `active-review-set` / `turn-mode` rows are genuinely product-state-gated and stay tripwired). `exchanges-and-generalized-capture` is **evidence-gated**: the exchange topology is enumerable now, but capture quality beyond directly-labeled facts (A22-L) needs a measurement, so it is being attacked with a capture-quality spike rather than awaited. The `elicitation-driver` frontier (promoted above) is likewise buildable now. +**Coverage-layer re-classification (2026-06-08 ln-plan, applying the hardened coverage protocol).** Re-asking "where are the *real* coverage frontiers" gives a tight answer: the coverage layer is mostly already closed. `graph-observed-shapes` and `runtime-affordances-and-legality` are both done genuine coverage. `exchanges-and-generalized-capture` **fails the coverage admission gate** — the remaining load-bearing unknown is capture *semantics* (a vertical proving slice with false-commit protection), not breadth closure; the exchange surface is largely built, with some breadth still explicitly deferred / topology-stubbed (e.g. `present-candidates` across all three layers), so it is reclassified to a bounded proving feature plus a delete-oriented symmetry audit, not a breadth fill. The genuinely-open coverage is **one pipeline, not scattered locking chores** — see the next subsection. The remaining open stages are `projection-shape-coverage`, `renderer-golden-coverage`, and `prompt-composition-golden-coverage`, and per the 2026-06-08 deep per-plane pass (below) they are now the **near-term spine**, sequenced in dependency order, each completing its **full ledger** with a human-in-the-loop design→lock rhythm. This revises the earlier "parallel/discretionary, never preempt `elicitation-driver`" disposition: the user has elevated the pipeline-coverage trio to the next 2–3 frontiers. `elicitation-driver` and `exchanges-and-generalized-capture` remain bounded features sequenced after the trio (`elicitation-driver` pairs naturally with the COMPOSE stage, which locks the oracle its behavior rides on); `poc-live-ship-gate` remains the in-flight delivery gate, proceeding independently on the FE-811 branch. + +A new graph-mutation planning result has been promoted into the rolling plan as `role-safe-graph-mutations`. It folds the prior role-named edge-surface scope and semantic seed-curation mutation scope into one initiative: `mutateGraph` / `mutate_graph` becomes the canonical authored graph-mutation grammar, create-edge ops use role-named endpoint fields, and exposed `commitGraph` / `commit_graph` is retired by break-and-repair rather than preserved as a weaker parallel API. This frontier is orthogonal to the context-pipeline coverage trio, but it is load-bearing for any future relation capture from unstructured data and for dev fixture curation; downstream capture/curation work must aim at `mutateGraph`, not recreate `{category, source, target}` at a new boundary. + +### Context-pipeline coverage (the next design/lock spine) + +The four LLM-facing context concerns are not independent — they are the stages of **one pipeline** (D60-L): **PULL → PROJECT → RENDER → COMPOSE → surface**. Coverage means *each stage carries its appropriate oracle over a complete, ledgered inventory*. The stages must be closed **in dependency order**, because each downstream lock is only stable once its upstream shape is locked (projection invariants churn while read shapes still move; renderer goldens churn while projection shapes still move; prompt goldens churn while renderer output still moves). + +**PULL is not one done stage — it has two halves.** The *graph* read surface is the template and is **done**: ledgered (`src/graph/README.md` observed-read-shape ledger) + drift-guarded (`observed-shapes-coverage.test.ts`). The *session* read surface (`session/workspace-context.ts`, `session/workspace-session-coordinator.ts`, `session/runtime-state.ts`, …) is behaviorally tested but **not yet inventoried as a closed read-shape ledger**. Because the session/workspace projections lock against those session reads, that PULL half must be ledgered before its downstream projection invariants are frozen. (The earlier "Stage 1 PULL is done" claim was graph-only.) + +The oracle *kind* differs by stage — this is the load-bearing distinction the flat "lock everything" framing hid: + +- **info-preserving stages (PULL, PROJECT)** want **invariant / no-loss / shape** oracles. A golden here is the wrong tool — brittle, and it cannot even catch the failure that matters (a projection silently dropping a field the renderer also hides). +- **lossy stages (RENDER, COMPOSE)** want **golden locks + semantic invariants**, because output wording/shape is itself the contract. + +``` +context-pipeline/ D60-L +├── PULL graph reads queries.ts invariant + drift ✓ DONE #pull +│ session reads session/* behaviorally tested ◐ un-ledgered +├── PROJECT @projections projections/ no-loss / shape ○ open #project -> renderer +├── RENDER @renderers renderers/ golden + invariant ◐ open #render -> compose +└── COMPOSE @pi-agents compose.ts+skills/ golden + invariant ◐ open #compose + +dependency: pull(session) -> #project -> #render -> #compose (lock upstream before downstream) +``` + +**Per-frontier deliverable:** the *complete* ledger for that plane (every module given a disposition — `✓` locked / `●` keep+lock / `◐` keep-decide / `✗` delete-inline / `○` leave — with owner + oracle), authored in the plane's README. The PROJECT ledger is now authored in `src/projections/README.md` (it applies an **earns-its-place gate before the oracle gate**: a single-consumer pass-through that only re-wraps its source is indirection to delete, not a row to lock). `renderers/README.md` still claims a ledger that does not yet exist. Not "close the gaps" — close the inventory. + +**Human-in-the-loop design→lock rhythm** (so the user reviews each row before it is frozen): + +``` +per ledger row: + 1. enumerate — name the module/case and its consumers + 2. preview/contract — golden-kind: generate output via harness (npm run render / new compose preview), user eyeballs + invariant-kind: state the no-loss/shape contract, user reviews "what must be preserved" + 3. design checkpoint — user approves the shape/wording/contract [USER IN LOOP] + 4. lock — golden-kind: toMatchFileSnapshot writes on first run, diffs after + invariant-kind: shape/round-trip assertion + 5. mark ● — update the plane ledger +``` + ## Sequencing ### Active @@ -41,15 +82,27 @@ The remaining coverage frontiers are being deliberately de-fogged rather than le ### Next -1. `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. -2. `elicitation-driver` — **first coverage follow-on**: it closes the last open required cross-cut row (Seam 3a `"what to ask next" driver`) and retires the temporary dual-plan state, so it sequences ahead of any fresh coverage frontier. Buildable-now on the FE-823 substrate; not POC-ship-critical. -3. `capture-quality-spike` — evidence spike that measures generalized-capture fitness (A22-L) so `exchanges-and-generalized-capture` can graduate from horizon on real evidence rather than waiting (`memory/cards/capture-quality--fitness-spike.md`). +The near-term spine has two independent tracks. The **context-pipeline coverage trio** remains sequenced in strict dependency order (lock upstream shape before downstream output). `role-safe-graph-mutations` is a graph-mutation grammar frontier that can run before or alongside the trio, and must land before relation-bearing generalized capture or semantic fixture curation rely on the new mutation surface. + +1. `role-safe-graph-mutations` — **authored graph-mutation grammar**; structural / proving. Fold the role-named edge-surface and semantic graph-mutation cards into one frontier: introduce `mutateGraph` / `mutate_graph`, make create-edge ops role-named, port current `commitGraph` callers, and retire exposed `commitGraph` / `commit_graph` by break-and-repair. Current execution pointer: `memory/cards/role-safe-graph-mutations--mutate-graph.md`. +2. `projection-shape-coverage` — **PROJECT stage** (`#project`); invariant / no-loss kind. Ledger authored in `src/projections/README.md`. Two sub-steps: (a) **PULL-session prerequisite** — ledger the session read surface (`session/workspace-context`, `workspace-session-coordinator`, `runtime-state`) the session/workspace projections lock against; (b) **earns-its-place audit then lock** — delete/inline the `✗` indirection (`workspace/workspace-context`: single-consumer tag wrapper), resolve the `◐` exchange family (direct-lock vs keep-transitive), and add a shape/no-loss invariant to each `●` survivor (`graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, `workspace/workspace-state`). The graph projection stubs (`overview`, `commit-result`, `reconciliation-needs`) are `export {}` topology stubs, **not** dark implementations — leave them. Upstream of everything else in the trio; do this first so renderer goldens lock against stable shapes. +3. `renderer-golden-coverage` — **RENDER stage** (`#render`); golden + invariant kind. **Depends on `projection-shape-coverage`.** Create the renderer ledger (README claims one that does not exist), extend the preview harness past `graph-neighborhood`, and golden-lock every durable renderer (only `graph/neighborhood` + `session/runtime-frame` are locked; the rest are dark or only transitively covered via the `.pi` adapter). +4. `prompt-composition-golden-coverage` — **COMPOSE stage** (`#compose`); golden + invariant kind. **Depends on `renderer-golden-coverage`.** Add a composed-prompt preview harness, golden-lock partial bodies and a representative composed-prompt matrix (axis × grade × pin) on top of the existing invariants. `elicitation-driver` rides on this stage's locked oracle, so it follows. + +### After the trio + +5. `elicitation-driver` — **bounded feature; cross-cut closing row** (not itself coverage): closes the last open required cross-cut row (Seam 3a `"what to ask next" driver`) and retires the temporary dual-plan state. Buildable-now on the FE-823 substrate; pairs with the COMPOSE stage (it adds per-turn behavior over the composition oracle locked there); not POC-ship-critical. +6. `exchanges-and-generalized-capture` — **bounded proving feature** (not coverage): the remaining load-bearing unknown is capture *semantics*, not breadth closure. Narrow high-confidence extractive capture with a false-commit guard; treat any exchange-layer cleanup as delete-oriented audit, not breadth fill. Relation-bearing capture must use the role-named `mutateGraph` grammar from `role-safe-graph-mutations`; do not revive `{category, source, target}` in a capture-local edge dialect. + +### Delivery gate (in flight, independent) + +- `poc-live-ship-gate` — FE-811 delivery gate; only the final fresh-cwd runbook remains (live-mention-autocomplete + ship-gate residue landed 2026-06-08 on `ln/fe-811-ship-gate-residue-and-mentions`, PR #179). Proceeds on its own branch; not a coverage frontier and does not compete with the trio for design attention. ### Parallel / Low-conflict - `probes-and-transcripts-evolution` — continuous probe/report/transcript hardening as each delivery frontier lands evidence. - `topology-readmes-and-boundaries` — small doc/test hardening when a frontier moves files or exposes a boundary; should remain attached to the frontier when possible rather than becoming an abstract cleanup project. -- `dev-seed-fixtures` — rich, real seed data for local dev / manual / observer testing: the consolidated seed contract, the `npm run seed` loader, and growing/enhancing fixture sets (Bilal-port + legacy). +- `dev-seed-fixtures` — rich, real seed data for local dev / manual / observer testing: the consolidated seed contract, the `npm run seed` loader, and growing/enhancing fixture sets (Bilal-port + legacy). Its semantic curation mutation slice is folded into / blocked by `role-safe-graph-mutations`; ongoing seed-data maintenance remains low-conflict. ### Horizon @@ -123,7 +176,7 @@ The remaining coverage frontiers are being deliberately de-fogged rather than le - **Lights up:** open backlog entries → rank → select next question per turn; capture-reflection grows/closes entries. - **Stabilizes:** D65-L's live elicitation behavior on top of the flat `elicitation_backlog` substrate; closes the cross-cut Seam 3a row. - **Objective:** Add the per-turn driver that reads open backlog entries for the selected spec, ranks them (band/priority), selects the next question to surface, and reconciles entries from capture-reflection (open new, close answered) — all on the existing FE-823 read/write substrate. -- **Why now / unlocks:** This is buildable now (the FE-823 substrate and per-spec read-back exist) and it closes the last required cross-cut row. It is **not** POC-ship-critical (the POC delivery cut de-scopes elicitation quality), so it sequences as a coverage frontier, not a ship-gate blocker. +- **Why now / unlocks:** This is buildable now (the FE-823 substrate and per-spec read-back exist) and it closes the last required cross-cut row. It is itself a **bounded feature, not coverage**; as the cross-cut's promoted closing row it sequences ahead of fresh coverage breadth, but it is **not** POC-ship-critical (the POC delivery cut de-scopes elicitation quality), so it is not a ship-gate blocker. - **Acceptance:** - A driver reads open entries for the selected spec and produces a deterministic ranked selection of the next question. - Capture-reflection can open new entries and close answered ones through the existing `CommandExecutor` path; no second mutation clock. @@ -181,7 +234,7 @@ The remaining coverage frontiers are being deliberately de-fogged rather than le - **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`; 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. +- **Current execution pointer:** FE-811 ship-gate hardening landed on `ln/fe-811-ship-gate-residue-and-mentions`: stale graph-snapshot/report residue in the committed fixture-curation and project-graph-review-cycle runs was regenerated to the graph-overview/workspace.state contract, the related-edge formatter now labels non-anchor edges `lateral`, and the live mention autocomplete slice now sources selected-spec graph nodes instead of fixture candidates. The remaining frontier work is the final fresh-cwd runbook gate. ### graph-observed-shapes @@ -226,22 +279,122 @@ The remaining coverage frontiers are being deliberately de-fogged rather than le - **Design docs:** `memory/SPEC.md` D40-L/D59-L; `src/projections/README.md`; `src/session/README.md`. - **Current execution pointer:** Done 2026-06-08. `src/projections/session/affordances.ts` now owns the shared `(resolvedState, readinessGrade)` derivation for legal goal/strategy/lens options plus default-on-switch values, reusing the same grade/AUTO legality source consumed by `.pi/agents/state.ts`; `src/session/README.md` owns the closed coverage ledger and `src/session/runtime-affordances-coverage.test.ts` guards required agent/RPC rows while leaving `active-review-set` and `turn-mode` as explicit product-state-gated deferrals. +### role-safe-graph-mutations + +- **Name:** Role-safe `mutateGraph` / `mutate_graph` as the canonical graph mutation grammar +- **Linear:** unassigned +- **Kind:** structural / bounded feature +- **Status:** next +- **Certainty:** proving +- **Folded scopes:** `memory/cards/graph--role-named-edge-surface.md` and `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` are superseded by `memory/cards/role-safe-graph-mutations--mutate-graph.md`. +- **Lights up:** one authored graph-mutation grammar across direct agent graph writes, review-set proposal drafts, capture writes, seed-fixture loading, and dev curation RPC. +- **Stabilizes:** D51-L/D53-L/D27-L edge-authoring boundary; agents express edges by category + endpoint roles, while `sourceId`/`targetId` stays internal storage geometry derived from `EDGE_CATEGORY_METADATA`. +- **Objective:** Replace exposed create-only `commitGraph` / `commit_graph` with `mutateGraph` / `mutate_graph` as the canonical authored mutation command/tool. The grammar supports create/patch/delete operations, uses role-named create-edge variants (`oracle/claim`, `dependency/dependent`, `abstract/concrete`, etc.), normalizes those variants through `EDGE_CATEGORY_METADATA`, and preserves one `CommandExecutor` transaction, one spec-local LSN, one change-log row, and the existing stored edge shape. +- **Why now / unlocks:** The edge model was intended to help agents map relations from unstructured material, but `{category, source, target}` leaves the most error-prone directionality burden at the agent boundary. The earlier semantic-mutation curation scope would otherwise mint a richer graph-write path with a different API pattern. Taking the bigger step now prevents two graph mutation dialects, gives generalized capture one safe relation grammar, and gives fixture curation patch/delete without creating a second mutation model. +- **Break-and-repair path:** Change the canonical shape first, then let type/test failures enumerate callers. Add `RoleNamedEdgeDraft` + a drift-tested normalizer over `EDGE_CATEGORY_METADATA`; introduce `CommandExecutor.mutateGraph` / a shared mutation planner; remove/rename exposed `commit_graph` and repair prompt resources, Pi graph tool schemas/adapters, capture, seed loader, review-set translation, dev RPC, probes, and docs to `mutate_graph`. `acceptReviewSet` remains the workflow/audit command but reuses the same mutation planner. Do not keep a compatibility bridge accepting both role-named and generic source/target edge drafts; any temporary create-only helper must be private, delegate to `mutateGraph`, and be removed before frontier completion unless a same-slice caller proves it still earns its place. +- **Acceptance:** + - `mutateGraph` / `mutate_graph` is the one exposed authored graph-mutation grammar; exposed `commitGraph` / `commit_graph` is retired or private-only over the same engine. + - Create-edge ops are an 8-variant role-named union at category/role granularity; no tuple-specific relation catalogue is introduced. + - Role field names are test-pinned to `EDGE_CATEGORY_METADATA`; normalization to private `source`/`target` is table-driven, and generic `{category, source, target}` authored drafts are rejected at graph tool and review-set boundaries. + - Create/patch/delete batches are atomic: one transaction, one selected-spec LSN, one change-log row; invalid ops reject the whole batch without writes or clock advancement. + - Edge identity remains immutable: category, semantic endpoints / stored endpoints, stance, and basis cannot be patched; changing them requires delete+create or supersession. + - Policy gates op kinds by caller/posture, so the unified tool grammar does not silently grant autonomous agents deletion authority. + - Product writers are ported: propose-graph uses create-only ops with `createBasis: implicit`; capture and seed loading use create-only ops with `createBasis: explicit`; review-set proposals use role-named edge drafts and acceptance reuses the shared planner; dev curation RPC exposes projected-code create/patch/delete through the same command. +- **Verification:** Inner — normalizer/drift/schema tests over all eight categories; `CommandExecutor` mutation tests for creation parity, patch/delete legality, rollback, sibling-spec rejection, LSN/change-log behavior, and no-reuse ordinals. Middle — graph tool/review-set/capture/seed/dev-RPC tests repaired to `mutateGraph`; dry-run/accept parity for review sets; grep/source tests quarantine `source`/`target` to internal planner/storage/projection code. Outer — optional propose-graph / project-graph probe regeneration if committed fixtures encode old `commit_graph` payloads. +- **Cross-cutting obligations:** Preserve D4-L/D20-L command boundary, D16-L/A4-L spec-local mutation clock, D51-L stored edge identity, D62-L projected node codes, D63-L `basis` semantics, and D52-L ownership (`graph/` owns mutation semantics; adapters translate only at boundaries). Do not re-orient persistence to upstream/downstream and do not add a read DTO merely to mirror direction; `projection/direction.ts` remains the read projection. +- **Traceability:** D4-L, D16-L, D20-L, D27-L, D51-L, D52-L, D53-L, D62-L, D63-L / A14-L / I1-L, I11-L, I15-L, I20-L, I34-L, I39-L, I40-L, I41-L. +- **Design docs:** `docs/design/GRAPH_MODEL.md`; `memory/cards/role-safe-graph-mutations--mutate-graph.md`; `memory/SPEC.md` D27-L/D51-L/D53-L. + +### projection-shape-coverage + +- **Name:** Close the projections ledger with no-loss / shape invariants (PROJECT stage) +- **Linear:** unassigned +- **Kind:** coverage (buildable-now) / hardening +- **Status:** next — trio stage 1 (`#project`) +- **Certainty:** proving +- **Pipeline position:** PROJECT — the info-preserving DTO stage between PULL and RENDER (`renderers/`). PULL has two halves: the *graph* read surface is locked/ledgered (`graph/queries.ts` + `src/graph/README.md`), but the *session* read surface the session/workspace projections lock against is tested-but-un-ledgered, so this frontier carries a PULL-session prerequisite. Upstream of `renderer-golden-coverage`; lock projection shapes before renderer goldens so the goldens do not churn against moving DTOs. +- **Coverage-gate verdict (2026-06-08 deep per-plane pass; refined at design checkpoint):** **Passes the admission gate, and is the genuinely-new finding.** Named load-bearing layer (`src/projections/`), closeable inventory. The ledger is now authored in `src/projections/README.md`. Direct-coverage today: only `request-choice` (`✓`) and `affordances` (`✓`) plus the `topology-boundaries` import guard. The enumeration **corrected the plan's dark-zone claim**: `graph/{overview,commit-result,reconciliation-needs}` and `exchanges/present-candidates` are `export {}` **topology stubs**, not dark implementations (nothing to lock — `○`). The real `●` survivors needing invariants are `graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, and `workspace/workspace-state`. The enumeration also found one `✗` indirection: `workspace/workspace-context` is a single-consumer `{ mode, data }` tag wrapper with zero transform — **delete/inline**, feed its consumer from the source read. The exchange family (`present-*`, `request-answer/choices/review`, `review-set-payload`) is `◐`: covered transitively via `.pi` tests, direct-lock optional. +- **Oracle kind:** **invariant / no-loss / shape — NOT golden.** Projections are info-preserving (D60-L); a golden would be brittle and could not catch the failure that matters (a projection dropping a field the renderer also hides). Lock with shape assertions (required fields present, types correct) and round-trip / no-loss properties where a projection re-shapes a typed read. An **earns-its-place gate runs before the oracle gate**: a single-consumer pass-through is deleted, not locked. +- **Boundary:** In — the `●` DTO transforms (`graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, `workspace/workspace-state`), the `✗` delete (`workspace/workspace-context`), the `◐` exchange-family decision, and the PULL-session read-shape ledger. Out — `○` topology stubs (`graph/{overview,commit-result,reconciliation-needs}`, `exchanges/present-candidates`), `session/runtime-policy` (policy data, not a transform), `topology-boundaries` (already an import guard), and the already-locked `✓` rows. +- **Aggregate DoD:** Every `●` projection carries a shape/no-loss invariant; every `✗` row is deleted/inlined with its consumer fed from source; `◐` rows resolved by explicit decision; `○` rows untouched. The session-PULL read-shape ledger exists. Every `projections/` module appears in `src/projections/README.md` with a disposition (`✓`/`●`/`◐`/`✗`/`○`) + owner + oracle. +- **Inventory authority:** the closed ledger lives in `src/projections/README.md` (authored 2026-06-08), mirroring the `src/graph/README.md` read-shape ledger form (module × consumers × disposition × oracle). The PULL-session half gets a sibling read-shape ledger in `src/session/README.md`. +- **Why now / unlocks:** It is the missing middle of the pipeline and the prerequisite for stable renderer goldens. Closing it makes the info-preserving half of the context pipeline (PULL+PROJECT) fully oracle-backed, matching the graph PULL template. +- **Human-in-the-loop:** per-row design checkpoint = user reviews "what must be preserved" for each load-bearing DTO (and approves each `✗` delete) before the invariant is locked (see Context §design→lock rhythm). The enumeration/ledger pass itself was the first design checkpoint. +- **Acceptance:** + - `src/projections/README.md` carries the full projections ledger (done) and `src/session/README.md` carries the session-PULL read-shape ledger. + - Each `●` DTO carries a shape/no-loss invariant; `workspace/workspace-context` is deleted/inlined; the `◐` exchange family is dispositioned; `○` stubs are left untouched. + - No golden snapshots are introduced for projections (wrong tool); `projections/` stays free of adapter/transport imports (D52-L, enforced by `topology-boundaries.test.ts`). +- **Verification:** vitest shape/round-trip asserts co-located with each projection (or a `projections//*.test.ts`); the existing `topology-boundaries.test.ts` continues to guard imports. +- **Cross-cutting obligations:** Keep projections info-preserving (no lossy text — that is RENDER's job); do not duplicate a typed read as a projection just to fill a ledger row (D60-L: many callers consume the typed read directly). +- **Traceability:** D52-L, D60-L. +- **Design docs:** `src/projections/README.md`; `src/graph/README.md` (ledger form to mirror). + +### renderer-golden-coverage + +- **Name:** Complete the uneven renderer text-regression (golden + invariant) coverage (RENDER stage) +- **Linear:** unassigned +- **Kind:** coverage (buildable-now) / hardening +- **Status:** next — trio stage 2 (`#render`); **depends on `projection-shape-coverage`** +- **Certainty:** proving +- **Pipeline position:** RENDER — the first lossy stage, consuming PROJECT outputs. Locks only after projection shapes are stable; upstream of `prompt-composition-golden-coverage` (composed prompts embed rendered context). +- **Coverage-gate verdict (2026-06-08 ln-plan):** **Passes the admission gate** — an open coverage frontier. Named load-bearing layer (`src/renderers/`), closeable inventory, honest ●/○ marking, owner+oracle per row, explicit ledger authority. Classified **buildable-now**, and framed as **partial-oracle completion, not greenfield adoption**: the preview→lock→formalize loop already exists and is adopted unevenly. `toMatchFileSnapshot` goldens are live for `graph/neighborhood` and `session/runtime-frame` (`src/renderers/**/__previews__/`); what remains is closing the gaps — `workspace-state` is still invariant-only, `renderers/exchanges` has no goldens, and `src/scripts/render-preview.ts` (`npm run render`) only supports the `graph-neighborhood` renderer. +- **Boundary:** In — the durable LLM-facing renderers under `src/renderers/{graph,workspace,session,exchanges}` (per `src/renderers/README.md`). Out — format helpers/primitives (`markdown.ts`, `toon.ts`), trivial JSON serializers (`○`), non-renderer projection DTOs, intentional topology stubs not yet owning a renderer (e.g. `present-candidates`), and any new renderer not already built (no symmetry regrowth). +- **Aggregate DoD:** No required (`●`) durable renderer remains without a locked golden (`toMatchFileSnapshot`) plus targeted invariant asserts (e.g. "renders projected code, never raw id"; "active-context omits superseded nodes"; "no dangling edge endpoints"). Extend `render-preview.ts` to the renderers being locked. +- **Inventory authority:** the closed ledger lives in `src/renderers/README.md`; golden artifacts co-locate with the renderer test (`src/renderers//__previews__/.md`), not under `.fixtures/`. +- **Why now / unlocks:** The cross-cut named the preview→lock→formalize loop a prerequisite oracle; it shipped for two renderers but not the rest, so the un-locked renderers can drift silently. Closing the gaps makes every durable renderer-bearing surface drift-protected. +- **Sequencing:** trio stage 2 — starts once `projection-shape-coverage` has stabilized the DTO shapes it renders. Renderer text quality is **fitness evidence**, so it is still **never a ship gate** and does not block `poc-live-ship-gate`; but per the 2026-06-08 elevation it is near-term spine work, not background discretionary hardening. +- **Human-in-the-loop:** per-row design checkpoint = user eyeballs the `npm run render` preview and approves the wording/shape before the golden is written (see Context §design→lock rhythm). +- **Acceptance:** + - Each `●` durable renderer has a golden lock that writes on first run and diffs after (matching the existing `graph/neighborhood` + `session/runtime-frame` pattern). + - Each `●` renderer carries at least one semantic invariant assert beyond the snapshot. + - `src/renderers/README.md` carries the closed ledger (renderer × required/deferred × golden-present). + - `render-preview.ts` covers each newly-locked renderer; no new renderer is introduced merely to fill a symmetric cell. +- **Verification:** `npm run render` for sketch; vitest `toMatchFileSnapshot` for lock; existing invariant-style asserts for formalize. All in the renderer's co-located test file. +- **Cross-cutting obligations:** Goldens co-locate with renderer tests (not `.fixtures/`); keep `renderers/` free of adapter/transport imports (D52-L); do not promote a renderer shape to a new consumer just to fill the ledger (consumer bleed-through); leave intentional topology stubs (`present-candidates`) alone until they own a real renderer. +- **Traceability:** D52-L, D60-L, D62-L. +- **Design docs:** `src/renderers/README.md`; `memory/CROSS_CUT_PLAN.md` §Renderer feedback loops. + +### prompt-composition-golden-coverage + +- **Name:** Lock the prompt partials and composition output (golden + invariant) over the agent prompt family (COMPOSE stage) +- **Linear:** unassigned +- **Kind:** coverage (buildable-now) / hardening +- **Status:** next — trio stage 3 (`#compose`); **depends on `renderer-golden-coverage`** +- **Certainty:** proving +- **Pipeline position:** COMPOSE — the last lossy stage; composed prompts embed rendered context strings, so lock only after RENDER goldens are stable. `elicitation-driver` rides on this stage's locked oracle and follows it. +- **Coverage-gate verdict (2026-06-08 ln-plan):** **Passes the admission gate** — an open coverage frontier of the same golden-locking kind as `renderer-golden-coverage`, surfaced from manual feedback-loop work. Named load-bearing layer (`src/.pi/skills/**` partials + `src/.pi/agents/compose.ts` composition), closeable inventory, owner+oracle per row, explicit ledger authority. Classified **buildable-now** and framed as **partial-oracle completion, not greenfield**: composition is already **invariant-rich** — `compose.test.ts` and `prompting.test.ts` assert structure, manifest legality, grade filtering, pinned/AUTO axis behavior, illegal-pin rejection, plus a `≥700`-char depth floor and a readable-resource check on every partial. What is missing is the **lock** stage: there is **no golden** of either the partial bodies or the composed-prompt output (no `__previews__`, no `toMatchFileSnapshot` for prompts; the only `.pi` inline snapshots are tool-output, not prompts), and there is **no preview harness** for composed prompts (`npm run render` only supports `graph-neighborhood`). +- **Boundary:** In — the agent prompt partials under `src/.pi/skills/{goals,strategies,lenses,methods}` and `src/.pi/agents/definitions/{elicitor,reviewer}.md`, and the `composeAgentPrompt` output for a representative matrix of axis/grade/pin combinations. Out — tool-output snapshots (already inline-locked where useful), `state.ts` legality source (guarded elsewhere), and any new partial/axis introduced merely to fill a symmetric cell (no symmetry regrowth). +- **Aggregate DoD:** No required (`●`) prompt partial body or representative composed-prompt output remains without a locked golden plus the existing structural/legality invariants. Add a composed-prompt preview path (extend `render-preview.ts` or a sibling script) so goldens can be regenerated deterministically. +- **Inventory authority:** the closed ledgers live in `src/.pi/skills/README.md` (partials) and `src/.pi/agents/README.md` (composition); golden artifacts co-locate with the owning test (`src/.pi/agents/__previews__/.md`), not under `.fixtures/`. +- **Why now / unlocks:** Prompt partials and composition shape every agent turn; today they can drift in wording/depth/order while invariants stay green, because the lock stage was never adopted for prompts. Locking them makes the manual feedback loop (eyeball → lock → diff) durable instead of re-eyeballed each change. +- **Sequencing:** trio stage 3 — starts once `renderer-golden-coverage` has stabilized the rendered context strings the composed prompt embeds. Still **never a ship gate**; `elicitation-driver` follows it (it adds per-turn behavior over the composition oracle locked here), so the two pair naturally. +- **Human-in-the-loop:** per-row design checkpoint = user eyeballs the composed-prompt preview (new harness) and approves partial body / composed wording before each golden is written (see Context §design→lock rhythm). +- **Acceptance:** + - A representative composed-prompt matrix (axis/grade/pin) has golden locks that write on first run and diff after. + - Each `●` partial body has at least the existing depth/readability invariant plus a body golden where wording is load-bearing. + - `src/.pi/skills/README.md` + `src/.pi/agents/README.md` carry the closed ledger (partial/composition-case × required/deferred × golden-present). + - A composed-prompt preview path exists for deterministic golden regeneration; no new partial/axis is introduced merely to fill a symmetric cell. +- **Verification:** preview script for sketch; vitest `toMatchFileSnapshot` for lock; the existing `compose.test.ts` / `prompting.test.ts` invariants for formalize. +- **Cross-cutting obligations:** Goldens co-locate with prompt tests (not `.fixtures/`); keep `state.ts` the single legality source (do not fork it for previews); do not promote a partial to a new agent just to fill the ledger. +- **Traceability:** D25-L, D39-L, D40-L, D52-L, D58-L, D59-L, D60-L. +- **Design docs:** `src/.pi/skills/README.md`; `src/.pi/agents/README.md`; `memory/CROSS_CUT_PLAN.md` §Renderer feedback loops. + ### exchanges-and-generalized-capture -- **Name:** Exchange surface and generalized capture inventory +- **Name:** Generalized capture (narrow extractive) + exchange-surface symmetry audit - **Linear:** unassigned -- **Kind:** structural +- **Kind:** bounded feature - **Status:** next - **Certainty:** proving -- **Unblocked by:** `capture-quality-spike` (2026-06-08) measured fixed free-prose, file/ref-bearing, and implication-heavy scenarios, reached precision 1.0 / recall 1.0 with zero false commits in the sample extraction report, and recommended graduating a narrow generalized-capture frontier with an explicit false-commit guard. -- **Stabilizes:** The ownership split between `.pi/extensions/exchanges`, `projections/exchanges`, `renderers/exchanges`, and `session/structured-exchange-loop.ts`. -- **Objective:** Enumerate the surviving exchange/capture families and scope generalized capture narrowly around high-confidence extractive facts; keep implication-heavy material out of graph truth unless a later slice proves a safe commitment path. -- **Why now / unlocks:** The capture-quality spike closed the evidence gate enough to scope the next inventory. The frontier should still start with enumeration and false-commit protection rather than regrowing deleted `capture-*` topology or broad LLM commitment behavior. +- **Coverage-gate verdict (2026-06-08 ln-plan):** **Not a coverage frontier.** It was sitting in the coverage slot, but the admission gate fails on the load-bearing question: the remaining required work is **vertical capture semantics** (high-confidence extractive capture with false-commit protection), not breadth closure. The exchange surface is largely built across {`.pi/extensions/exchanges`, `projections/exchanges`, `renderers/exchanges`}, with some breadth still explicitly deferred / topology-stubbed (e.g. the `present-candidates` candidate-family stub mirrored across all three layers). So the open unknown is the capture vertical, not an unbuilt inventory; reclassified as a bounded proving feature plus a delete-oriented symmetry audit. +- **Unblocked by:** `capture-quality-spike` (2026-06-08) measured fixed free-prose, file/ref-bearing, and implication-heavy scenarios, reached precision 1.0 / recall 1.0 with zero false commits in the sample extraction report, and recommended graduating a narrow generalized-capture feature with an explicit false-commit guard. +- **Objective:** (1) Build narrow generalized capture around high-confidence extractive facts with an explicit false-commit oracle for implication-heavy text — keep implication-heavy material out of graph truth unless a later slice proves a safe commitment path. (2) Run an **earned symmetry audit** of the already-built exchange three-layer split: confirm each `projections/exchanges` and `renderers/exchanges` file earns its place (genuine multi-consumer reuse or shared semantics), and delete symmetry regrowth where a single-owner read was mirrored into a shared layer "for symmetry." +- **Why now / unlocks:** The capture-quality spike closed the evidence gate for the capture vertical. The audit rides along because the same symmetry the frontier would have "enumerated" is exactly where consumer-bleed-through/symmetry-regrowth hides. Start with the vertical + false-commit protection and treat the audit as deletion-oriented, not as breadth-building; do not regrow deleted `capture-*` topology or broad LLM commitment behavior. - **Acceptance:** - - The surviving exchange/capture families are 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 starts with high-confidence extractive facts and carries an explicit false-commit oracle for implication-heavy text. -- **Verification:** Probe-backed transcript and capture read-back oracles; include the capture-quality false-commit scenario family as a regression guard. + - Each retained `projections/exchanges` / `renderers/exchanges` file has a named multi-consumer or shared-semantics justification; unjustified symmetric mirrors are deleted (delete-as-progress), not documented as "covered." + - Single-owner reads or orchestration state stay in their owning domains; `renderers/exchanges` stays durable markdown/text/toon only. +- **Verification:** Probe-backed transcript and capture read-back oracles; include the capture-quality false-commit scenario family as a regression guard. For the audit, the oracle is the existing topology-boundary test plus a per-file justification check. - **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`. @@ -294,7 +447,7 @@ The remaining coverage frontiers are being deliberately de-fogged rather than le - **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`; 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. +- **Current execution pointer:** The semantic-mutation curation scope formerly parked at `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` is folded into `role-safe-graph-mutations` (`memory/cards/role-safe-graph-mutations--mutate-graph.md`) so dev curation does not mint a second graph-write dialect. Product-driven fixture-curation tracer evidence remains the quality-review input: `macro-view-grounded-intent` is a deterministic explicit-basis Bilal variant, and `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` proves one historical 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`. @@ -323,8 +476,13 @@ nodes: poc-live-ship-gate [next · P1] final fresh-cwd composed product runbook graph-observed-shapes [done · proving] ratified consumer-specific observed-shape ledger + drift guard; no transport shape shipped runtime-affordances-and-legality [done · proving] shared affordance(resolvedState, grade) derivation + coverage ledger; review-set/turn-mode rows tripwired - elicitation-driver [next · proving] live per-turn what-to-ask-next driver on FE-823 substrate; closes cross-cut Seam 3a - capture-quality-spike [done · spike] A22-L fitness evidence graduated a narrow exchanges-and-generalized-capture scope + role-safe-graph-mutations [next · proving] canonical mutateGraph/mutate_graph authored grammar; role-named edges; retire exposed commitGraph/commit_graph + projection-shape-coverage [next · coverage] TRIO stage 1 (#project, PROJECT): create projections ledger + no-loss/shape invariants over dark graph/transcript DTOs; invariant-kind, NOT golden + renderer-golden-coverage [next · coverage] TRIO stage 2 (#render, RENDER): create renderer ledger + golden-lock every durable renderer; depends on projection-shape-coverage + prompt-composition-golden-coverage [next · coverage] TRIO stage 3 (#compose, COMPOSE): composed-prompt preview + golden-lock partials/composition matrix; depends on renderer-golden-coverage + elicitation-driver [after-trio · proving] live per-turn what-to-ask-next driver on FE-823 substrate; rides COMPOSE oracle; closes cross-cut Seam 3a + exchanges-and-generalized-capture [after-trio · proving] bounded feature (NOT coverage): narrow extractive capture + false-commit guard + exchange symmetry audit + capture-quality-spike [done · spike] A22-L fitness evidence graduated the narrow exchanges-and-generalized-capture feature 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 @@ -337,15 +495,21 @@ edges: project-graph-review-cycle -[optional]-> poc-live-ship-gate minimal-authority-shell -[hard]-> poc-live-ship-gate elicitation-backlog -[hard]-> elicitation-driver + graph-tool-resilience -[hard]-> role-safe-graph-mutations (current graph tool + edge model exist) + project-graph-review-cycle -[hard]-> role-safe-graph-mutations (current review-set proposal/accept path exists) + role-safe-graph-mutations -[hard]-> exchanges-and-generalized-capture (relation-bearing capture uses mutateGraph grammar) + role-safe-graph-mutations -[hard]-> dev-seed-fixtures (semantic curation slice uses mutateGraph grammar) capture-quality-spike -[evidence]-> exchanges-and-generalized-capture + projection-shape-coverage -[hard]-> renderer-golden-coverage (lock DTO shape before renderer golden) + renderer-golden-coverage -[hard]-> prompt-composition-golden-coverage (lock rendered text before prompt golden) + prompt-composition-golden-coverage -[oracle]-> elicitation-driver (compose oracle underwrites per-turn driver) parallel obligations: probes-and-transcripts-evolution -[evidence]-> every P0/P1 frontier topology-readmes-and-boundaries -[boundary]-> every frontier that moves/claims source topology - dev-seed-fixtures -[data]-> capture-response-to-graph, poc-live-ship-gate (real multi-spec graphs to exercise observer/capture) + dev-seed-fixtures -[data]-> capture-response-to-graph, poc-live-ship-gate (real multi-spec graphs to exercise observer/capture; semantic curation waits on role-safe-graph-mutations) horizon: - exchanges-and-generalized-capture turn-boundary-reconciliation coherence-first-class compaction-and-conflict-widening @@ -357,10 +521,15 @@ horizon: notes: - `elicitation-backlog` was the promoted D65-L *substrate* row from `memory/CROSS_CUT_PLAN.md`; the prompt-resource body-depth pass landed in 1ca02e38. The cross-cut is **not** exhausted: its Seam 3a `"what to ask next" driver` row is still `partial · ●`, which by the seam DoD keeps the seam open. That row is now disposed as the `elicitation-driver` frontier (not residue), so the remaining cross-cut obligation has a named owner in `PLAN.md`. - - Parallel worktree streams (2026-06-08): all three landed — (A) `crosscut-know--resource-body-depth` (1ca02e38), (B) `graph-observed-shapes--coverage-ledger` (85e73ba7), (C) `minimal-authority-shell--audit-and-guard` (68474e3f); each kept to its declared write paths and left `src/.pi/agents/state.ts` untouched, so the parallel run produced no collisions. `poc-live-ship-gate` is now unblocked (its hard dependency `minimal-authority-shell` is done). `runtime-affordances-and-legality` has since landed (00105108), so the remaining de-fogged coverage frontiers are `elicitation-driver` (buildable-now on the FE-823 substrate) and the now-graduated narrow `exchanges-and-generalized-capture` inventory — both cold-startable worktree streams. + - Parallel worktree streams (2026-06-08): all three landed — (A) `crosscut-know--resource-body-depth` (1ca02e38), (B) `graph-observed-shapes--coverage-ledger` (85e73ba7), (C) `minimal-authority-shell--audit-and-guard` (68474e3f); each kept to its declared write paths and left `src/.pi/agents/state.ts` untouched, so the parallel run produced no collisions. `poc-live-ship-gate` is now unblocked (its hard dependency `minimal-authority-shell` is done). `runtime-affordances-and-legality` has since landed (00105108). The 2026-06-08 ln-plan coverage re-classification then found the coverage layer mostly closed: `graph-observed-shapes` + `runtime-affordances` are done coverage, `exchanges-and-generalized-capture` is reclassified to a bounded proving feature (the remaining unknown is capture semantics, not breadth closure), and the genuinely-open coverage was then deepened (same-day per-plane pass) into the **context-pipeline coverage trio** — `projection-shape-coverage` → `renderer-golden-coverage` → `prompt-composition-golden-coverage`, now the dependency-ordered near-term spine (see the trio note below and the Context §Context-pipeline coverage section). This superseded the earlier "two discretionary locking frontiers, precedence to `elicitation-driver`" disposition. - 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` is now graduated only narrowly: scope high-confidence extractive capture with a false-commit guard, and do not regrow deleted `capture-*` symmetry. + - `role-safe-graph-mutations` folds the prior role-named edge-surface card and semantic graph-mutation curation card into one frontier. The canonical authored graph command becomes `mutateGraph` / `mutate_graph`; role-named endpoint fields are normalized through `EDGE_CATEGORY_METADATA`; exposed `commitGraph` / `commit_graph` is retired by break-and-repair rather than kept as a weaker parallel API. Downstream capture and dev curation must not reintroduce `{category, source, target}` at authored boundaries. + - `exchanges-and-generalized-capture` is a bounded proving feature, not coverage: the remaining load-bearing unknown is capture semantics, not breadth closure. The exchange surface is largely built across the three layers, with some breadth still deferred / topology-stubbed (`present-candidates`). Scope high-confidence extractive capture with a false-commit guard, do not regrow deleted `capture-*` symmetry, and treat the exchange three-layer audit as delete-oriented (drop unjustified `projections/exchanges` / `renderers/exchanges` mirrors), not breadth-building. + - **Context-pipeline coverage trio (the near-term spine, 2026-06-08 deep per-plane pass).** The four LLM-facing context concerns are one pipeline — PULL → PROJECT → RENDER → COMPOSE (D60-L). PULL has **two halves**: the *graph* read surface is the done template (`graph/queries.ts` + `src/graph/README.md`: behavioral oracle for all 8 shapes + drift guard + real ledger), but the *session* read surface (`session/workspace-context`, `workspace-session-coordinator`, `runtime-state`) is tested-but-un-ledgered and must be ledgered before the session/workspace projections lock against it. The trio closes the other three stages **in dependency order**, each completing its plane's **full ledger** via the human-in-the-loop design→lock rhythm. Oracle kind differs by stage: info-preserving stages want **invariant/no-loss** locks, lossy stages want **golden** locks. The PROJECT ledger (`src/projections/README.md`, authored 2026-06-08) applies an **earns-its-place gate before the oracle gate** — `workspace/workspace-context` is `✗` delete/inline (single-consumer tag wrapper), and the plan's earlier "dark zone = graph/{overview,commit-result,reconciliation-needs}" was wrong: those are `export {}` topology stubs (`○`), not dark implementations. + - `projection-shape-coverage` (TRIO stage 1, `#project`) is the genuinely-new finding. Ledger authored in `src/projections/README.md`. The real `●` survivors are `graph/neighborhood`, `session/transcript-context`, `session/runtime-state`, `workspace/workspace-state`; `workspace/workspace-context` is `✗` delete/inline; the graph projection stubs (`overview`, `commit-result`, `reconciliation-needs`) are `○` topology stubs, not dark. Also carries the PULL-session read-shape ledger prerequisite. Lock with shape/no-loss invariants — **not goldens** (wrong tool for an info-preserving DTO; can't catch silent field-drop). Do it first; it stabilizes the shapes renderer goldens lock against. + - `renderer-golden-coverage` (TRIO stage 2, `#render`) **depends on stage 1**: only `graph/neighborhood` + `session/runtime-frame` are golden-locked; the rest are dark or only transitively covered via the `.pi` adapter. Create the renderer ledger (README claims one that does not exist), extend the preview harness past `graph-neighborhood`. Bound to durable renderers (exclude `markdown.ts` / `toon.ts` helpers and topology stubs). Never a ship gate. + - `prompt-composition-golden-coverage` (TRIO stage 3, `#compose`) **depends on stage 2**: `compose.test.ts` / `prompting.test.ts` are invariant-rich but no golden of partial bodies or composed output exists and there is no composed-prompt preview harness. Add the preview, golden-lock partials + a composed-prompt matrix. `elicitation-driver` rides on this stage's locked oracle and follows it. Never a ship gate. - `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/REFACTOR.md b/memory/REFACTOR.md deleted file mode 100644 index a3b694e3c..000000000 --- a/memory/REFACTOR.md +++ /dev/null @@ -1,116 +0,0 @@ -# Refactor: reconcile PR 177 rename residue + edge-direction label - -> Source: `ln-induct` run on PR 177 (FE-811) review comments. Temporary execution -> aid — delete when complete or superseded (per `AGENTS.md` §ln-refactor). -> Builder works on branch `ln/fe-811-poc-live-ship-blockers`. - -## Problem Statement - -PR 177 renamed several identifiers across code and docs, but the migration -stopped at the code/doc plane and never reached the **data plane**. Two -committed reference runs were generated before the renames and never -regenerated, so they straddle old and new contracts silently: - -- `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/` -- `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` - -Concretely, against the current writer contract (`src/probes/*` now emit -`graphOverviewJson` → `graph-overview.json`; `src/rpc/product-updates.ts` only -emits topic `workspace.state`): - -- both runs' `report.json` carry `artifacts.graphSnapshotJson` → `graph-snapshot.json` -- both runs ship a stale `graph-snapshot.json` file (writers now produce `graph-overview.json`) -- the `project-graph-review-cycle` run's `report.json:88` carries a stale - `"topic": "workspace.snapshot"` - -The Cursor bot sampled only the first of these in only the artifact field; the -others are the unsampled tail of the same syndrome. Field-patching the one the -bot named would leave the run still wrong on the others — the false confidence -this whole lens predicts. - -Separately, a presentation-layer bug: `formatRelatedNodesResult` in -`src/.pi/extensions/graph/command-adapter.ts:255` labels each result edge -`outgoing`/`incoming` from a one-sided check (`source ∈ anchors`). The query -layer (`src/graph/queries.ts`) correctly traverses multi-hop and node↔node -edges, so at hop ≥ 2 an edge between two non-anchor nodes (source ∉ anchors) is -silently mislabeled `incoming`. - -**Data-plane delta:** - -```pseudo -tree current (per stale run) tree desired - report.json report.json - artifacts.graphSnapshotJson --> artifacts.graphOverviewJson - productUpdates[].topic productUpdates[].topic - "workspace.snapshot" --> "workspace.state" - graph-snapshot.json --> graph-overview.json (file renamed/regenerated) -``` - -## Solution - -Regenerate both reference runs from their committed session transcripts (the -runs are replay-deterministic — the probe reads `session.jsonl` + seed and -derives artifacts; no live model calls), so every committed identifier matches -the current writer contract. Then install a guard so a future rename cannot -silently leave reference-data residue. Finally, fix the edge-direction label to -classify by both endpoints. - -### Non-goals (do not do these) - -- **Do NOT add `snapshottedLsn` backward-compatibility.** Copilot suggested the - reader at `src/projections/session/runtime-state.ts:121` accept both - `seenLsn` and the legacy `snapshottedLsn`. This contradicts the repo's - pre-release posture (`AGENTS.md`: no back-compat shims unless explicitly - required). No committed transcript carries the legacy field. Leave the - single-field read as-is. -- Do not widen scope to other probe runs — the audit confirmed the other five - committed `report.json` files carry no graph-artifact or workspace-topic keys. -- Do not touch the `queries.ts` traversal — it is correct. - -## Commits - -Ordered; each leaves the suite green. Behavioral change last. - -1. **Regenerate the `fixture-curation-2026-06-05T104440Z` reference run.** - Replay its committed `session.jsonl` through the fixture-curation probe so - `report.json` emits `graphOverviewJson` → `graph-overview.json`, and the - stale `graph-snapshot.json` is replaced by `graph-overview.json`. Confirm the - probe test still passes against the regenerated run. - - Touches: `.fixtures/runs/fixture-curation/fixture-curation-2026-06-05T104440Z/*` - - Driver: `src/probes/fixture-curation-loop.ts` (entrypoint writes to the run dir) - -2. **Regenerate the `2026-06-06-project-graph-review-cycle` reference run.** - Same regeneration; this additionally resolves the stale - `"topic":"workspace.snapshot"` to `"workspace.state"`. Confirm - `src/probes/project-graph-review-cycle-proof.test.ts` passes. - - Touches: `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/*` - - Driver: `src/probes/project-graph-review-cycle-proof.ts` - -3. **Add a contract-residue guard test (enforce loudly).** A test that scans - every committed `report.json` under `.fixtures/runs/**` and fails if any - contains a retired contract token (`graphSnapshotJson`, `graph-snapshot`, - `workspace.snapshot`). Green only after commits 1–2. This is the lens's - "enforce it loudly" repair: a future rename that forgets the data plane now - fails CI instead of shipping silent drift. - - Touches: new test near `src/probes/` (e.g. `src/probes/fixture-contract-residue.test.ts`) - - Note: `.fixtures` is gitignored but force-committed — enumerate files via - `git ls-files '.fixtures/**/report.json'`, not a glob that respects ignore. - -4. **Fix the edge-direction label (behavioral).** In - `formatRelatedNodesResult` (`src/.pi/extensions/graph/command-adapter.ts:255`), - classify by both endpoints: `source ∈ anchors → outgoing`, - `target ∈ anchors → incoming`, else `lateral`. Add a regression test that - builds a 2-hop related result containing a node↔node edge and asserts it is - labeled `lateral`, not `incoming`. - - Touches: `src/.pi/extensions/graph/command-adapter.ts` + its test - -## Verification - -- Per commit: `npm run fix` (inner loop). -- Gate before handing off: `npm run verify` (fix → test → build). -- Commit 3's guard must be RED if either regeneration is skipped, GREEN after. -- Commit 4's regression test must be RED against the current one-sided label. -``` - -The lens that produced this is `ln-review` §Contract integrity → "rename -blast-radius includes the data plane." diff --git a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md index 6ccf6f7fc..a0af7a786 100644 --- a/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md +++ b/memory/cards/dev-seed-fixtures--semantic-graph-mutations.md @@ -1,12 +1,14 @@ # Semantic graph mutations for fixture curation -Frontier: dev-seed-fixtures -Status: active +Frontier: superseded by `role-safe-graph-mutations` for the semantic mutation command; `dev-seed-fixtures` remains the seed-data frontier. +Status: superseded Mode: chain Created: 2026-06-05 +Superseded: 2026-06-09 by `memory/cards/role-safe-graph-mutations--mutate-graph.md`. ## Orientation +- Supersession note: this card's command-layer create/patch/delete curation scope is folded into `role-safe-graph-mutations` so semantic mutation work lands as the canonical `mutateGraph` / `mutate_graph` grammar instead of a dev-only second graph-write dialect. Keep this file as historical scoping context; build from `memory/cards/role-safe-graph-mutations--mutate-graph.md`. - Containing seam: `graph/CommandExecutor` as the single graph-truth mutation boundary. The current creation-only `commitGraph({nodes, edges})` shape is sufficient for `propose-graph` creation, but not for manual curation of persisted seed specs where humans must patch or remove existing graph items. - Relevant frontier item: `dev-seed-fixtures` because the immediate product need is curated Bilal/reference seed data that can be edited in a local DB and exported back to `.fixtures/seeds/**`. This slice also touches the cross-frontier graph mutation contract (`D4-L`, `D20-L`, `D53-L`), so it must reconcile SPEC/GRAPH_MODEL when built. - Volatile handoff state: a clean curation workspace exists at `.fixtures/workbenches/bilal-curation`; DB→fixture export and the one-shot RPC helper are already in place (`src/graph/export-fixtures.ts`, `src/dev/workspace-rpc.ts`). FE-809 review-cycle work has landed, but this scope still touches fresh `src/graph/command-executor.ts` and review-set graph code; coordinate before building it in a shared worktree. diff --git a/memory/cards/graph--role-named-edge-surface.md b/memory/cards/graph--role-named-edge-surface.md new file mode 100644 index 000000000..28216d01c --- /dev/null +++ b/memory/cards/graph--role-named-edge-surface.md @@ -0,0 +1,318 @@ +# Role-named edge surface for agent-authored graph mutations + +Frontier: superseded by `role-safe-graph-mutations` +Status: superseded +Mode: chain +Created: 2026-06-08 +Superseded: 2026-06-09 by `memory/cards/role-safe-graph-mutations--mutate-graph.md`. + +## Orientation + +- **Supersession note:** this card's role-named edge-surface design is folded into + the `role-safe-graph-mutations` frontier, where it becomes part of the + canonical `mutateGraph` / `mutate_graph` grammar rather than a standalone + `commit_graph` remediation. +- **Seam:** the *agent-authored edge boundary* — the two places an LLM emits an + edge before `CommandExecutor`: the `commit_graph` Pi tool schema + (`src/.pi/extensions/graph/tool-schemas.ts` → `command-adapter.ts`, + D53-L) and the `project-graph` review-set proposal payload + (`src/graph/review-set.ts`, D27-L). Both currently expose generic + `{ category, source, target, stance?, rationale? }`. +- **Problem (this thread):** `source → target` *sounds* directional but is + meaningless/misleading at the agent boundary. Direction is real only as + endpoint **role** (oracle/claim, dependency/dependent, abstract/concrete, …) + already encoded in `EDGE_CATEGORY_METADATA` (`src/graph/policy/category-policy.ts`). + The agent must today silently remember "proof source = oracle, target = + claim" etc. — a directionally-wrong-yet-structurally-valid error the + executor cannot reject. +- **Decision carried in from discussion:** flip only the *agent boundary* to an + 8-variant role-named discriminated union (category/role granularity, **not** + tuple-specific `requirement_realized_by_module` sprawl). Normalize to the + existing `BatchEdgeInput { category, source, target }` deterministically via + `EDGE_CATEGORY_METADATA`. **Do not** re-orient persistence to + upstream/downstream — storage stays assertion-oriented `source/target` + (see Non-goals). +- **Posture:** proving (no inherited frontier; settled design, low residual + unknown — the normalizer is a few lines over an existing table). The + load-bearing risk is drift between the union's role field names and the + metadata table, retired by an explicit drift test (Card 1). +- **Open risk:** the change edits locked agent-edge-draft wording in D53-L / + D27-L and `docs/design/GRAPH_MODEL.md`; that is durable reconciliation, not + just code (handled per card + Routing). + +## Cross-cutting obligations (whole chain) + +- Preserve the D4-L/D20-L command boundary: agents never touch `db/`; all edge + writes still route through `CommandExecutor.commitGraph`. +- Preserve D51-L storage contract: stored edge identity stays + `(category, sourceId, targetId, stance)`, immutable; **no** persistence / + schema / migration change. +- Preserve D16-L/A4-L one `{specId, lsn}` clock and I34-L all-or-nothing batch + semantics — the union is a pre-executor translation only. +- Keep the closed category set (D51-L) the single source of relation kinds; the + union must not become a relation catalogue. +- `EDGE_CATEGORY_METADATA` stays the **one** source of endpoint-role truth + (its header comment already records it superseded a prior drifted split — do + not reintroduce a parallel role map). + +## Non-goals (explicit) + +- **No upstream/downstream re-orientation of storage.** Rejected in discussion: + impact direction is *undefined* for `association` (1 of 8), `direction.ts` + already derives upstream/downstream from the metadata for the only two + readers (`labels.ts`, `direction.ts`), and assertion orientation is what + `change_log`, supersession acyclicity, and `labels.ts` want. Storage column + names and `BatchEdgeInput` stay `source/target`. +- **No read DTO with `upstream/downstream`.** Deferred and likely unnecessary — + `src/graph/projection/direction.ts` already *is* that projection. Do not add + one in this chain. +- **No tuple-specific variants** (`criterion_proves_requirement`, …). Tuple + phrasing stays in `edgeLabel()` (`labels.ts`). Union stays at category/role. +- **No new `link*` single-edge tools.** GRAPH_MODEL.md's `linkProof`/ + `linkDependency`/… surface stays M5/out of scope; this chain only fixes the + two edge boundaries that ship today (`commit_graph` batch + review-set). + +--- + +## Card 1 — `commit_graph` role-named edge union + driftless normalizer + +Status: next + +### Target Behavior + +The `commit_graph` Pi tool accepts edges as an 8-variant role-named +discriminated union and deterministically normalizes each variant to +`BatchEdgeInput { category, source, target }` via `EDGE_CATEGORY_METADATA`, +with no change to stored edge shape. + +### Boundary Crossings + +``` +→ LLM tool call (commit_graph params) +→ CommitEdgeSchema (TypeBox discriminated union, role-named) [tool-schemas.ts] +→ translateCommitGraph → normalizeEdgeDraft(category, roleFields) [command-adapter.ts] +→ EDGE_CATEGORY_METADATA[category].{sourceRole,targetRole} [category-policy.ts] +→ BatchEdgeInput { category, source, target, stance?, rationale? } (unchanged) +→ CommandExecutor.commitGraph (unchanged) +``` + +### The union (category/role level) + +``` +dependency { dependency, dependent } +proof { oracle, claim, stance } +support { support, claim, stance } +realization { abstract, concrete } +boundary { boundary, subject } +composition { whole, part } +supersession{ successor, predecessor } +association { a, b } // peer/peer; arbitrary storage orientation +``` + +Normalization rule (single, table-driven): for category `C`, the field named +`EDGE_CATEGORY_METADATA[C].sourceRole` → `source`, the field named +`.targetRole` → `target`. (`association` peer/peer: map `a`→source, `b`→target.) +Each variant still carries optional `rationale`; `stance` only on +`proof`/`support`. + +### Risks and Assumptions + +``` +- RISK: union role field names drift from EDGE_CATEGORY_METADATA roles + → MITIGATION: drift test (acceptance ✓ below) pins every variant's two + role field names to that category's sourceRole/targetRole; normalizer + reads the table, never a hand-copied map. +- RISK: TypeBox discriminated-union JSON Schema is awkward for the LLM / + Pi `defineTool` typing (D41-L: schemas must stay JSON-representable) + → MITIGATION: use a tagged union keyed on `category` (StringEnum literal + per variant) — plain JSON Schema oneOf; add an export/parse test that the + params schema still satisfies the Pi `TSchema` adapter and round-trips. +- ASSUMPTION: EDGE_CATEGORY_METADATA endpoint roles are the correct agent-facing + role vocabulary (oracle/claim, abstract/concrete, whole/part, …). + → IMPACT IF FALSE: rename in one table + union; localized. + → VALIDATE: matches docs/design/GRAPH_MODEL.md §Per-category policy table. +- ASSUMPTION: dev RPC (`rpc/methods/dev-graph.ts`) builds CommitGraphInput + directly (not via the tool schema), so it is unaffected. + → IMPACT IF FALSE: add it to touched paths at build. + → VALIDATE: grep confirms it constructs BatchEdgeInput directly; leave as-is. +``` + +### Posture check (proving) + +Scores on **invariants** (locks the agent-edge-draft seam to the metadata +table via a drift oracle) and **uncertainty** (retires the "agent orients +source/target wrong" failure mode named in this thread). It lights up the new +role-named path end-to-end through a real tool call. Build it. + +### Acceptance Criteria + +``` +✓ tool-schema-edge-union — commit_graph CommitEdgeSchema is an 8-variant + category-tagged union; submitting `{category:"proof", oracle, claim, stance}` + normalizes to source=oracle, target=claim in the resulting BatchEdgeInput. +✓ normalizer-all-categories — for every EdgeCategory, a role-named draft + normalizes to source/target matching EDGE_CATEGORY_METADATA sourceRole/ + targetRole (table-driven over all 8). +✓ role-name-drift-guard — a test asserts each union variant's two endpoint + field names equal that category's {sourceRole,targetRole} (peer/peer ↔ a/b + mapping asserted explicitly); fails if a variant or the table drifts. +✓ stance-locality — stance accepted only on proof/support variants; rejected + (structural_illegal or schema reject) elsewhere. +✓ schema-export-roundtrip — CommitGraphParams still passes the Pi `TSchema` + adapter / JSON-Schema export used for the tool (D41-L). +✓ commit-graph-batch-unchanged — existing commit-graph-batch executor tests + still pass with no edits (storage path untouched). +``` + +### Verification Approach + +``` +- Inner: vitest unit — normalizer + drift table + schema parse/export. +- Middle: src/.pi/__tests__/graph-tools.test.ts — real tool registration emits + role-named edges and persists correct source/target via CommandExecutor. +- Outer: optional — re-run a propose-graph-commit probe to confirm an LLM emits + the role-named union (not required to land; existing A14-L probes cover the + commit path). +``` + +### Cross-cutting obligations + +See chain-level section. Specifically: metadata stays the single role source; +no storage/schema change; I34-L all-or-nothing preserved. + +### Expected touched paths (tentative) + +``` +src/graph/policy/ +├── category-policy.ts ~ # + normalizeEdgeDraft / endpointForRole helper +└── category-policy.test.ts ~ # + drift guard + all-category normalize +src/graph/index.ts ~ # export normalizer + role types/field names +src/.pi/extensions/graph/ +├── tool-schemas.ts ~ # CommitEdgeSchema → category-tagged union +├── command-adapter.ts ~ # translateCommitGraph uses normalizer +└── __tests__/ + └── graph-tools.test.ts ~ # (under src/.pi/__tests__) role-named edges +src/rpc/methods/dev-graph.ts ? # confirm builds BatchEdgeInput directly; likely untouched +docs/design/GRAPH_MODEL.md ~ # commitGraph example edges → role-named; reconcile §Agent-facing surface +memory/SPEC.md ~ # D53-L wording: agent edge drafts are role-named; + invariant (see Traceability) +``` + +--- + +## Card 2 — review-set proposal edge drafts adopt the same union + +Status: next (after Card 1; reuses Card 1's normalizer) + +### Target Behavior + +The `project-graph` review-set proposal payload carries edge drafts as the same +role-named union, and `translateReviewSetPayloadToCommitGraph` normalizes them +to `BatchEdgeInput` through Card 1's shared normalizer. + +### Boundary Crossings + +``` +→ review-set proposal payload (LLM-authored, D27-L) +→ ReviewSetEdgeDraft union (role-named, mirrors Card 1) [review-set.ts] +→ resolve endpoints (draftId | existingCode) per role field +→ normalizeEdgeDraft (shared, Card 1) [category-policy.ts] +→ BatchEdgeInput → dryRun/accept via CommandExecutor (unchanged, D27-L/I20-L) +``` + +### Risks and Assumptions + +``` +- RISK: a separate TypeBox/Zod review-set payload schema exists in + src/.pi/extensions/exchanges/schemas/ (present_review_set / request_review) + and must change in lockstep with the runtime shape in review-set.ts. + → MITIGATION: at build, grep exchanges/schemas/{present,request,shared}.ts + for the edge-draft shape; update both or confirm review-set.ts is the only + validator. Dry-run parity (I20-L) test must still hold. +- ASSUMPTION: review-set endpoint refs ({draftId} | {existingCode}) are + orthogonal to the role rename — only the *field names* change, not ref shape. + → IMPACT IF FALSE: localized to resolveReviewSetEndpoint. + → VALIDATE: ReviewSetEndpointRef stays a draftId/existingCode union. +``` + +### Posture check (proving) + +Closure-flavored but proving: it **canonicalizes** the agent edge vocabulary +across *both* edge boundaries so the role-named union is the one way an agent +ever expresses an edge. Lands the same seam on the D27-L path; drift guard from +Card 1 extends to cover it. Build it. + +### Acceptance Criteria + +``` +✓ review-set-edge-union — a review-set payload with role-named edge drafts + (e.g. {category:"realization", abstract, concrete}) translates to + BatchEdgeInput with source=abstract, target=concrete. +✓ review-set-dryrun-parity — dryRunAcceptReviewSet still rejects structurally + illegal role-named drafts and surfaces non-reviewable diagnostics (I20-L). +✓ schema-lockstep — if an exchanges/schemas payload schema exists, it accepts + the role-named union and rejects generic source/target; otherwise a test + asserts review-set.ts is the sole edge-draft validator. +✓ no-generic-source-target — generic {category, source, target} edge drafts are + rejected at both boundaries (grep/lint or schema-reject test). +``` + +### Verification Approach + +``` +- Inner: vitest — review-set translation over role-named drafts; reuse Card 1 normalizer. +- Middle: src/graph/review-set.test.ts — dry-run/accept parity, projected-code + resolution, invalid-proposal rejection (existing suite, updated inputs). +- Outer: optional — project-graph-review-cycle probe regen if its fixture + encodes edge drafts (check .fixtures/runs/project-graph-review-cycle/). +``` + +### Cross-cutting obligations + +See chain-level section. Plus: preserve I20-L (only dry-run-valid proposals +surface as reviewable) and I18-L lens metadata on the payload. + +### Expected touched paths (tentative) + +``` +src/graph/ +├── review-set.ts ~ # ReviewSetEdgeDraft → role-named union; translate via shared normalizer +└── review-set.test.ts ~ +src/.pi/extensions/exchanges/schemas/ +├── present.ts ? # present_review_set payload edge-draft shape (confirm at build) +├── request.ts ? # request_review payload (confirm at build) +└── shared.ts ? +docs/design/GRAPH_MODEL.md ~ # review-set edge-draft shape → role-named +memory/SPEC.md ~ # D27-L wording: edge drafts role-named +``` + +--- + +## Traceability (durable reconciliation — do before/at build) + +This chain edits locked decisions; reconcile canonical docs as part of landing, +not as an afterthought: + +- **D53-L** — restate the `commit_graph` `edges` shape as the role-named union + (was `{category, source, target}`); note deterministic normalization to + stored `source/target` via `EDGE_CATEGORY_METADATA`. +- **D27-L** — restate review-set edge drafts as the role-named union (was + `{category, source, target, stance?, rationale?}` over draftId / existingCode). +- **D51-L** — unchanged storage contract; add a sentence clarifying that + endpoint *roles* are the agent vocabulary while `sourceId/targetId` remain the + immutable stored geometry. +- **New invariant (propose)** — "Agents express edges only by category + + endpoint roles; `source/target` is internal storage geometry derived + deterministically from `EDGE_CATEGORY_METADATA`. Union role field names are + test-pinned to that table." Tie to D51-L/D53-L/D27-L, A14-L. +- **A14-L** — the structural-legality assumption now includes role-named edge + drafts; existing probes still cover the commit path. +- `docs/design/GRAPH_MODEL.md` §"Agent-facing command surface" + the + `commitGraph` example — update both edge examples to role-named. + +Because this is durable change to locked decisions, the strictly-correct path is +a short `ln-spec` pass (D53-L/D27-L rewording + the new invariant) and, if it +should be its own Linear issue/branch, an `ln-plan` frontier promotion per +project AGENTS.md workflow. The cards above are buildable as soon as that +reconciliation is agreed; the SPEC/doc edits are listed in each card's touched +paths so they can also be folded into the build commit if you prefer to keep it +as one move. diff --git a/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md b/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md deleted file mode 100644 index ed9b5dc79..000000000 --- a/memory/cards/poc-live-ship-gate--live-mention-autocomplete.md +++ /dev/null @@ -1,85 +0,0 @@ -# Live selected-spec mention autocomplete - -Frontier: poc-live-ship-gate -Status: next -Mode: single -Created: 2026-06-05 - -## Orientation - -- 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. - -Posture: proving (inherited from `poc-live-ship-gate`). - -Frontier-level cross-cutting obligations this slice carries: - -- Preserve D14-L/D62-L: inserted mention text is only `#` from stable kind + ordinal; labels/descriptions remain UI-only. -- Preserve D52-L: `.pi/extensions/` adapts Pi seams and may consume selected-spec graph readers injected by the product shell; it must not import `db/` or own graph truth. -- Preserve the M7 caveat: no mention ledger, staleness hint, or `prepareNextTurn` machinery is added in this slice. -- Preserve co-tenancy: `src/.pi/brunch-pi-extensions.ts` is the expected shell touch point after the Pi-extension topology move; check `git status` before building because it overlaps common extension-registry work. - -## Card 1 — Replace fixture-backed mention candidates with live selected-spec nodes - -Status: next -Weight: light - -### Objective - -Typing `#` in a Brunch TUI session lists graph nodes from the currently selected specification instead of the hard-coded fixture identifiers. - -### Acceptance Criteria - -✓ Product shell default mention source is live graph-backed when selected-spec graph deps are present. -✓ Production code no longer exports or defaults to `FIXTURE_GRAPH_MENTION_SOURCE` / `#D12 #I9 #A10` fixture candidates. -✓ Autocomplete suggestions include projected codes built from live `overview.nodes` (`formatGraphNodeCode(node.kind, node.kindOrdinal)`) and insert only `#CODE`. -✓ When graph deps are absent, mention autocomplete yields no Brunch graph candidates rather than falling back to dummy data. -✓ No mention ledger, staleness hints, DB imports, or new reusable projection module are introduced. - -### Verification Approach - -- Inner: `npm test -- src/.pi/__tests__/mention-autocomplete.test.ts src/app/brunch-tui.test.ts src/.pi/__tests__/extension-registry.test.ts -t "mention|extension registry"` — proves provider mechanics, shell wiring, and explicit registry behavior against live injected graph overview data. -- Inner: targeted negative assertion — proves `D12/I9/A10` do not appear unless an explicit test fake source supplies them. -- Middle: optional seeded workbench smoke — launch/reload against `.fixtures/workbenches/seeded-dev-rpc` and observe `#` suggestions from `Macro View — grounded intent base` nodes. - -### Cross-cutting obligations - -- Keep autocomplete as presentation/handle insertion only; ledger/staleness remains M7. -- 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 readers and Pi autocomplete provider seams. - -### Expected touched paths (tentative) - -```pseudo -src/.pi/ -├── __tests__/ -│ ├── mention-autocomplete.test.ts ~ -│ └── extension-registry.test.ts ? -├── extensions/ -│ └── mentions/ -│ └── index.ts ~ -└── brunch-pi-extensions.ts ~ - -src/app/ -├── brunch-tui.test.ts ~ -└── brunch-tui.ts ? # only if shell cannot derive source from graph deps alone -``` - -### Promotion checklist - -- [ ] Does this change a requirement? -- [ ] Does this create, retire, or invalidate an assumption? -- [ ] Does this slice depend on an unvalidated high-impact assumption? -- [ ] Does this make or reverse a non-trivial design decision? -- [ ] Does this establish a new seam-level invariant? -- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer? -- [ ] Does it cross more than two major seams? -- [ ] Is this the first touch in an unfamiliar seam from a fresh thread? -- [ ] Can you not name the containing seam or current rationale from the live docs? diff --git a/memory/cards/role-safe-graph-mutations--mutate-graph.md b/memory/cards/role-safe-graph-mutations--mutate-graph.md new file mode 100644 index 000000000..3ff48c015 --- /dev/null +++ b/memory/cards/role-safe-graph-mutations--mutate-graph.md @@ -0,0 +1,506 @@ +# Role-safe graph mutations (`mutateGraph` / `mutate_graph`) + +Frontier: role-safe-graph-mutations +Status: active +Mode: chain +Created: 2026-06-09 + +## Orientation + +- Containing seam: the authored graph-mutation language before `CommandExecutor` turns payloads into accepted graph truth. The relevant frontier is `role-safe-graph-mutations` in `memory/PLAN.md`. +- Prior scope state: `memory/cards/graph--role-named-edge-surface.md` and `memory/cards/dev-seed-fixtures--semantic-graph-mutations.md` are superseded by this file. Current code still exposes `commitGraph` / `commit_graph` and several callers still author edges with `{ category, source, target }`. +- Main risk: landing patch/delete curation or role-named edges separately would create two graph mutation dialects. This chain takes the bigger step: `mutateGraph` / `mutate_graph` becomes the one authored graph-mutation grammar, and exposed `commitGraph` / `commit_graph` is retired by break-and-repair. +- Posture: proving (inherited from `role-safe-graph-mutations`). Landing this stabilizes the edge-authoring seam future relation capture, review-set projection, seed loading, and dev curation will aim at. + +Frontier-level cross-cutting obligations: + +- Preserve D4-L/D20-L: all graph mutations route through `CommandExecutor` and return structured command results. +- Preserve D16-L/A4-L/I1-L: one selected-spec LSN and one change-log row per mutation batch; bare LSNs remain spec-local. +- Preserve D51-L: stored accepted edge identity stays `(category, sourceId, targetId, stance)`; `sourceId`/`targetId` are internal storage geometry, not authored vocabulary. +- Preserve D62-L/D63-L: boundary refs use projected node codes where user/agent-facing; `basis` is approval strength and applies only to newly created graph items. +- Preserve D52-L: `graph/` owns mutation semantics; `.pi/`, `rpc/`, `seed-fixtures`, and capture adapters translate only at boundaries and do not import `db/` directly. +- Do not grant autonomous agents delete authority merely because `mutate_graph` can represent delete ops; operation permissions are policy-gated by caller/posture. + +## Chain-level design lock + +Canonical authored command shape (sketch; exact names may move during build): + +```ts +type MutateGraphInput = { + /** Applies only to newly-created nodes/edges; patch/delete never rewrite basis. */ + createBasis: 'explicit' | 'implicit'; + ops: GraphMutationOp[]; +}; + +type GraphMutationOp = + | { op: 'create_node'; ref: string; plane: NodePlane; kind: NodeKind; title: string; body?: string; source?: string; detail?: unknown } + | ({ op: 'create_edge' } & RoleNamedEdgeDraft) + | { op: 'patch_node'; node: NodeRef; patch: NodePatch } + | { op: 'patch_edge'; edge: EdgeRef; patch: { rationale?: string } } + | { op: 'delete_edge'; edge: EdgeRef } + | { op: 'delete_node'; node: NodeRef; deleteIncidentEdges?: boolean }; + +type RoleNamedEdgeDraft = + | { category: 'dependency'; dependency: NodeRef; dependent: NodeRef; rationale?: string } + | { category: 'proof'; oracle: NodeRef; claim: NodeRef; stance: 'for' | 'against'; rationale?: string } + | { category: 'support'; support: NodeRef; claim: NodeRef; stance: 'for' | 'against'; rationale?: string } + | { category: 'realization'; abstract: NodeRef; concrete: NodeRef; rationale?: string } + | { category: 'boundary'; boundary: NodeRef; subject: NodeRef; rationale?: string } + | { category: 'composition'; whole: NodeRef; part: NodeRef; rationale?: string } + | { category: 'supersession'; successor: NodeRef; predecessor: NodeRef; rationale?: string } + | { category: 'association'; a: NodeRef; b: NodeRef; rationale?: string }; +``` + +Normalization rule: for category `C`, the endpoint field named by `EDGE_CATEGORY_METADATA[C].sourceRole` becomes private `source`; the endpoint field named by `.targetRole` becomes private `target`. `association` is peer/peer and maps `a -> source`, `b -> target` for storage only. + +Break-and-repair path: + +```pseudo +1. Add graph-owned RoleNamedEdgeDraft + table-driven normalizer. +2. Introduce CommandExecutor.mutateGraph / mutateGraph planner. +3. Replace exposed commit_graph with mutate_graph and repair direct graph writers. +4. Port review-set proposals/acceptance to role-named edge drafts over the same planner. +5. Add dev curation RPC over mutateGraph. +6. Reconcile SPEC + GRAPH_MODEL alongside the slices that change those public contracts. +``` + +No compatibility bridge: generic `{ category, source, target }` authored drafts are rejected at graph-tool and review-set boundaries by the end of this chain. + +## Card 1 — Graph-owned role-named edge draft normalizer + +Status: next +Weight: full + +### Target Behavior + +Role-named edge drafts normalize to private `BatchEdgeInput` source/target geometry through `EDGE_CATEGORY_METADATA`. + +### Boundary Crossings + +```pseudo +→ authored RoleNamedEdgeDraft +→ graph-owned endpoint-role normalizer +→ EDGE_CATEGORY_METADATA sourceRole/targetRole +→ private BatchEdgeInput { category, source, target, stance?, rationale? } +→ existing commit/mutation edge planner +``` + +### Risks and Assumptions + +```pseudo +- RISK: role field names drift from EDGE_CATEGORY_METADATA endpoint roles. + → MITIGATION: drift guard enumerates all categories and asserts the union endpoint fields match metadata roles; normalizer reads the table, not a copied role map. +- RISK: `association` has peer/peer roles and no semantic source/target. + → MITIGATION: special-case only peer/peer as `a -> source`, `b -> target`; document this as arbitrary storage orientation. +- RISK: adding the normalizer to `category-policy.ts` makes policy metadata too command-specific. + → MITIGATION: prefer a command/mutation module importing `EDGE_CATEGORY_METADATA`; export only the minimal role-draft type/normalizer needed by adapters. +- ASSUMPTION: EDGE_CATEGORY_METADATA endpoint roles are the correct authored role vocabulary. + → IMPACT IF FALSE: docs, prompts, and normalizer drift together; role naming must be revised before tool-schema work. + → VALIDATE: drift tests against `docs/design/GRAPH_MODEL.md` policy table and existing `category-policy.test.ts` coverage. +``` + +### Posture check + +Proving slice on invariants: it locates the single source of endpoint-role truth and retires the most direct `source`/`target` authoring error before larger mutation work is built on top. + +### Acceptance Criteria + +```pseudo tree +role-named edge draft normalizer +├── ✓ all eight EdgeCategory values have one role-named draft variant +├── ✓ every non-peer variant's endpoint fields match EDGE_CATEGORY_METADATA sourceRole/targetRole +├── ✓ association peer/peer maps a/b to source/target with an explicit test +├── ✓ proof/support require stance and every other variant rejects stance at the authored shape +├── ✓ normalizeEdgeDraft returns private source/target refs without consulting a second role map +└── ✓ graph/index exports only the role-draft surface needed by adapters, not db/storage details +``` + +### Verification Approach + +```pseudo +- Inner: vitest normalizer matrix over all EdgeCategory values. +- Inner: drift guard comparing RoleNamedEdgeDraft endpoint fields to EDGE_CATEGORY_METADATA. +- Inner: stance-locality tests independent of CommandExecutor persistence. +``` + +### Cross-cutting obligations + +- `EDGE_CATEGORY_METADATA` stays the single endpoint-role source. +- Storage and `BatchEdgeInput` can still use source/target internally; authored boundaries must not. + +### Expected touched paths (tentative) + +```pseudo tree +src/graph/ +├── command-executor/ +│ ├── role-named-edge-draft.ts + +│ └── role-named-edge-draft.test.ts + +├── policy/ +│ └── category-policy.test.ts ~? +└── index.ts ~ +``` + +## Card 2 — Atomic `mutateGraph` command engine + +Status: next after Card 1 +Weight: full + +### Target Behavior + +`CommandExecutor.mutateGraph` executes one atomic role-safe graph mutation batch. + +### Boundary Crossings + +```pseudo +→ MutateGraphInput +→ graph mutation planner (create / patch / delete) +→ existing create-node and edge structural validation +→ CommandExecutor transaction boundary +→ SQLite nodes/edges + graph_clock + change_log +→ graph readers/export fixtures +``` + +### Risks and Assumptions + +```pseudo +- RISK: mutateGraph and commitGraph become two validation engines. + → MITIGATION: implement one planner; any temporary commitGraph helper delegates to mutateGraph and is private or removed by chain completion. +- RISK: patch/delete semantics weaken immutable graph identity. + → MITIGATION: patches allow only node title/body/source/detail and edge rationale; reject category, semantic endpoints, stored endpoints, stance, basis, LSN fields. +- RISK: node deletion creates dangling edges or surprising cascades. + → MITIGATION: reject incident edges by default before LSN allocation; require explicit incident-edge deletion flag and audit deleted edge ids in the same change-log payload. +- RISK: mixed create/patch/delete rollback accidentally advances counters/clock. + → MITIGATION: plan before write where possible; tests assert invalid batches do not advance graph_clock, change_log, node_kind_counters, nodes, or edges. +- ASSUMPTION: hard delete remains acceptable for pre-release fixture curation. + → IMPACT IF FALSE: deletion operation must be dropped or replaced by supersession/retirement before dev curation lands. + → VALIDATE: tests cover hard delete semantics and export readback; product-facing delete authority remains policy-gated. +``` + +### Posture check + +Proving slice on proof-of-life and invariants: a mixed graph mutation goes through the real command boundary with one transaction/LSN/change-log row, proving the future curation and capture seam without a second mutation model. + +### Acceptance Criteria + +```pseudo tree +mutateGraph command engine +├── creation parity +│ ├── ✓ create_node/create_edge ops express current commitGraph create-only batches +│ ├── ✓ intra-batch refs and same-spec existing refs validate before writes +│ └── ✓ structurally illegal create batches write no rows and do not advance graph_clock +├── patch +│ ├── ✓ patch_node mutates only title/body/source/detail and updated_at_lsn +│ ├── ✓ invalid per-kind detail is rejected before LSN allocation +│ ├── ✓ patch_edge mutates only rationale and updated_at_lsn +│ └── ✓ identity fields are rejected before LSN allocation +├── delete +│ ├── ✓ delete_edge removes exactly that edge and reports/audits its id +│ ├── ✓ delete_node with incident edges rejects by default before LSN allocation +│ ├── ✓ delete_node with explicit incident-edge deletion removes node+incident edges in one transaction +│ └── ✓ node kind ordinals are not decremented or reused after delete +├── atomicity and audit +│ ├── ✓ mixed create/patch/delete batch consumes one spec-local LSN and one change-log row +│ ├── ✓ any invalid op rejects the whole batch with diagnostics and no partial writes +│ ├── ✓ sibling-spec node/edge refs are rejected +│ └── ✓ result reports created/updated/deleted identities sufficiently for adapters and tests +└── consolidation + ├── ✓ no second commitGraph validation path remains + └── ✓ any surviving commitGraph helper is private and delegates to mutateGraph +``` + +### Verification Approach + +```pseudo +- Inner: CommandExecutor unit/regression tests for planning, write, rollback, and result shape. +- Inner: graph query/export readback after a mixed mutation. +- Middle: import/compile repair proves current callers cannot keep using a stale public commitGraph engine unnoticed. +``` + +### Cross-cutting obligations + +- Preserve one transaction, one selected-spec LSN, and one change-log row. +- Patch/delete never rewrite `basis`; `createBasis` applies only to newly-created nodes/edges. +- Do not expose operation permissions here as policy; command semantics and caller authority remain separate. + +### Expected touched paths (tentative) + +```pseudo tree +src/graph/ +├── command-executor.ts ~ +├── command-executor.test.ts ~ +├── command-executor/ +│ ├── commit-graph-types.ts ~ +│ ├── commit-graph-batch.ts ~ +│ ├── commit-graph-batch.test.ts ~ +│ ├── graph-mutation-types.ts + +│ ├── graph-mutation-planner.ts + +│ └── graph-mutation-planner.test.ts + +├── export-fixtures.test.ts ~? +└── index.ts ~ +``` + +## Card 3 — Exposed graph tool and direct writers port to `mutate_graph` + +Status: next after Card 2 +Weight: full + +### Target Behavior + +Product direct graph writers use `mutateGraph` as their only authored graph-mutation path. + +### Boundary Crossings + +```pseudo +→ Pi graph tool schema (`mutate_graph`) +→ graph command adapter / projected-code resolution +→ CommandExecutor.mutateGraph +→ product update invalidation +→ capture and seed-fixture direct writers +→ prompt resources / docs naming the graph tool +``` + +### Risks and Assumptions + +```pseudo +- RISK: exposed commit_graph remains as an easier stale path. + → MITIGATION: rename/remove exposed tool; tests assert only mutate_graph is registered for graph mutation. +- RISK: source/target remains in prompt/resource text and encourages old payloads. + → MITIGATION: grep/resource tests or targeted assertions over graph tool guidelines and relevant prompt resources. +- RISK: capture/seed loading are node-only today and feel unrelated. + → MITIGATION: port them anyway so `commitGraph` does not remain the go-to helper by inertia. +- ASSUMPTION: current graph tool callers can be break-repaired atomically in this branch. + → IMPACT IF FALSE: if an external consumer exists, this would need deprecation discipline; current posture and docs treat these as internal pre-release product seams. + → VALIDATE: grep callers and tests; no persisted wire consumer is promised. +``` + +### Posture check + +Proving slice on proof-of-life: the real Pi tool path and direct graph writers exercise the new grammar through the product entrypoints rather than only unit tests. + +### Acceptance Criteria + +```pseudo tree +product direct writer port +├── ✓ Pi registers mutate_graph, not commit_graph, as the graph mutation tool +├── ✓ mutate_graph tool schema uses role-named create_edge ops and rejects generic source/target authored edges +├── ✓ command adapter resolves projected existing node codes per role field before CommandExecutor.mutateGraph +├── ✓ propose-graph direct commits use create-only ops with createBasis=implicit +├── ✓ captureExplicitTextFacts uses create-only ops with createBasis=explicit +├── ✓ seedFixture uses create-only ops with createBasis=explicit +├── ✓ graph mutation success still publishes graph invalidations with {specId, lsn} +└── ✓ prompt/resource/docs text no longer presents commit_graph as the go-to graph-writing tool +``` + +### Verification Approach + +```pseudo +- Inner: graph tool adapter tests for role-named endpoint resolution and schema rejection of source/target. +- Middle: graph tools end-to-end test persists nodes/edges through mutate_graph and read_graph readback. +- Inner: capture and seed-fixture tests pass over mutateGraph create-only ops. +- Inner: grep/source assertions for exposed commit_graph retirement where practical. +``` + +### Cross-cutting obligations + +- Keep createBasis explicit/implicit semantics aligned with D63-L. +- Do not expose patch/delete to autonomous agent modes unless current runtime policy allows it; the unified grammar and authority remain separate. + +### Expected touched paths (tentative) + +```pseudo tree +src/.pi/extensions/graph/ +├── index.ts ~ +├── tool-schemas.ts ~ +└── command-adapter.ts ~ +src/.pi/__tests__/ +├── graph-tools.test.ts ~ +└── prompting.test.ts ~? +src/.pi/skills/methods/ +└── commit-graph.md ~? +src/graph/ +├── capture/structured-response.ts ~ +├── capture/structured-response.test.ts ~ +├── seed-fixtures.ts ~ +├── seed-fixtures.test.ts ~ +└── index.ts ~ +docs/design/GRAPH_MODEL.md ~ +memory/SPEC.md ~ +``` + +## Card 4 — Review-set proposals use role-named mutation drafts + +Status: next after Card 3 +Weight: full + +### Target Behavior + +Review-set proposal edge drafts use the same role-named edge grammar as `mutateGraph`. + +### Boundary Crossings + +```pseudo +→ project-graph review-set payload +→ review-set payload validation +→ role-named endpoint resolution (draftId | existingCode) +→ mutateGraph create-only planning / dry-run +→ acceptReviewSet workflow audit +→ CommandExecutor write +``` + +### Risks and Assumptions + +```pseudo +- RISK: review-set payload schemas drift from graph-owned role draft shape. + → MITIGATION: review-set translation imports/reuses graph-owned RoleNamedEdgeDraft semantics; schema tests reject source/target edge drafts. +- RISK: acceptReviewSet loses its workflow audit identity if it delegates too deeply. + → MITIGATION: acceptReviewSet remains the workflow command and change_log operation; only graph write planning is shared with mutateGraph. +- RISK: present_review_set structured-exchange schemas encode the old edge shape separately. + → MITIGATION: inspect/update `src/.pi/extensions/exchanges/schemas/**` in the same slice if they own review-set edge payload shape; add lockstep test or source assertion. +- ASSUMPTION: review-set endpoint refs remain `{draftId}` or `{existingCode}` regardless of role field name. + → IMPACT IF FALSE: role-named endpoint resolution must be redesigned before payload schema update. + → VALIDATE: translation tests over draft and existing-code endpoints for several categories. +``` + +### Posture check + +Proving slice on invariants: it canonicalizes the second LLM-authored edge boundary and preserves I20-L dry-run gating under the new grammar. + +### Acceptance Criteria + +```pseudo tree +review-set role-named edge drafts +├── ✓ ReviewSetEdgeDraft is role-named and rejects generic source/target +├── ✓ draftId and existingCode endpoints resolve correctly from role fields +├── ✓ role-named edge drafts translate to mutateGraph create-only ops with createBasis=explicit +├── ✓ dryRunAcceptReviewSet still rejects structurally illegal proposals before user review +├── ✓ acceptReviewSet still writes one accept_review_set change_log row and one graph LSN +├── ✓ project-graph review-set tests cover at least proof/support stance and one non-stance category +└── ✓ D27-L / GRAPH_MODEL review-set examples are reconciled to role-named edge drafts +``` + +### Verification Approach + +```pseudo +- Inner: review-set payload shape and translation tests. +- Inner: CommandExecutor accept/dry-run parity tests. +- Middle: existing project-graph review-cycle tests/probe fixture review; regenerate only if committed fixtures encode old payloads. +``` + +### Cross-cutting obligations + +- Preserve D27-L/I15-L/I20-L: only dry-run-valid proposals surface as reviewable; approval remains one atomic acceptReviewSet command. +- Keep review-set exact approval basis explicit; mutation path remains in change_log, not `basis`. + +### Expected touched paths (tentative) + +```pseudo tree +src/graph/ +├── review-set.ts ~ +├── review-set.test.ts ~ +└── command-executor/accept-review-set.test.ts ~ +src/.pi/extensions/exchanges/schemas/ ? +src/.pi/__tests__/ +├── structured-exchange-schemas.test.ts ~? +└── structured-exchange-present-request.test.ts ~? +docs/design/GRAPH_MODEL.md ~ +memory/SPEC.md ~ +.fixtures/runs/project-graph-review-cycle/ ? +``` + +## Card 5 — Dev curation RPC exposes `mutateGraph` by projected codes + +Status: next after Card 4 +Weight: full + +### Target Behavior + +Dev-only graph curation RPC applies projected-code `mutateGraph` operations to a selected spec. + +### Boundary Crossings + +```pseudo +→ dev JSON-RPC params +→ TypeBox schema / discovery gate +→ selected-spec projected-code and edge-id validation +→ CommandExecutor.mutateGraph +→ product update invalidation +→ graph.overview readback / fixture export workflow docs +``` + +### Risks and Assumptions + +```pseudo +- RISK: dev RPC becomes accidental public product API. + → MITIGATION: method stays under dev.graph.*, discovery requires BRUNCH_DEV_RPC=1, and read-only sidecars do not expose it. +- RISK: curation payloads need raw node ids. + → MITIGATION: node refs accept projected node codes and batch refs; core may use internal ids only after adapter resolution. +- RISK: edge targets lack stable projected edge codes. + → MITIGATION: allow edge ids for patch_edge/delete_edge at the dev boundary only with selected-spec validation; do not invent edge-code projection in this frontier. +- RISK: old dev.graph.commitGraph remains as stale convenience. + → MITIGATION: replace it with dev.graph.mutateGraph; no parallel dev commit method after this card. +- ASSUMPTION: one-shot `src/dev/workspace-rpc.ts` remains enough curation ergonomics. + → IMPACT IF FALSE: scope a later tiny curation CLI; do not add package scripts here. + → VALIDATE: smoke against a temporary seeded workspace and document the command shape. +``` + +### Posture check + +Proving slice on proof-of-life: it exercises create/patch/delete through the real local curation entrypoint without a separate mutation model or DB bypass. + +### Acceptance Criteria + +```pseudo tree +dev curation mutateGraph RPC +├── discovery and access +│ ├── ✓ dev.graph.mutateGraph appears only when BRUNCH_DEV_RPC=1 +│ └── ✓ normal/read-only sidecars do not expose it +├── refs and validation +│ ├── ✓ node refs accept selected-spec projected codes and reject malformed/unresolved/sibling-spec codes +│ ├── ✓ create_edge uses role-named endpoint fields and rejects source/target +│ ├── ✓ edge-id patch/delete refs are selected-spec validated +│ └── ✓ invalid ops return structural_illegal without writes +├── mutation behavior +│ ├── ✓ create-node/create-edge, patch-node, patch-edge, delete-edge, and guarded delete-node work through one method +│ ├── ✓ success publishes graph invalidation with {specId, lsn} +│ └── ✓ graph.overview readback shows post-mutation graph and unchanged sibling-spec LSNs +└── workflow ergonomics + ├── ✓ src/dev/workspace-rpc.ts can call dev.graph.mutateGraph + ├── ✓ docs/testing/seeded-dev-rpc.md shows mutate and fixture-export examples + └── ✓ fresh temporary seed workspace smoke mutates one spec and exports JSON for inspection +``` + +### Verification Approach + +```pseudo +- Inner: RPC handler/discovery tests. +- Middle: one-shot workspace-rpc smoke against a temporary seeded workspace. +- Outer: optional manual Bilal curation rehearsal only after user confirms the workbench may be mutated. +``` + +### Cross-cutting obligations + +- Dev RPC remains absent from normal product discovery and read-only sidecars. +- Do not mutate `.fixtures/workbenches/bilal-curation` or capture curated seeds unless the user explicitly approves that data change. + +### Expected touched paths (tentative) + +```pseudo tree +src/rpc/ +├── methods/dev-graph.ts ~ +├── handlers.test.ts ~ +├── methods/registry.test.ts ~? +└── README.md ~ +src/dev/ +└── workspace-rpc.ts ~ +docs/testing/seeded-dev-rpc.md ~ +.fixtures/workbenches/ ? (scratch smoke only; do not commit DB state) +``` + +## Traceability / canonical reconciliation during the chain + +- D53-L: exposed graph-writing tool becomes `mutate_graph`; direct propose-graph commits are create-only `mutateGraph` ops with `createBasis: implicit`. +- D27-L: review-set edge drafts are role-named and acceptance still writes one atomic `accept_review_set` command. +- D51-L: add explicit boundary note that endpoint roles are authored vocabulary while `sourceId`/`targetId` remain immutable stored geometry. +- A14-L: structural-legality assumption now includes role-named edge drafts and the `mutate_graph` grammar. +- New/updated invariant: agents express edges only by category + endpoint roles; `source/target` is internal storage geometry derived from `EDGE_CATEGORY_METADATA`; role field names are test-pinned to that table. +- `docs/design/GRAPH_MODEL.md`: update agent-facing command surface and examples from `commitGraph({nodes, edges})` / `{category, source, target}` to `mutateGraph` create ops with role-named edges. diff --git a/package.json b/package.json index 06988e0b1..cba5fb8c9 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,20 @@ "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", "test": "vitest --run", "test:watch": "vitest", + "test:renderers": "vitest --run src/renderers", + "test:renderers:watch": "vitest src/renderers", + "test:renderers:update": "vitest --run src/renderers --update", + "test:renderers:graph": "vitest --run src/renderers/graph", + "test:renderers:graph:watch": "vitest src/renderers/graph", + "test:renderers:session": "vitest --run src/renderers/session", + "test:renderers:session:watch": "vitest src/renderers/session", + "test:renderers:workspace": "vitest --run src/renderers/workspace", + "test:renderers:workspace:watch": "vitest src/renderers/workspace", "lint": "oxlint", "lint:fix": "oxlint --fix", "fmt": "oxfmt", diff --git a/src/.pi/__tests__/extension-registry.test.ts b/src/.pi/__tests__/extension-registry.test.ts index 9d6850805..9ae675c19 100644 --- a/src/.pi/__tests__/extension-registry.test.ts +++ b/src/.pi/__tests__/extension-registry.test.ts @@ -5,18 +5,19 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; -import alternatives from '../components/alternatives.js'; +import { registerBrunchAlternatives as alternatives } from '../components/alternatives.js'; import chrome from '../extensions/chrome/index.js'; -import commands, { +import { BRUNCH_CONTINUE_COMMAND, BRUNCH_LENS_COMMAND, BRUNCH_MODE_COMMAND, BRUNCH_STRATEGY_COMMAND, BRUNCH_SWITCH_COMMAND, + registerBrunchCommands as commands, } from '../extensions/commands/index.js'; -import commandPolicy from '../extensions/commands/policy.js'; -import context from '../extensions/context/index.js'; -import structuredExchange, { +import { registerBrunchBranchPolicyHandlers as commandPolicy } from '../extensions/commands/policy.js'; +import { registerBrunchContext as context } from '../extensions/context/index.js'; +import { PRESENT_OPTIONS_TOOL, PRESENT_QUESTION_TOOL, PRESENT_REVIEW_SET_TOOL, @@ -24,11 +25,12 @@ import structuredExchange, { REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, REQUEST_REVIEW_TOOL, + registerStructuredExchange as structuredExchange, } from '../extensions/exchanges/index.js'; -import mentionAutocomplete from '../extensions/mentions/index.js'; -import operationalMode from '../extensions/runtime/index.js'; -import sessionLifecycle from '../extensions/session/lifecycle.js'; -import prompting from '../extensions/system-prompts/index.js'; +import { registerBrunchMentionAutocomplete as mentionAutocomplete } from '../extensions/mentions/index.js'; +import { registerBrunchOperationalModePolicy as operationalMode } from '../extensions/runtime/index.js'; +import { registerBrunchSessionBoundary as sessionLifecycle } from '../extensions/session/lifecycle.js'; +import { registerBrunchPrompting as prompting } from '../extensions/system-prompts/index.js'; const extensionDefaults = { 'components/alternatives.ts': alternatives, @@ -44,7 +46,7 @@ const extensionDefaults = { }; describe('Brunch explicit Pi extension registry', () => { - it('keeps default factory exports for src/.pi iteration', () => { + it('keeps named factory exports for src/.pi iteration', () => { for (const [path, factory] of Object.entries(extensionDefaults)) { expect(factory, path).toEqual(expect.any(Function)); } diff --git a/src/.pi/__tests__/graph-tools.test.ts b/src/.pi/__tests__/graph-tools.test.ts index 636a78e3b..de3140200 100644 --- a/src/.pi/__tests__/graph-tools.test.ts +++ b/src/.pi/__tests__/graph-tools.test.ts @@ -1,95 +1,48 @@ -/** - * Graph tool integration tests. - * - * Tests the commit_graph and read_graph tools end-to-end through - * the command adapter → CommandExecutor → graph read chain. - * - * SPEC: D4-L, D20-L, D52-L, D53-L, I26-L, I34-L, A14-L - */ +import { describe, expect, it } from 'vitest'; -import { Value } from 'typebox/value'; -import { describe, beforeEach, it, expect } from 'vitest'; - -import { createDb } from '../../db/connection.js'; -import type { BrunchDb } from '../../db/connection.js'; -import { edges } from '../../db/schema.js'; +import { createDb, type BrunchDb } from '../../db/connection.js'; import { CommandExecutor } from '../../graph/command-executor.js'; import { - getGraphGaps, - getGraphOverview, - getGraphSliceByKinds, - getGraphSliceByReadinessBands, - getNodeNeighborhood, - getRelatedNodes, + getNodes, + queryGraph, resolveGraphNodeCode, + type GraphFilter, + type GraphVisibility, } 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 GraphReaders } from '../extensions/graph/index.js'; -import { CommitGraphParams, ReadGraphParams } from '../extensions/graph/tool-schemas.js'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- let nextSpecSlug = 0; function createTestDb(): BrunchDb { return createDb(':memory:'); } + function seedSpec(db: BrunchDb): number { - const result = new CommandExecutor(db).createSpec({ - name: 'Test Spec', - slug: `test-${nextSpecSlug++}`, - }); + const result = new CommandExecutor(db).createSpec({ name: 'Test Spec', slug: `test-${nextSpecSlug++}` }); if (result.status !== 'success') throw new Error('Unable to create test spec'); return result.specId; } function createGraphReads(db: BrunchDb, specId: number): GraphReaders { return { - 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), + queryGraph: (filter?: GraphFilter, options?: { visibility?: GraphVisibility }) => + queryGraph(db, specId, filter, options), + getNodes: (selectors, options) => getNodes(db, specId, selectors, options), resolveNodeCode: (code) => resolveGraphNodeCode(db, specId, code), }; } -// --------------------------------------------------------------------------- -// command-adapter: translateCommitGraph -// --------------------------------------------------------------------------- - -describe('translateCommitGraph', () => { - it('resolves existing projected codes before handing edges to CommandExecutor', () => { +describe('graph tool adapter', () => { + it('translates existing projected codes before handing edges to CommandExecutor', () => { const input = translateCommitGraph( { - nodes: [ - { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }, - { - ref: 'n2', - plane: 'intent', - kind: 'requirement', - title: 'Test req', - body: 'details', - }, - ], - edges: [ - { category: 'dependency', source: 'n2', target: 'n1' }, - { - category: 'support', - source: { existingCode: 'G1' }, - target: 'n1', - stance: 'for', - }, - ], + nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }], + edges: [{ category: 'support', source: { existingCode: 'G1' }, target: 'n1', stance: 'for' }], }, 7, (code) => (code === 'G1' ? 42 : undefined), @@ -97,741 +50,50 @@ describe('translateCommitGraph', () => { expect('status' in input).toBe(false); if ('status' in input) throw new Error('unreachable'); - expect(input.specId).toBe(7); - expect(input.nodes).toHaveLength(2); - expect(input.nodes[0]!.ref).toBe('n1'); - expect(input.edges).toHaveLength(2); - expect(input.edges[0]!.source).toBe('n2'); - expect(input.edges[1]!.source).toEqual({ existing: 42 }); - expect(input.basis).toBe('implicit'); - expect(input.nodes[0]).not.toHaveProperty('basis'); - expect(input.edges[0]).not.toHaveProperty('basis'); - }); - it('normalizes projected-code failures into structured diagnostics', () => { - expect( - translateCommitGraph( - { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }], - edges: [{ category: 'dependency', source: { existingCode: 'bad' }, target: 'n1' }], - }, - 7, - () => undefined, - ), - ).toMatchObject({ - status: 'structural_illegal', - diagnostics: [{ field: 'edges[0].source' }], - }); - - expect( - translateCommitGraph( - { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Test goal' }], - edges: [{ category: 'dependency', source: { existingCode: 'G99' }, target: 'n1' }], - }, - 7, - () => undefined, - ), - ).toMatchObject({ - status: 'structural_illegal', - diagnostics: [{ field: 'edges[0].source' }], - }); - }); -}); - -describe('graph tool schemas', () => { - it('accepts existing-node projected codes but not raw existing node ids', () => { - const valid = { - nodes: [], - edges: [{ category: 'dependency', source: { existingCode: 'G1' }, target: 'n1' }], - }; - const rawId = { - nodes: [], - edges: [{ category: 'dependency', source: { existing: 1 }, target: 'n1' }], - }; - - expect(Value.Check(CommitGraphParams, valid)).toBe(true); - expect(Value.Check(CommitGraphParams, rawId)).toBe(false); - }); - - it('accepts projected node codes for read_graph neighborhood mode instead of node_id', () => { - 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); - }); -}); - -// --------------------------------------------------------------------------- -// command-adapter: formatCommitGraphResult -// --------------------------------------------------------------------------- - -describe('formatCommitGraphResult', () => { - it('formats success with node refs, projected node codes, and edge ids', () => { - const text = formatCommitGraphResult({ - status: 'success', - lsn: 5, - createdNodes: { n1: { id: 1, code: 'G1' }, n2: { id: 2, code: 'R1' } }, - edges: [10, 11], - }); - - expect(text).toContain('Graph committed successfully'); - expect(text).toContain('LSN 5'); - expect(text).toContain('n1 → G1'); - expect(text).not.toContain('n1 → #1'); - expect(text).toContain('#10'); + expect(input.edges[0]!.source).toEqual({ existing: 42 }); }); - it('formats structural_illegal with diagnostics', () => { - const text = formatCommitGraphResult({ - status: 'structural_illegal', - diagnostics: [ - { field: 'nodes[0].kind', message: '"invalid" is not a valid kind' }, - { field: 'edges[0].stance', message: 'stance required for proof' }, - ], - }); - - expect(text).toContain('STRUCTURAL_ILLEGAL'); - expect(text).toContain('nodes[0].kind'); - expect(text).toContain('edges[0].stance'); + it('formats graph slices for LLM-facing tool content', () => { + expect(formatGraphOverview({ nodes: [], edges: [], lsn: 0 })).toContain('empty'); }); }); -// --------------------------------------------------------------------------- -// command-adapter: formatGraphOverview -// --------------------------------------------------------------------------- - -describe('formatGraphOverview', () => { - it('reports empty graph', () => { - const text = formatGraphOverview({ - nodes: [], - edges: [], - nodeCount: 0, - edgeCount: 0, - lsn: 0, - }); - - expect(text).toContain('empty'); - }); -}); - -// --------------------------------------------------------------------------- -// End-to-end: commit then read -// --------------------------------------------------------------------------- - describe('graph tools end-to-end', () => { - let db: BrunchDb; - let executor: CommandExecutor; - let reads: GraphReaders; - let specId: number; - - beforeEach(() => { - db = createTestDb(); - executor = new CommandExecutor(db); - specId = seedSpec(db); - reads = createGraphReads(db, specId); - }); - - it('commit_graph creates nodes and edges readable by read_graph', () => { - // Commit a small graph - const input = translateCommitGraph( - { - nodes: [ - { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Build auth' }, - { - ref: 'n2', - plane: 'intent', - kind: 'requirement', - title: 'JWT tokens', - }, - ], - edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], - }, - specId, - () => undefined, - ); - if ('status' in input) throw new Error('unreachable'); - const result = executor.commitGraph(input); - expect(result.status).toBe('success'); - - // Read the graph - const overview = reads.getGraphOverview(); - const text = formatGraphOverview(overview); - - expect(overview.nodeCount).toBe(2); - expect(overview.edgeCount).toBe(1); - expect(text).toContain('Build auth'); - expect(text).toContain('JWT tokens'); - expect(text).toContain('dependency'); - }); - - it('commit_graph publishes selected-spec graph update topics after successful commits', async () => { - const productUpdates = createProductUpdatePublisher(); - const observed: unknown[] = []; - const tools = new Map }>(); - productUpdates.subscribe((updates) => observed.push(...updates)); - registerBrunchGraph( - { - registerTool(tool: { name: string; execute(toolCallId: string, params: unknown): Promise }) { - tools.set(tool.name, tool); - }, - } as never, - { specId, commandExecutor: executor, reads, productUpdates }, - ); - - await tools.get('commit_graph')!.execute('call-1', { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'Observable goal' }], - edges: [], - }); - - expect(observed).toEqual([ - { topic: 'graph.overview', specId, lsn: 2 }, - { topic: 'graph.nodeNeighborhood', specId, lsn: 2 }, - ]); - }); - - it('commit_graph resolves selected-spec projected codes through the tool adapter', async () => { - const existing = executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'Existing goal' }); - expect(existing.status).toBe('success'); - if (existing.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 result = (await tools.get('commit_graph')!.execute('commit-1', { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'requirement', title: 'New req' }], - edges: [{ category: 'realization', source: { existingCode: 'G1' }, target: 'n1' }], - })) as { - content: Array<{ type: 'text'; text: string }>; - details: unknown; - }; - - expect(result.content[0]?.text).toContain('n1 → R1'); - expect(result.content[0]?.text).not.toContain('n1 → #'); - expect(result.details).toMatchObject({ status: 'success', createdNodes: { n1: { code: 'R1' } } }); - expect(db.select().from(edges).all()[0]!.source_id).toBe(existing.nodeId); - }); - - it('commit_graph rejects projected codes that belong to another selected spec', async () => { - const otherSpecId = seedSpec(db); - const otherExecutor = new CommandExecutor(db); - const other = otherExecutor.createNode({ - specId: otherSpecId, - plane: 'intent', - kind: 'goal', - title: 'Other spec goal', - }); - expect(other.status).toBe('success'); - - 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('commit_graph')!.execute('commit-1', { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'requirement', title: 'New req' }], - edges: [{ category: 'realization', source: { existingCode: 'G1' }, target: 'n1' }], - })) as { - content: Array<{ type: 'text'; text: string }>; - details: unknown; - }; - - expect(result.content[0]?.text).toContain('STRUCTURAL_ILLEGAL'); - expect(result.details).toMatchObject({ - status: 'structural_illegal', - diagnostics: [{ field: 'edges[0].source' }], - }); - }); - - it('graph tool prompt guidance names projected codes rather than raw node ids', () => { - const registered: Array<{ name: string; description?: string; promptGuidelines?: readonly string[] }> = + it('commit_graph creates nodes and read_graph overview reads the selected-spec slice', async () => { + const db = createTestDb(); + const executor = new CommandExecutor(db); + const specId = seedSpec(db); + const reads = createGraphReads(db, specId); + const tools: Array<{ name: string; execute: (toolCallId: string, params: never) => Promise }> = []; - registerBrunchGraph( - { - registerTool(tool: { name: string; description?: string; promptGuidelines?: readonly string[] }) { - registered.push(tool); - }, - } as never, - { specId, commandExecutor: executor, reads }, - ); - - const text = registered - .flatMap((tool) => [tool.description ?? '', ...(tool.promptGuidelines ?? [])]) - .join('\n'); - - expect(text).toContain('existingCode'); - expect(text).toContain('nodeCode'); - expect(text).not.toContain('{existing: }'); - expect(text).not.toContain('node_id'); - }); - - it('commit_graph returns diagnostics on invalid batch', () => { - const input = translateCommitGraph( - { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'not_a_kind' as never, title: 'Bad' }], - edges: [], - }, - specId, - () => undefined, - ); - if ('status' in input) throw new Error('unreachable'); - const result = executor.commitGraph(input); - expect(result.status).toBe('structural_illegal'); - - if (result.status === 'structural_illegal') { - const text = formatCommitGraphResult(result); - expect(text).toContain('STRUCTURAL_ILLEGAL'); - expect(text).toContain('not_a_kind'); - } - }); - - it('commit_graph with edge validation failure rolls back nodes (I34-L)', () => { - const input = translateCommitGraph( - { - nodes: [{ ref: 'n1', plane: 'intent', kind: 'goal', title: 'A goal' }], - edges: [ - // stance required for proof but missing - { category: 'proof', source: 'n1', target: 'n1' }, - ], - }, - specId, - () => undefined, - ); - if ('status' in input) throw new Error('unreachable'); - const result = executor.commitGraph(input); - expect(result.status).toBe('structural_illegal'); - - // Node should NOT have been created (all-or-nothing) - const overview = reads.getGraphOverview(); - expect(overview.nodeCount).toBe(0); - }); - - it('read_graph neighborhood returns node details', () => { - // Create a node first - const input = translateCommitGraph( - { - nodes: [ - { - ref: 'n1', - plane: 'intent', - kind: 'goal', - title: 'Main goal', - body: 'A detailed goal', - }, - ], - edges: [], - }, - specId, - () => undefined, - ); - if ('status' in input) throw new Error('unreachable'); - const commitResult = executor.commitGraph(input); - expect(commitResult.status).toBe('success'); - - if (commitResult.status === 'success') { - const nodeId = commitResult.createdNodes['n1']!.id; - const result = reads.getNodeNeighborhood(nodeId); - const text = formatNeighborhoodResult(result); - - expect(text).toContain('Main goal'); - expect(text).toContain('A detailed goal'); - } - }); - - it('read_graph neighborhood returns node-context markdown and typed details through the tool path', async () => { - const input = translateCommitGraph( - { - nodes: [ - { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Tool-visible goal', body: 'Selected body' }, - ], - edges: [], - }, - specId, - () => undefined, - ); - if ('status' in input) throw new Error('unreachable'); - const commitResult = executor.commitGraph(input); - 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 result = (await tools.get('read_graph')!.execute('read-1', { - mode: 'neighborhood', - nodeCode: 'G1', - })) as { - content: Array<{ type: 'text'; text: string }>; - details: unknown; - }; - - expect(result.content[0]?.text).toMatchInlineSnapshot(` - "[Selected-spec node context] - - anchor: [G1] intent/goal: Tool-visible goal - - anchor body: Selected body - - neighbors: none within requested hops - - edges: none" - `); - expect(result.details).toMatchObject({ - status: 'success', - anchor: { title: 'Tool-visible goal' }, - neighbors: [], - edges: [], - }); - }); - - 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({ + registerBrunchGraph({ registerTool: (tool: unknown) => tools.push(tool as never) } as never, { 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' }], + commandExecutor: executor, + reads, }); - 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' }], - }); - }); + const commit = tools.find((tool) => tool.name === 'commit_graph')!; + const read = tools.find((tool) => tool.name === 'read_graph')!; - it('read_graph related mode returns related nodes and structural_illegal for unknown anchors', async () => { - const commitResult = executor.commitGraph({ - specId, - basis: 'implicit', + const commitResult = (await commit.execute('tool-1', { nodes: [ - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Anchor requirement' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'Direct assumption' }, + { ref: 'n1', plane: 'intent', kind: 'goal', title: 'Build graph API' }, + { ref: 'n2', plane: 'intent', kind: 'requirement', title: 'Expose queryGraph' }, ], - edges: [{ category: 'dependency', source: 'r1', target: 'a1' }], - }); - expect(commitResult.status).toBe('success'); - if (commitResult.status !== 'success') return; + edges: [{ category: 'dependency', source: 'n2', target: 'n1' }], + } as never)) as { content: readonly { text: string }[]; details: { status: string } }; - 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 }, - ); + expect(commitResult.details.status).toBe('success'); + expect(formatCommitGraphResult(commitResult.details as never)).toContain('Graph committed successfully'); - 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; + const readResult = (await read.execute('tool-2', { mode: 'overview' } as never)) as { + content: readonly { text: string }[]; + details: { nodes: readonly unknown[]; edges: readonly 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 = 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'); + expect(readResult.details.nodes).toHaveLength(2); + expect(readResult.details.edges).toHaveLength(1); + expect(readResult.content[0]!.text).toContain('Build graph API'); }); }); diff --git a/src/.pi/__tests__/prompting.test.ts b/src/.pi/__tests__/prompting.test.ts index a7803cdb5..073700a5b 100644 --- a/src/.pi/__tests__/prompting.test.ts +++ b/src/.pi/__tests__/prompting.test.ts @@ -19,16 +19,6 @@ 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', @@ -74,10 +64,8 @@ const promptContext = { }, session: { id: 'session-1', label: 'Session' }, graphReads: { - getGraphOverview: () => ({ + queryGraph: () => ({ lsn: 4, - nodeCount: 2, - edgeCount: 1, nodes: [ { id: 1, @@ -115,11 +103,7 @@ const promptContext = { }, ], }), - getGraphSliceByKinds: () => emptyGraphSlice(), - getGraphSliceByReadinessBands: () => emptyGraphSlice(), - getGraphGaps: () => emptyGraphSlice(), - getRelatedNodes: () => ({ status: 'not_found' as const }), - getNodeNeighborhood: () => ({ status: 'not_found' as const }), + getNodes: () => [], resolveNodeCode: () => undefined, }, }; @@ -240,10 +224,8 @@ describe('Brunch prompt-pack topology', () => { workspace: promptContext.workspace, session: selected.session, graphReads: { - getGraphOverview: () => ({ + queryGraph: () => ({ lsn: 1, - nodeCount: selected.nodeTitles.length, - edgeCount: 0, nodes: selected.nodeTitles.map((title, index) => ({ id: index + 1, specId: selected.spec.id, @@ -257,11 +239,7 @@ describe('Brunch prompt-pack topology', () => { })), edges: [], }), - getGraphSliceByKinds: () => emptyGraphSlice(), - getGraphSliceByReadinessBands: () => emptyGraphSlice(), - getGraphGaps: () => emptyGraphSlice(), - getRelatedNodes: () => ({ status: 'not_found' as const }), - getNodeNeighborhood: () => ({ status: 'not_found' as const }), + getNodes: () => [], resolveNodeCode: () => undefined, }, }), diff --git a/src/.pi/__tests__/structured-exchange-extension.test.ts b/src/.pi/__tests__/structured-exchange-extension.test.ts index 21a772e2e..a184766af 100644 --- a/src/.pi/__tests__/structured-exchange-extension.test.ts +++ b/src/.pi/__tests__/structured-exchange-extension.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; -import registerStructuredExchange, { +import { PRESENT_OPTIONS_TOOL, REQUEST_CHOICE_TOOL, + registerStructuredExchange, } from '../extensions/exchanges/index.js'; const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'); diff --git a/src/.pi/__tests__/structured-exchange-present-request.test.ts b/src/.pi/__tests__/structured-exchange-present-request.test.ts index b15d8aa73..c18001917 100644 --- a/src/.pi/__tests__/structured-exchange-present-request.test.ts +++ b/src/.pi/__tests__/structured-exchange-present-request.test.ts @@ -2,12 +2,13 @@ import { describe, expect, it } from 'vitest'; import { createDb } from '../../db/connection.js'; import { CommandExecutor } from '../../graph/command-executor.js'; -import registerStructuredExchange, { +import { PRESENT_OPTIONS_TOOL, PRESENT_REVIEW_SET_TOOL, REQUEST_CHOICE_TOOL, REQUEST_CHOICES_TOOL, REQUEST_REVIEW_TOOL, + registerStructuredExchange, } from '../extensions/exchanges/index.js'; import { findIncompleteStructuredExchangePresents, diff --git a/src/.pi/agents/contexts/graph.test.ts b/src/.pi/agents/contexts/graph.test.ts index 8cfe374e5..b9231bb8e 100644 --- a/src/.pi/agents/contexts/graph.test.ts +++ b/src/.pi/agents/contexts/graph.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { GraphOverview } from '../../../graph/queries.js'; +import type { GraphSlice } from '../../../graph/queries.js'; import { renderGraphContext } from './graph.js'; -const overview: GraphOverview = { +const overview: GraphSlice = { lsn: 7, - nodeCount: 4, - edgeCount: 2, nodes: [ node(1, 'intent', 'goal', 'Fast local specification'), node(2, 'design', 'module', 'Prompt composer'), @@ -67,10 +65,10 @@ describe('renderGraphContext', () => { function node( id: number, - plane: GraphOverview['nodes'][number]['plane'], - kind: GraphOverview['nodes'][number]['kind'], + plane: GraphSlice['nodes'][number]['plane'], + kind: GraphSlice['nodes'][number]['kind'], title: string, -): GraphOverview['nodes'][number] { +): GraphSlice['nodes'][number] { return { id, specId: 1, diff --git a/src/.pi/agents/contexts/graph.ts b/src/.pi/agents/contexts/graph.ts index aac60a0e1..9fcf75a33 100644 --- a/src/.pi/agents/contexts/graph.ts +++ b/src/.pi/agents/contexts/graph.ts @@ -1,4 +1,4 @@ -import type { GraphOverview } from '../../../graph/queries.js'; +import type { GraphSlice } from '../../../graph/queries.js'; import { formatGraphNodeCode, type GraphNode } from '../../../graph/schema/nodes.js'; import type { AgentLensSelection } from '../../../session/runtime-state.js'; @@ -11,7 +11,7 @@ export interface RenderGraphContextOptions { const DEFAULT_MAX_NODES = 8; const DEFAULT_MAX_EDGES = 8; -export function renderGraphContext(overview: GraphOverview, options: RenderGraphContextOptions): string { +export function renderGraphContext(overview: GraphSlice, options: RenderGraphContextOptions): string { const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES; const maxEdges = options.maxEdges ?? DEFAULT_MAX_EDGES; const emphasizedNodes = [...overview.nodes].sort((a, b) => { @@ -22,11 +22,11 @@ export function renderGraphContext(overview: GraphOverview, options: RenderGraph const lines = [ `[Selected-spec graph context · ${options.lens} lens]`, - `- selected-spec lsn: ${overview.lsn}; nodes: ${overview.nodeCount}; edges: ${overview.edgeCount}`, + `- selected-spec lsn: ${overview.lsn}; nodes: ${overview.nodes.length}; edges: ${overview.edges.length}`, `- emphasis: ${lensEmphasis(options.lens)}`, ]; - if (overview.nodeCount === 0) { + if (overview.nodes.length === 0) { lines.push('- graph: empty'); return lines.join('\n'); } diff --git a/src/.pi/agents/contexts/index.ts b/src/.pi/agents/contexts/index.ts index 03fba947e..633e4b85e 100644 --- a/src/.pi/agents/contexts/index.ts +++ b/src/.pi/agents/contexts/index.ts @@ -1,3 +1,3 @@ -export { renderCwdContext, type AgentPromptSessionContext, type RenderCwdContextInput } from './cwd.js'; -export { renderGraphContext, type RenderGraphContextOptions } from './graph.js'; -export { renderNodeContext, type RenderNodeContextOptions } from './node.js'; +export { renderCwdContext, type AgentPromptSessionContext } from './cwd.js'; +export { renderGraphContext } from './graph.js'; +export { renderNodeContext } from './node.js'; diff --git a/src/.pi/agents/contexts/node.test.ts b/src/.pi/agents/contexts/node.test.ts deleted file mode 100644 index 9353dcb35..000000000 --- a/src/.pi/agents/contexts/node.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { NeighborhoodResult } from '../../../graph/queries.js'; -import type { GraphNode } from '../../../graph/schema/nodes.js'; -import { renderNodeContext } from './node.js'; - -const neighborhood: NeighborhoodResult = { - status: 'success', - anchor: node( - 1, - 'intent', - 'requirement', - 'Selected spec has graph truth', - 'A long body explains the requirement.', - ), - neighbors: [ - node(2, 'design', 'module', 'Graph snapshot reader'), - node(3, 'oracle', 'check', 'Prompt path test'), - ], - edges: [ - { - id: 5, - specId: 1, - category: 'realization', - sourceId: 2, - targetId: 1, - basis: 'explicit', - rationale: 'The reader supplies typed selected-spec data to context renderers.', - createdAtLsn: 5, - updatedAtLsn: 5, - }, - ], -}; - -describe('renderNodeContext', () => { - it('renders anchor, neighbors, and relevant edges with bounded output', () => { - const rendered = renderNodeContext(neighborhood, { maxNeighbors: 1, maxEdges: 1 }); - - expect(rendered).toContain('[Selected-spec node context]'); - expect(rendered).toContain('- anchor: [R1] intent/requirement: Selected spec has graph truth'); - 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('M2 -[realization]-> R1'); - }); - - it('renders a clear selected-spec missing-node result', () => { - expect(renderNodeContext({ status: 'not_found' })).toBe( - '[Selected-spec node context]\n- node: not found in selected spec', - ); - }); -}); - -function node( - id: number, - plane: GraphNode['plane'], - kind: GraphNode['kind'], - title: string, - body?: string, -): GraphNode { - return { - id, - specId: 1, - plane, - kind, - kindOrdinal: id, - title, - ...(body ? { body } : {}), - basis: 'explicit', - createdAtLsn: id, - updatedAtLsn: id, - }; -} diff --git a/src/.pi/agents/contexts/node.ts b/src/.pi/agents/contexts/node.ts index 314a8c0a2..58690896d 100644 --- a/src/.pi/agents/contexts/node.ts +++ b/src/.pi/agents/contexts/node.ts @@ -1,23 +1,10 @@ -import type { NeighborhoodResult } from '../../../graph/queries.js'; -import { projectNeighborhood } from '../../../projections/graph/neighborhood.js'; -import { formatNeighborhood } from '../../../renderers/graph/neighborhood.js'; +import type { NodeNeighborhood } from '../../../graph/queries.js'; +import { formatNeighborhood } from '../../../renderers/graph/node-neighborhood.js'; export interface RenderNodeContextOptions { - readonly maxNeighbors?: number; readonly maxEdges?: number; } -const DEFAULT_MAX_NEIGHBORS = 6; -const DEFAULT_MAX_EDGES = 8; - -export function renderNodeContext( - result: NeighborhoodResult, - options: RenderNodeContextOptions = {}, -): string { - return formatNeighborhood( - projectNeighborhood(result, { - maxNeighbors: options.maxNeighbors ?? DEFAULT_MAX_NEIGHBORS, - maxEdges: options.maxEdges ?? DEFAULT_MAX_EDGES, - }), - ); +export function renderNodeContext(result: NodeNeighborhood, options: RenderNodeContextOptions = {}): string { + return formatNeighborhood(result, options); } diff --git a/src/.pi/agents/index.ts b/src/.pi/agents/index.ts index 503f0bbff..71e6213a0 100644 --- a/src/.pi/agents/index.ts +++ b/src/.pi/agents/index.ts @@ -3,8 +3,6 @@ export { type AgentPromptSpecContext, type AgentPromptContextBundle, type AgentPromptWorkspaceContext, - type ComposeAgentPromptInput, - type ComposeAgentPromptResult, } from './compose.js'; export { AGENT_PROMPT_DEFINITIONS, @@ -13,18 +11,10 @@ export { METHOD_RESOURCES, STRATEGY_RESOURCES, manifestsForState, - type AgentPromptDefinition, - type MethodId, - type PromptManifests, - type PromptResourceManifestEntry, - type ReadinessGrade, } from './state.js'; export { renderCwdContext, renderGraphContext, renderNodeContext, type AgentPromptSessionContext, - type RenderCwdContextInput, - type RenderGraphContextOptions, - type RenderNodeContextOptions, } from './contexts/index.js'; diff --git a/src/.pi/agents/state.ts b/src/.pi/agents/state.ts index 8a4dde3c1..9e021ba93 100644 --- a/src/.pi/agents/state.ts +++ b/src/.pi/agents/state.ts @@ -13,7 +13,7 @@ import { import type { AgentGoalId, AgentLensId, AgentRoleId, AgentStrategyId } from '../../session/runtime-state.js'; export type { ReadinessGrade }; -export type PromptResourceFamily = 'goals' | 'strategies' | 'lenses' | 'methods' | 'definitions'; +type PromptResourceFamily = 'goals' | 'strategies' | 'lenses' | 'methods' | 'definitions'; export type MethodId = | 'run-structured-exchange' | 'infer-and-capture' diff --git a/src/.pi/brunch-pi-extensions.ts b/src/.pi/brunch-pi-extensions.ts index c90bfe585..bc437c77e 100644 --- a/src/.pi/brunch-pi-extensions.ts +++ b/src/.pi/brunch-pi-extensions.ts @@ -1,5 +1,6 @@ import { type ExtensionAPI, type ExtensionFactory } from '@earendil-works/pi-coding-agent'; +import { formatGraphNodeCode } from '../graph/schema/nodes.js'; import { registerBrunchAlternatives } from './components/alternatives.js'; import { registerBrunchChrome } from './extensions/chrome/index.js'; import { type BrunchChromeState } from './extensions/chrome/index.js'; @@ -9,10 +10,7 @@ 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'; -import { - FIXTURE_GRAPH_MENTION_SOURCE, - registerBrunchMentionAutocomplete, -} from './extensions/mentions/index.js'; +import { registerBrunchMentionAutocomplete } from './extensions/mentions/index.js'; import { registerBrunchOperationalModePolicy } from './extensions/runtime/index.js'; import { registerBrunchSessionBoundary } from './extensions/session/lifecycle.js'; import { type BrunchSessionBoundaryHandler } from './extensions/session/lifecycle.js'; @@ -23,11 +21,7 @@ import { export { registerBrunchAlternatives } from './components/alternatives.js'; export { BRUNCH_BRANCH_FLOW_BLOCKED_MESSAGE } from './extensions/commands/policy.js'; -export { - registerBrunchMentionAutocomplete, - type GraphMentionCandidate, - type GraphMentionSource, -} from './extensions/mentions/index.js'; +export { registerBrunchMentionAutocomplete } from './extensions/mentions/index.js'; export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, DEFAULT_BRUNCH_AGENT_STATE, @@ -36,21 +30,6 @@ export { appendBrunchAgentRuntimeSwitch, projectBrunchAgentState, registerBrunchOperationalModePolicy, - type AgentGoalSelection, - type AgentGoalId, - type AgentLensId, - type AgentLensSelection, - type AgentRoleDefinition, - type AgentRoleId, - type AgentStrategyId, - type AgentStrategySelection, - type AutoAxisSelection, - type BrunchAgentState, - type BrunchAgentStateEntryData, - type BrunchAgentStateEntrySessionManager, - type OperationalModeDefinition, - type OperationalModeId, - type ResolvedBrunchAgentState, } from './extensions/runtime/index.js'; export { registerBrunchPrompting } from './extensions/system-prompts/index.js'; export { registerBrunchContext } from './extensions/context/index.js'; @@ -59,12 +38,7 @@ export { projectBrunchChromeFooterLines, registerBrunchChrome, renderBrunchChrome, - type BrunchChromeCoherenceVerdict, - type BrunchChromeFooterTelemetry, - type BrunchChromeStage, type BrunchChromeState, - type BrunchChromeUi, - type BrunchChromeWorkerStatus, } from './extensions/chrome/index.js'; export { bindBrunchSessionBoundary, @@ -81,15 +55,10 @@ export { BRUNCH_SWITCH_COMMAND, BRUNCH_SWITCH_SHORTCUT, registerBrunchCommands, - type BrunchCommandsOptions, } from './extensions/commands/index.js'; -export { - runBrunchWorkspaceAction, - runBrunchWorkspaceCommand, - type BrunchSpecSessionPickerOptions, -} from './extensions/workspace/index.js'; +export { runBrunchWorkspaceAction, runBrunchWorkspaceCommand } from './extensions/workspace/index.js'; -export { registerBrunchGraph, type BrunchGraphDeps, type GraphReaders } from './extensions/graph/index.js'; +export { registerBrunchGraph } from './extensions/graph/index.js'; export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions { graphMentionSource?: GraphMentionSource; @@ -99,13 +68,26 @@ export interface BrunchPiExtensionsOptions extends BrunchCommandsOptions { type BrunchProductExtensionRegistrar = (pi: ExtensionAPI) => void | Promise; +function graphMentionSourceFromDeps(graph: BrunchGraphDeps | undefined): GraphMentionSource { + if (!graph) return { listMentionCandidates: () => [] }; + return { + listMentionCandidates: () => + graph.reads.queryGraph().nodes.map((node) => ({ + code: formatGraphNodeCode(node.kind, node.kindOrdinal), + title: node.title, + plane: node.plane, + ...(node.body ? { description: node.body } : {}), + })), + }; +} + export function createBrunchPiExtensions( chrome: BrunchChromeState, onSessionBoundary: BrunchSessionBoundaryHandler | undefined, options: BrunchPiExtensionsOptions, ): ExtensionFactory { return async (pi) => { - const graphMentionSource = options.graphMentionSource ?? FIXTURE_GRAPH_MENTION_SOURCE; + const graphMentionSource = options.graphMentionSource ?? graphMentionSourceFromDeps(options.graph); const promptContext = options.promptContext; const extensions: BrunchProductExtensionRegistrar[] = [ (api) => registerBrunchSessionBoundary(api, onSessionBoundary), diff --git a/src/.pi/components/alternatives.ts b/src/.pi/components/alternatives.ts index 6b47bd13d..aac177cdf 100644 --- a/src/.pi/components/alternatives.ts +++ b/src/.pi/components/alternatives.ts @@ -185,5 +185,3 @@ export function registerBrunchAlternatives(pi: ExtensionAPI) { }, }); } - -export default registerBrunchAlternatives; diff --git a/src/.pi/components/brunch-identity.ts b/src/.pi/components/brunch-identity.ts index 58ae854f4..83d2d41c8 100644 --- a/src/.pi/components/brunch-identity.ts +++ b/src/.pi/components/brunch-identity.ts @@ -13,8 +13,8 @@ const LOGO_240 = 'brunch-logo-quad-56x18-240.ansi'; // Letterform copied from: cfonts "brunch" -f tiny -c candy. export const BRUNCH_COMPACT_WORDMARK = ['█▄▄ █▀█ █ █ █▄ █ █▀▀ █ █', '█▄█ █▀▄ █▄█ █ ▀█ █▄▄ █▀█'] as const; -export type BrunchIdentityColorMode = 'dark' | 'light' | 'plain'; -export type BrunchIdentityTheme = Pick; +type BrunchIdentityColorMode = 'dark' | 'light' | 'plain'; +type BrunchIdentityTheme = Pick; export interface BrunchVersionInfo { version: string; diff --git a/src/.pi/components/workspace-dialog.ts b/src/.pi/components/workspace-dialog.ts index a04377084..93ec8c73d 100644 --- a/src/.pi/components/workspace-dialog.ts +++ b/src/.pi/components/workspace-dialog.ts @@ -1,5 +1 @@ -export { - createWorkspaceDialogComponent, - runWorkspaceDialogPreflight, - type WorkspaceDialogComponentOptions, -} from './workspace-dialog/index.js'; +export { createWorkspaceDialogComponent, runWorkspaceDialogPreflight } from './workspace-dialog/index.js'; diff --git a/src/.pi/components/workspace-dialog/index.ts b/src/.pi/components/workspace-dialog/index.ts index c5c332e3d..558615829 100644 --- a/src/.pi/components/workspace-dialog/index.ts +++ b/src/.pi/components/workspace-dialog/index.ts @@ -1,14 +1,3 @@ -export { - WORKSPACE_DIALOG_WIDTH, - createWorkspaceDialogComponent, - type WorkspaceDialogComponentOptions, -} from './component.js'; -export { - buildWorkspaceSelectionView, - selectWorkspaceSelectionOption, - type WorkspaceSelectionOption, - type WorkspaceSelectionResult, - type WorkspaceSelectionStage, - type WorkspaceSelectionView, -} from './model.js'; +export { WORKSPACE_DIALOG_WIDTH, createWorkspaceDialogComponent } from './component.js'; +export { buildWorkspaceSelectionView, selectWorkspaceSelectionOption } from './model.js'; export { runWorkspaceDialogPreflight } from './preflight.js'; diff --git a/src/.pi/components/workspace-dialog/model.ts b/src/.pi/components/workspace-dialog/model.ts index 982d3bf6c..071863743 100644 --- a/src/.pi/components/workspace-dialog/model.ts +++ b/src/.pi/components/workspace-dialog/model.ts @@ -20,7 +20,7 @@ export type WorkspaceSelectionStage = specId: number; }; -export interface WorkspaceSelectionOption { +interface WorkspaceSelectionOption { id: string; label: string; description: string; diff --git a/src/.pi/extensions/chrome/index.ts b/src/.pi/extensions/chrome/index.ts index 86db40cb2..4bb2cd102 100644 --- a/src/.pi/extensions/chrome/index.ts +++ b/src/.pi/extensions/chrome/index.ts @@ -24,16 +24,16 @@ import type { WorkspaceSessionReadyState, } from '../../../session/workspace-session-coordinator.js'; -export type BrunchChromeStage = 'idle' | 'streaming' | 'observer-review'; -export type BrunchChromeWorkerStatus = 'idle' | 'queued' | 'running' | 'blocked'; -export type BrunchChromeCoherenceVerdict = 'unknown' | 'coherent' | 'needs_review' | 'incoherent'; +type BrunchChromeStage = 'idle' | 'streaming' | 'observer-review'; +type BrunchChromeWorkerStatus = 'idle' | 'queued' | 'running' | 'blocked'; +type BrunchChromeCoherenceVerdict = 'unknown' | 'coherent' | 'needs_review' | 'incoherent'; -export interface BrunchChromeContextUsage { +interface BrunchChromeContextUsage { usedTokens: number; maxTokens: number; } -export interface BrunchChromeRuntimeState { +interface BrunchChromeRuntimeState { bundle?: string; role?: string; model?: string; @@ -43,18 +43,18 @@ export interface BrunchChromeRuntimeState { lens?: AgentLensSelection; } -export interface BrunchChromeBuildState { +interface BrunchChromeBuildState { version?: string; dev?: string; } -export interface BrunchChromeLiveContextUsage { +interface BrunchChromeLiveContextUsage { tokens?: number | null; contextWindow?: number | null; percent?: number | null; } -export interface BrunchChromeModelTelemetry { +interface BrunchChromeModelTelemetry { id: string; provider?: string; reasoning?: boolean; diff --git a/src/.pi/extensions/commands/index.ts b/src/.pi/extensions/commands/index.ts index 0fb58713e..65be5f692 100644 --- a/src/.pi/extensions/commands/index.ts +++ b/src/.pi/extensions/commands/index.ts @@ -95,5 +95,3 @@ export function registerBrunchCommands(pi: ExtensionAPI, { coordinator }: Brunch }, }); } - -export default registerBrunchCommands; diff --git a/src/.pi/extensions/commands/policy.ts b/src/.pi/extensions/commands/policy.ts index adefc9f73..e52ea20c7 100644 --- a/src/.pi/extensions/commands/policy.ts +++ b/src/.pi/extensions/commands/policy.ts @@ -13,5 +13,3 @@ export function registerBrunchBranchPolicyHandlers(pi: ExtensionAPI): void { return { cancel: true }; }); } - -export default registerBrunchBranchPolicyHandlers; diff --git a/src/.pi/extensions/context/index.ts b/src/.pi/extensions/context/index.ts index 8a0db62fa..0d0772f30 100644 --- a/src/.pi/extensions/context/index.ts +++ b/src/.pi/extensions/context/index.ts @@ -93,8 +93,6 @@ export function registerBrunchContext(pi: ExtensionAPI): void { }); } -export default registerBrunchContext; - function projectSessionContext( sessionManager: SessionManagerLike | undefined, ): RuntimeStateProjection | SessionRuntimeFrameRenderInput { diff --git a/src/.pi/extensions/exchanges/index.ts b/src/.pi/extensions/exchanges/index.ts index 3250fc484..130fefd44 100644 --- a/src/.pi/extensions/exchanges/index.ts +++ b/src/.pi/extensions/exchanges/index.ts @@ -17,7 +17,6 @@ export { buildStructuredExchangeEditorPrefill, parseStructuredExchangeEditorResponse, structuredExchangeResultFromEditor, - type StructuredExchangeEditorPrefillParams, } from './shared/editor-fallback.js'; export { findIncompleteStructuredExchangePresents, @@ -28,10 +27,8 @@ export { STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA as STRUCTURED_EXCHANGE_PRESENT_SCHEMA, STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA as STRUCTURED_EXCHANGE_REQUEST_SCHEMA, type PresentDetails as StructuredExchangePresentDetails, - type PresentToolName, type RequestDetails as StructuredExchangeRequestDetails, type RequestDetails as StructuredExchangeToolResultDetails, - type RequestToolName, } from './schemas/index.js'; export { PRESENT_CANDIDATES_TOOL, @@ -72,5 +69,3 @@ export function registerStructuredExchange(pi: ExtensionAPI, deps: StructuredExc pi.registerTool(tool); } } - -export default registerStructuredExchange; diff --git a/src/.pi/extensions/exchanges/schemas/capture.ts b/src/.pi/extensions/exchanges/schemas/capture.ts index 4754e27ee..78a986fe0 100644 --- a/src/.pi/extensions/exchanges/schemas/capture.ts +++ b/src/.pi/extensions/exchanges/schemas/capture.ts @@ -14,7 +14,6 @@ export const zCaptureAnswerDetails = zCaptureDetailsHeader tool_meta: zCaptureAnswerToolMeta, }) .strict(); -export type CaptureAnswerDetails = z.infer; export const CaptureAnswerDetailsSchema = z.toJSONSchema(zCaptureAnswerDetails, { unrepresentable: 'throw' }); export const zCaptureChoiceDetails = zCaptureDetailsHeader @@ -22,7 +21,6 @@ export const zCaptureChoiceDetails = zCaptureDetailsHeader tool_meta: zCaptureChoiceToolMeta, }) .strict(); -export type CaptureChoiceDetails = z.infer; export const CaptureChoiceDetailsSchema = z.toJSONSchema(zCaptureChoiceDetails, { unrepresentable: 'throw' }); export const zCaptureChoicesDetails = zCaptureDetailsHeader @@ -30,7 +28,6 @@ export const zCaptureChoicesDetails = zCaptureDetailsHeader tool_meta: zCaptureChoicesToolMeta, }) .strict(); -export type CaptureChoicesDetails = z.infer; export const CaptureChoicesDetailsSchema = z.toJSONSchema(zCaptureChoicesDetails, { unrepresentable: 'throw', }); @@ -40,7 +37,6 @@ export const zCaptureReviewDetails = zCaptureDetailsHeader tool_meta: zCaptureReviewToolMeta, }) .strict(); -export type CaptureReviewDetails = z.infer; export const CaptureReviewDetailsSchema = z.toJSONSchema(zCaptureReviewDetails, { unrepresentable: 'throw' }); export const zCaptureCandidateDetails = zCaptureDetailsHeader @@ -48,7 +44,6 @@ export const zCaptureCandidateDetails = zCaptureDetailsHeader tool_meta: zCaptureCandidateToolMeta, }) .strict(); -export type CaptureCandidateDetails = z.infer; export const CaptureCandidateDetailsSchema = z.toJSONSchema(zCaptureCandidateDetails, { unrepresentable: 'throw', }); @@ -60,7 +55,6 @@ export const zCaptureDetails = z.union([ zCaptureReviewDetails, zCaptureCandidateDetails, ]); -export type CaptureDetails = z.infer; export const CaptureDetailsSchema = z.toJSONSchema(zCaptureDetails, { unrepresentable: 'throw', }); diff --git a/src/.pi/extensions/exchanges/schemas/present.ts b/src/.pi/extensions/exchanges/schemas/present.ts index c8db5ab27..d889c545c 100644 --- a/src/.pi/extensions/exchanges/schemas/present.ts +++ b/src/.pi/extensions/exchanges/schemas/present.ts @@ -12,7 +12,6 @@ import { } from './shared.js'; export const zPresentDisplay = zDisplayBase.extend({ preface: zMarkdown.optional() }).strict(); -export type PresentDisplay = z.infer; export const PresentDisplaySchema = z.toJSONSchema(zPresentDisplay, { unrepresentable: 'throw', }); @@ -35,7 +34,6 @@ export const zPresentOption = z rationale: zMarkdown.optional(), }) .strict(); -export type PresentOption = z.infer; export const PresentOptionSchema = z.toJSONSchema(zPresentOption, { unrepresentable: 'throw', }); @@ -56,7 +54,6 @@ export const zReviewSetEndpointRef = z.union([ z.object({ draft_id: z.string().min(1) }).strict(), z.object({ existing_code: z.string().min(1) }).strict(), ]); -export type ReviewSetEndpointRef = z.infer; export const ReviewSetEndpointRefSchema = z.toJSONSchema(zReviewSetEndpointRef, { unrepresentable: 'throw', }); @@ -71,7 +68,6 @@ export const zReviewSetNodeDraft = z detail: z.unknown().optional(), }) .strict(); -export type ReviewSetNodeDraft = z.infer; export const ReviewSetNodeDraftSchema = z.toJSONSchema(zReviewSetNodeDraft, { unrepresentable: 'throw', }); @@ -85,7 +81,6 @@ export const zReviewSetEdgeDraft = z rationale: zMarkdown.optional(), }) .strict(); -export type ReviewSetEdgeDraft = z.infer; export const ReviewSetEdgeDraftSchema = z.toJSONSchema(zReviewSetEdgeDraft, { unrepresentable: 'throw', }); @@ -124,7 +119,6 @@ export const zCandidateUserRubric = z recommendation: zMarkdown.optional(), }) .strict(); -export type CandidateUserRubric = z.infer; export const CandidateUserRubricSchema = z.toJSONSchema(zCandidateUserRubric, { unrepresentable: 'throw', }); @@ -137,7 +131,6 @@ export const zCandidateMetaRubric = z commitment: zMarkdown.optional(), }) .strict(); -export type CandidateMetaRubric = z.infer; export const CandidateMetaRubricSchema = z.toJSONSchema(zCandidateMetaRubric, { unrepresentable: 'throw', }); @@ -151,7 +144,6 @@ export const zPresentedCandidate = z graph_refs: z.array(zGraphNodeRef), }) .strict(); -export type PresentedCandidate = z.infer; export const PresentedCandidateSchema = z.toJSONSchema(zPresentedCandidate, { unrepresentable: 'throw', }); @@ -163,7 +155,6 @@ export const zPresentCandidatesDetails = zPresentDetailsHeader candidates: z.array(zPresentedCandidate).min(1), }) .strict(); -export type PresentCandidatesDetails = z.infer; export const PresentCandidatesDetailsSchema = z.toJSONSchema(zPresentCandidatesDetails, { unrepresentable: 'throw', }); diff --git a/src/.pi/extensions/exchanges/schemas/request.ts b/src/.pi/extensions/exchanges/schemas/request.ts index a87fdf2b9..1189f6ca2 100644 --- a/src/.pi/extensions/exchanges/schemas/request.ts +++ b/src/.pi/extensions/exchanges/schemas/request.ts @@ -18,7 +18,6 @@ export const zCancelledOutcome = z .strict(), }) .strict(); -export type CancelledOutcome = z.infer; export const CancelledOutcomeSchema = z.toJSONSchema(zCancelledOutcome, { unrepresentable: 'throw', }); @@ -32,13 +31,11 @@ export const zUnavailableOutcome = z .strict(), }) .strict(); -export type UnavailableOutcome = z.infer; export const UnavailableOutcomeSchema = z.toJSONSchema(zUnavailableOutcome, { unrepresentable: 'throw', }); export const zChoiceKind = z.enum(['listed', 'other', 'none']); -export type ChoiceKind = z.infer; export const ChoiceKindSchema = z.toJSONSchema(zChoiceKind, { unrepresentable: 'throw', }); @@ -74,7 +71,6 @@ const zChoiceAnsweredPayload = z } }); export const zRequestChoiceAnswered = zChoiceAnsweredPayload; -export type RequestChoiceAnswered = z.infer; const zChoicesAnsweredPayload = z .object({ @@ -95,7 +91,6 @@ const zChoicesAnsweredPayload = z } }); export const zRequestChoicesAnswered = zChoicesAnsweredPayload; -export type RequestChoicesAnswered = z.infer; export const zRequestAnswerDetails = z.union([ zRequestDetailsHeader @@ -173,7 +168,6 @@ export const RequestChoicesDetailsSchema = z.toJSONSchema(zRequestChoicesDetails }); export const zReviewDecision = z.enum(['approve', 'request_changes', 'reject']); -export type ReviewDecision = z.infer; export const ReviewDecisionSchema = z.toJSONSchema(zReviewDecision, { unrepresentable: 'throw', }); @@ -201,7 +195,6 @@ const zReviewAnsweredPayload = z.union([ .strict(), ]); export const zRequestReviewAnswered = zReviewAnsweredPayload; -export type RequestReviewAnswered = z.infer; export const zRequestReviewDetails = z.union([ zRequestDetailsHeader diff --git a/src/.pi/extensions/exchanges/schemas/shared.ts b/src/.pi/extensions/exchanges/schemas/shared.ts index 4048bb563..a31540ce2 100644 --- a/src/.pi/extensions/exchanges/schemas/shared.ts +++ b/src/.pi/extensions/exchanges/schemas/shared.ts @@ -6,11 +6,9 @@ export const STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA = 'brunch.structured_exc export const STRUCTURED_EXCHANGE_DETAILS_VERSION = 1 as const; export const zMarkdown = z.string(); -export type Markdown = z.infer; export const MarkdownSchema = z.toJSONSchema(zMarkdown, { unrepresentable: 'throw' }); export const zGraphNodeRef = z.object({ node_id: z.string().min(1) }).strict(); -export type GraphNodeRef = z.infer; export const GraphNodeRefSchema = z.toJSONSchema(zGraphNodeRef, { unrepresentable: 'throw' }); export const zPresentToolName = z.enum([ @@ -19,7 +17,6 @@ export const zPresentToolName = z.enum([ 'present_review_set', 'present_candidates', ]); -export type PresentToolName = z.infer; export const PresentToolNameSchema = z.toJSONSchema(zPresentToolName, { unrepresentable: 'throw' }); export const zRequestToolName = z.enum([ @@ -28,7 +25,6 @@ export const zRequestToolName = z.enum([ 'request_choices', 'request_review', ]); -export type RequestToolName = z.infer; export const RequestToolNameSchema = z.toJSONSchema(zRequestToolName, { unrepresentable: 'throw' }); export const zCaptureToolName = z.enum([ @@ -38,7 +34,6 @@ export const zCaptureToolName = z.enum([ 'capture_review', 'capture_candidate', ]); -export type CaptureToolName = z.infer; export const CaptureToolNameSchema = z.toJSONSchema(zCaptureToolName, { unrepresentable: 'throw' }); const zDetailsHeaderFields = { @@ -49,23 +44,19 @@ const zDetailsHeaderFields = { export const zPresentDetailsHeader = z .object({ schema: z.literal(STRUCTURED_EXCHANGE_PRESENT_DETAILS_SCHEMA), ...zDetailsHeaderFields }) .strict(); -export type PresentDetailsHeader = z.infer; export const PresentDetailsHeaderSchema = z.toJSONSchema(zPresentDetailsHeader, { unrepresentable: 'throw' }); export const zRequestDetailsHeader = z .object({ schema: z.literal(STRUCTURED_EXCHANGE_REQUEST_DETAILS_SCHEMA), ...zDetailsHeaderFields }) .strict(); -export type RequestDetailsHeader = z.infer; export const RequestDetailsHeaderSchema = z.toJSONSchema(zRequestDetailsHeader, { unrepresentable: 'throw' }); export const zCaptureDetailsHeader = z .object({ schema: z.literal(STRUCTURED_EXCHANGE_CAPTURE_DETAILS_SCHEMA), ...zDetailsHeaderFields }) .strict(); -export type CaptureDetailsHeader = z.infer; export const CaptureDetailsHeaderSchema = z.toJSONSchema(zCaptureDetailsHeader, { unrepresentable: 'throw' }); export const zDisplayBase = z.object({ heading: z.string().min(1), body: zMarkdown.optional() }).strict(); -export type DisplayBase = z.infer; export const DisplayBaseSchema = z.toJSONSchema(zDisplayBase, { unrepresentable: 'throw' }); export const zPresentQuestionToolMeta = z @@ -87,7 +78,6 @@ export const zPresentToolMeta = z.discriminatedUnion('curr', [ zPresentReviewSetToolMeta, zPresentCandidatesToolMeta, ]); -export type PresentToolMeta = z.infer; export const PresentToolMetaSchema = z.toJSONSchema(zPresentToolMeta, { unrepresentable: 'throw' }); export const zRequestAnswerToolMeta = z @@ -137,7 +127,6 @@ export const zRequestToolMeta = z.union([ zRequestChoicesToolMeta, zRequestReviewToolMeta, ]); -export type RequestToolMeta = z.infer; export const RequestToolMetaSchema = z.toJSONSchema(zRequestToolMeta, { unrepresentable: 'throw' }); export const zCaptureAnswerToolMeta = z @@ -163,5 +152,4 @@ export const zCaptureToolMeta = z.union([ zCaptureReviewToolMeta, zCaptureCandidateToolMeta, ]); -export type CaptureToolMeta = z.infer; export const CaptureToolMetaSchema = z.toJSONSchema(zCaptureToolMeta, { unrepresentable: 'throw' }); diff --git a/src/.pi/extensions/exchanges/shared/editor-fallback.ts b/src/.pi/extensions/exchanges/shared/editor-fallback.ts index 3be1d89fa..b72a79452 100644 --- a/src/.pi/extensions/exchanges/shared/editor-fallback.ts +++ b/src/.pi/extensions/exchanges/shared/editor-fallback.ts @@ -4,9 +4,9 @@ import { formatRequestChoice } from '../../../../renderers/exchanges/request-cho import { formatRequestChoices } from '../../../../renderers/exchanges/request-choices.js'; import type { SelectedChoice } from '../schemas/index.js'; -export type StructuredExchangeMode = 'single-select' | 'multi-select'; +type StructuredExchangeMode = 'single-select' | 'multi-select'; -export interface StructuredExchangeOption { +interface StructuredExchangeOption { label: string; value: string; description?: string; diff --git a/src/.pi/extensions/graph/command-adapter.ts b/src/.pi/extensions/graph/command-adapter.ts index 6e6c05f56..42f90d93e 100644 --- a/src/.pi/extensions/graph/command-adapter.ts +++ b/src/.pi/extensions/graph/command-adapter.ts @@ -19,7 +19,7 @@ import type { Diagnostic, StructuralIllegal, } from '../../../graph/command-executor.js'; -import type { GraphOverview, NeighborhoodResult, RelatedNodesResult } from '../../../graph/queries.js'; +import type { GraphSlice, NodeNeighborhood } from '../../../graph/queries.js'; import { formatGraphNodeCode, parseGraphNodeCode } from '../../../graph/schema/nodes.js'; import type { ToolCommitGraphParams } from './tool-schemas.js'; @@ -143,15 +143,15 @@ export function formatStructuralIllegal(result: StructuralIllegal): string { // --------------------------------------------------------------------------- /** - * Format a GraphOverview as readable text for the agent. + * Format a GraphSlice as readable text for the agent. */ -export function formatGraphOverview(overview: GraphOverview, heading = 'Graph overview'): string { - if (overview.nodeCount === 0) { +export function formatGraphOverview(overview: GraphSlice, heading = 'Graph overview'): string { + if (overview.nodes.length === 0) { return `${heading}: empty (no nodes or edges).`; } const lines: string[] = [ - `${heading} (LSN ${overview.lsn}): ${overview.nodeCount} node(s), ${overview.edgeCount} edge(s).`, + `${heading} (LSN ${overview.lsn}): ${overview.nodes.length} node(s), ${overview.edges.length} edge(s).`, '', ]; const nodesById = new Map(overview.nodes.map((node) => [node.id, node])); @@ -178,44 +178,9 @@ export function formatGraphOverview(overview: GraphOverview, heading = 'Graph ov return lines.join('\n'); } -/** - * Format a NeighborhoodResult as readable text for the agent. - */ -export function formatNeighborhoodResult(result: NeighborhoodResult): string { - if (result.status === 'not_found') { - return 'Node not found.'; - } - - const { anchor, neighbors, edges } = result; - const nodesById = new Map([[anchor.id, anchor], ...neighbors.map((node) => [node.id, node] as const)]); - const lines: string[] = [ - `Neighborhood of [${formatGraphNodeCode(anchor.kind, anchor.kindOrdinal)}] ${anchor.plane}/${anchor.kind}: "${anchor.title}"`, - ]; - - if (anchor.body) { - lines.push(`Body: ${anchor.body}`); - } - - if (neighbors.length > 0) { - lines.push('', 'Neighbors:'); - for (const n of neighbors) { - lines.push(` - [${formatGraphNodeCode(n.kind, n.kindOrdinal)}] ${n.plane}/${n.kind}: "${n.title}"`); - } - } - - if (edges.length > 0) { - lines.push('', 'Edges:'); - for (const e of edges) { - const stance = e.stance ? ` (${e.stance})` : ''; - const source = nodesById.get(e.sourceId); - const target = nodesById.get(e.targetId); - const sourceCode = source ? formatGraphNodeCode(source.kind, source.kindOrdinal) : `#${e.sourceId}`; - const targetCode = target ? formatGraphNodeCode(target.kind, target.kindOrdinal) : `#${e.targetId}`; - lines.push(` - #${e.id}: ${sourceCode} —[${e.category}${stance}]→ ${targetCode}`); - } - } - - return lines.join('\n'); +export interface RelatedNodesResult { + readonly status: 'success' | 'not_found'; + readonly anchors?: readonly NodeNeighborhood[]; } export function formatRelatedNodesResult(result: RelatedNodesResult): string { @@ -223,38 +188,44 @@ export function formatRelatedNodesResult(result: RelatedNodesResult): string { 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 anchors = result.anchors ?? []; + const found = anchors.filter( + (anchor): anchor is Extract => anchor.status === 'found', + ); + const related = new Map(found.flatMap((anchor) => anchor.related.map((node) => [node.id, node] as const))); + const edges = found.flatMap((anchor) => anchor.edges); + const nodesById = new Map([...found.map((anchor) => [anchor.node.id, anchor.node] as const), ...related]); 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(', ')}`, + `Related nodes: ${related.size} node(s), ${edges.length} edge(s).`, + `Anchors: ${found.map((anchor) => `[${formatGraphNodeCode(anchor.node.kind, anchor.node.kindOrdinal)}] ${anchor.node.title}`).join(', ')}`, ]; - if (result.relatedNodes.length === 0) { + if (related.size === 0) { lines.push('Related: none'); } else { lines.push('Related:'); - for (const node of result.relatedNodes) { + for (const node of related.values()) { lines.push( ` - [${formatGraphNodeCode(node.kind, node.kindOrdinal)}] ${node.plane}/${node.kind}: "${node.title}"`, ); } } - if (result.edges.length === 0) { + if (edges.length === 0) { lines.push('Edges: none'); } else { lines.push('Edges:'); - for (const edge of result.edges) { + const anchorIds = new Set(found.map((anchor) => anchor.node.id)); + for (const edge of 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) + const direction = anchorIds.has(edge.sourceId) ? 'outgoing' - : 'incoming'; + : anchorIds.has(edge.targetId) + ? 'incoming' + : 'lateral'; lines.push(` - ${sourceCode} -[${edge.category}/${direction}]-> ${targetCode}`); } } diff --git a/src/.pi/extensions/graph/index.ts b/src/.pi/extensions/graph/index.ts index f0825df72..238d075f4 100644 --- a/src/.pi/extensions/graph/index.ts +++ b/src/.pi/extensions/graph/index.ts @@ -1,28 +1,20 @@ -/** - * Graph tool registrar — wires commit_graph and read_graph as Pi tools. - * - * SPEC: D4-L (one mutation surface), D20-L (CommandExecutor boundary), - * D52-L (graph/ imports db/; .pi/extensions/ imports graph/), - * 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 graph reads passed as explicit - * dependencies from the extension shell. - */ +/** Graph tool registrar — wires commit_graph and read_graph as Pi tools. */ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import type { CommandExecutor } from '../../../graph/command-executor.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'; + EdgeCategory, + EdgeDirection, + GraphFilter, + GraphSlice, + GraphVisibility, + NodeKind, + NodeNeighborhood, + NodeSelector, + ReadinessBand, +} from '../../../graph/index.js'; +import { formatNeighborhood } from '../../../renderers/graph/node-neighborhood.js'; import { graphMutationProductUpdates, type ProductUpdatePublisher } from '../../../rpc/product-updates.js'; import { translateCommitGraph, @@ -33,43 +25,15 @@ import { } from './command-adapter.js'; import { CommitGraphParams, ReadGraphParams } from './tool-schemas.js'; -// --------------------------------------------------------------------------- -// Dependencies injected by the extension shell -// --------------------------------------------------------------------------- - -/** 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 queryGraph: (filter?: GraphFilter, options?: { visibility?: GraphVisibility }) => GraphSlice; + readonly getNodes: ( + selectors: readonly NodeSelector[], + options?: { hops?: number; visibility?: GraphVisibility }, + ) => readonly NodeNeighborhood[]; readonly resolveNodeCode: (code: string) => number | undefined; } -/** - * Selected-spec-bound dependencies for the Brunch graph extension. - * - * The shell pre-binds these to the workspace's active spec (D61-L) so the - * agent-facing `commit_graph` / `read_graph` tools never receive `specId` - * from the LLM and cannot reach into another spec's graph truth. - */ export interface BrunchGraphDeps { readonly specId: number; readonly commandExecutor: CommandExecutor; @@ -77,14 +41,9 @@ export interface BrunchGraphDeps { readonly productUpdates?: ProductUpdatePublisher; } -// --------------------------------------------------------------------------- -// Registrar -// --------------------------------------------------------------------------- - export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): void { const { commandExecutor, reads } = deps; - // ── commit_graph ──────────────────────────────────────────────────── pi.registerTool({ name: 'commit_graph', label: 'Commit Graph', @@ -112,14 +71,10 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo deps.productUpdates?.publish(graphMutationProductUpdates({ specId, lsn: result.lsn })); } - return { - content: [{ type: 'text' as const, text }], - details: result, - }; + return { content: [{ type: 'text' as const, text }], details: result }; }, }); - // ── read_graph ────────────────────────────────────────────────────── pi.registerTool({ name: 'read_graph', label: 'Read Graph', @@ -131,44 +86,36 @@ 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 'list_by_kind' and one or more kinds to inspect a bounded graph slice.", + "Use read_graph with mode 'list_by_band' and readiness bands (grounding, elicitation, commitment) to inspect spec evidence by 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.", + "Set show to 'all' when you need superseded nodes; otherwise the default 'active' hides superseded nodes and dangling edges.", ], parameters: ReadGraphParams, async execute(_toolCallId, params) { + const options = params.show === undefined ? undefined : { visibility: params.show }; let text: string; let details: - | GraphOverview - | NeighborhoodResult - | RelatedNodesResult + | GraphSlice + | readonly NodeNeighborhood[] | { readonly status: 'structural_illegal'; readonly diagnostics: readonly { readonly field: string; readonly message: string }[]; }; if (params.mode === 'overview') { - const overview = reads.getGraphOverview( - params.projection != null ? { projection: params.projection } : undefined, - ); - text = formatGraphOverview(overview); - details = overview; + const slice = reads.queryGraph(undefined, options); + text = formatGraphOverview(slice); + details = slice; } 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; + const slice = reads.queryGraph({ kinds: params.kinds as readonly NodeKind[] }, options); + text = formatGraphOverview(slice, 'Graph slice by kind'); + details = slice; } 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; + const slice = reads.queryGraph({ bands: params.readinessBands as readonly ReadinessBand[] }, options); + text = formatGraphOverview(slice, 'Graph slice by readiness band'); + details = slice; } else if (params.mode === 'gaps') { const hasBaseFilter = (params.kinds?.length ?? 0) > 0 || (params.readinessBands?.length ?? 0) > 0; if (!hasBaseFilter) { @@ -186,38 +133,30 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo details = { status: 'structural_illegal', diagnostics: [ - { - field: 'absentEdgeCategory', - message: 'absentEdgeCategory is required for gaps mode', - }, + { 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; + const filter: GraphFilter = { + ...(params.kinds != null ? { kinds: params.kinds as readonly NodeKind[] } : {}), + ...(params.readinessBands != null + ? { bands: params.readinessBands as readonly ReadinessBand[] } + : {}), + lacksEdge: { + categories: [params.absentEdgeCategory], + ...(params.direction !== undefined ? { direction: params.direction } : {}), + }, + }; + const slice = reads.queryGraph(filter, options); + text = formatGraphOverview(slice, 'Graph gaps'); + details = slice; } } 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) { + if ((params.anchorCodes?.length ?? 0) === 0) { 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`, - })), + diagnostics: [{ field: 'anchorCodes', message: 'related mode requires anchorCodes' }], }; text = formatStructuralIllegal(details); } else if (params.edgeCategory == null) { @@ -227,15 +166,16 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo }; 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 } : {}), + const anchorCodes = params.anchorCodes ?? []; + const readsForAnchors = reads.getNodes( + anchorCodes.map((code) => ({ code })), + { ...options, hops: params.hops ?? 1 }, + ); + text = formatRelatedNodesResult({ + status: 'success', + anchors: filterNodeNeighborhoodEdges(readsForAnchors, params.edgeCategory, params.direction), }); - text = formatRelatedNodesResult(related); - details = related; + details = readsForAnchors; } } else if (params.nodeCode == null) { details = { @@ -244,37 +184,40 @@ export function registerBrunchGraph(pi: ExtensionAPI, deps: BrunchGraphDeps): vo }; text = formatStructuralIllegal(details); } else { - const nodeId = reads.resolveNodeCode(params.nodeCode); - if (nodeId === undefined) { - details = { - status: 'structural_illegal', - diagnostics: [ - { - field: 'nodeCode', - message: `nodeCode ${params.nodeCode} does not resolve in the selected spec`, - }, - ], - }; - text = formatStructuralIllegal(details); - } else { - const neighborhood = reads.getNodeNeighborhood( - nodeId, - 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; - } + const nodeRead = reads.getNodes([{ code: params.nodeCode }], { + ...options, + hops: params.hops ?? 1, + }); + text = formatNeighborhood( + nodeRead[0] ?? { selector: { code: params.nodeCode }, status: 'not_found', related: [], edges: [] }, + ); + details = nodeRead; } - return { - content: [{ type: 'text' as const, text }], - details, - }; + return { content: [{ type: 'text' as const, text }], details }; }, }); } + +function filterNodeNeighborhoodEdges( + neighborhoods: readonly NodeNeighborhood[], + category: EdgeCategory, + direction: EdgeDirection | undefined, +): readonly NodeNeighborhood[] { + return neighborhoods.map((neighborhood) => { + if (neighborhood.status === 'not_found') return neighborhood; + const edges = neighborhood.edges.filter((edge) => { + if (edge.category !== category) return false; + if (direction === 'outgoing') return edge.sourceId === neighborhood.node.id; + if (direction === 'incoming') return edge.targetId === neighborhood.node.id; + return true; + }); + const relatedIds = new Set(edges.flatMap((edge) => [edge.sourceId, edge.targetId])); + relatedIds.delete(neighborhood.node.id); + return { + ...neighborhood, + related: neighborhood.related.filter((node) => relatedIds.has(node.id)), + edges, + }; + }); +} diff --git a/src/.pi/extensions/graph/tool-schemas.ts b/src/.pi/extensions/graph/tool-schemas.ts index cd1e1e55c..832bbc0aa 100644 --- a/src/.pi/extensions/graph/tool-schemas.ts +++ b/src/.pi/extensions/graph/tool-schemas.ts @@ -16,8 +16,8 @@ import { INTENT_KINDS, ORACLE_KINDS, PLAN_KINDS, - type GraphProjection, - type RelatedDirection, + type EdgeDirection, + type GraphVisibility, } from '../../../graph/index.js'; const ALL_KINDS = [...INTENT_KINDS, ...ORACLE_KINDS, ...DESIGN_KINDS, ...PLAN_KINDS] as const; @@ -87,9 +87,9 @@ export const ReadGraphParams = { required: ['mode'], properties: { 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)', + show: { + enum: ['active', 'all'] satisfies readonly GraphVisibility[], + description: 'Graph visibility to read (default: active)', }, nodeCode: { type: 'string', @@ -117,7 +117,7 @@ export const ReadGraphParams = { description: 'Edge category to follow in related mode', }, direction: { - enum: ['outgoing', 'incoming', 'both'] satisfies readonly RelatedDirection[], + enum: ['outgoing', 'incoming', 'both'] satisfies readonly EdgeDirection[], description: 'Traversal direction for related or gaps mode (default: both)', }, absentEdgeCategory: { @@ -129,40 +129,4 @@ export const ReadGraphParams = { '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 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/mentions/index.ts b/src/.pi/extensions/mentions/index.ts index 1f896751b..0fc4dd28b 100644 --- a/src/.pi/extensions/mentions/index.ts +++ b/src/.pi/extensions/mentions/index.ts @@ -1,7 +1,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; import type { AutocompleteItem, AutocompleteSuggestions } from '@earendil-works/pi-tui'; -export interface GraphMentionCandidate { +interface GraphMentionCandidate { code: string; title: string; description?: string; @@ -16,29 +16,6 @@ const EMPTY_GRAPH_MENTION_SOURCE: GraphMentionSource = { listMentionCandidates: () => [], }; -export const FIXTURE_GRAPH_MENTION_SOURCE: GraphMentionSource = { - listMentionCandidates: () => [ - { - code: 'D12', - title: 'Transcript-native structured prompts', - description: 'Structured elicitation prompt/response entries stay visible in Pi JSONL.', - plane: 'design', - }, - { - code: 'I9', - title: 'Mention ledger uses stable handles', - description: 'Inserted # handles are transcript text; labels are UI-only.', - plane: 'intent', - }, - { - code: 'A10', - title: 'Persistent TUI chrome seam', - description: 'Brunch chrome renders through Pi UI primitives without forking Pi.', - plane: 'intent', - }, - ], -}; - export function registerBrunchMentionAutocomplete( pi: ExtensionAPI, source: GraphMentionSource = EMPTY_GRAPH_MENTION_SOURCE, @@ -123,5 +100,3 @@ function candidateToAutocompleteItem(candidate: GraphMentionCandidate): Autocomp ...(candidate.description !== undefined ? { description: candidate.description } : {}), }; } - -export default registerBrunchMentionAutocomplete; diff --git a/src/.pi/extensions/runtime/index.ts b/src/.pi/extensions/runtime/index.ts index 0a2513a4b..1529e0a54 100644 --- a/src/.pi/extensions/runtime/index.ts +++ b/src/.pi/extensions/runtime/index.ts @@ -28,30 +28,14 @@ export { projectBrunchAgentState, type ResolvedBrunchAgentState, } from '../../../projections/session/runtime-state.js'; -export type { - AgentRoleDefinition, - OperationalModeDefinition, -} from '../../../projections/session/runtime-policy.js'; + export { BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE, appendBrunchAgentRuntimeInit, appendBrunchAgentRuntimeSwitch, - type AgentGoalId, - type AgentGoalSelection, - type AgentLensId, - type AgentLensSelection, - type AgentRoleId, - type AgentStrategyId, - type AgentStrategySelection, - type AutoAxisSelection, type BrunchAgentState, type BrunchAgentStateEntryData, type BrunchAgentStateEntrySessionManager, - type ModelPreference, - type OperationalModeId, - type PromptPackId, - type ThinkingLevel, - type ToolPolicyId, } from '../../../session/runtime-state.js'; import { projectBrunchAgentState, @@ -312,5 +296,3 @@ export function registerBrunchOperationalModePolicy(pi: ExtensionAPI) { }; }); } - -export default registerBrunchOperationalModePolicy; diff --git a/src/.pi/extensions/session/lifecycle.ts b/src/.pi/extensions/session/lifecycle.ts index 898ca8b67..b264ea706 100644 --- a/src/.pi/extensions/session/lifecycle.ts +++ b/src/.pi/extensions/session/lifecycle.ts @@ -32,5 +32,3 @@ export function registerBrunchSessionBoundary( }); registerBrunchSessionBoundaryRefreshHandlers(pi, onSessionBoundary); } - -export default registerBrunchSessionBoundary; diff --git a/src/.pi/extensions/system-prompts/index.ts b/src/.pi/extensions/system-prompts/index.ts index 2d5333fcd..16a9148bb 100644 --- a/src/.pi/extensions/system-prompts/index.ts +++ b/src/.pi/extensions/system-prompts/index.ts @@ -26,7 +26,7 @@ interface BeforeAgentStartContextLike { sessionManager?: SessionManagerLike; } -export interface BrunchPromptContext { +interface BrunchPromptContext { spec: AgentPromptSpecContext; workspace: AgentPromptWorkspaceContext; session?: AgentPromptSessionContext; @@ -91,9 +91,7 @@ function contextForPrompt( }), ]; if (context.graphReads) { - renderedContexts.push( - renderGraphContext(context.graphReads.getGraphOverview(), { lens: state.agentLens }), - ); + renderedContexts.push(renderGraphContext(context.graphReads.queryGraph(), { lens: state.agentLens })); } return { @@ -107,5 +105,3 @@ async function resolvePromptContext( ): Promise { return typeof promptContext === 'function' ? promptContext() : promptContext; } - -export default registerBrunchPrompting; diff --git a/src/app/brunch-tui.test.ts b/src/app/brunch-tui.test.ts index 76994a2d5..c99213103 100644 --- a/src/app/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -152,11 +152,11 @@ describe('Brunch TUI boot', () => { ); const graph = await openWorkspaceGraphRuntime(cwd); - expect(graph.forSpec(first.spec.id).getGraphOverview().nodeCount).toBe(0); + expect(graph.forSpec(first.spec.id).queryGraph().nodes).toHaveLength(0); expect( graph .forSpec(second.spec.id) - .getGraphOverview() + .queryGraph() .nodes.map((node) => node.title), ).toEqual(['Second current goal']); expect(observedUpdates).toEqual([ @@ -876,14 +876,42 @@ describe('Brunch TUI boot', () => { expect(commands).toEqual([]); }); - it('wires the fixture graph-code mention source through the Brunch shell', async () => { + it('wires live selected-spec graph nodes through the Brunch shell mention source', async () => { let providerFactory: ((current: FakeAutocompleteProvider) => FakeAutocompleteProvider) | undefined; const sessionStart: Array<(event: unknown, ctx: FakeExtensionContext) => Promise | void> = []; await createBrunchPiExtensions( chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-1')), undefined, - { coordinator: noOpWorkspaceCoordinator('/tmp/project') }, + { + coordinator: noOpWorkspaceCoordinator('/tmp/project'), + graph: { + specId: 1, + commandExecutor: {} as never, + reads: { + queryGraph: () => ({ + nodes: [ + { + id: 1, + specId: 1, + plane: 'intent', + kind: 'goal', + kindOrdinal: 1, + title: 'Live selected-spec goal', + body: 'Graph-backed candidate', + basis: 'explicit', + createdAtLsn: 1, + updatedAtLsn: 1, + }, + ], + edges: [], + lsn: 1, + }), + getNodes: () => [], + resolveNodeCode: () => undefined, + }, + }, + }, )({ on: (event: string, handler: never) => { if (event === 'session_start') sessionStart.push(handler); @@ -928,7 +956,69 @@ describe('Brunch TUI boot', () => { await expect(provider?.getSuggestions(['Discuss #'], 0, 9, {} as never)).resolves.toMatchObject({ prefix: '#', - items: expect.arrayContaining([expect.objectContaining({ value: '#D12' })]), + items: [ + expect.objectContaining({ + value: '#G1', + label: '#G1 Live selected-spec goal', + description: 'Graph-backed candidate', + }), + ], + }); + }); + + it('does not fall back to dummy mention candidates when graph deps are absent', async () => { + let providerFactory: ((current: FakeAutocompleteProvider) => FakeAutocompleteProvider) | undefined; + const sessionStart: Array<(event: unknown, ctx: FakeExtensionContext) => Promise | void> = []; + + await createBrunchPiExtensions( + chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-1')), + undefined, + { coordinator: noOpWorkspaceCoordinator('/tmp/project') }, + )({ + on: (event: string, handler: never) => { + if (event === 'session_start') sessionStart.push(handler); + }, + registerCommand: (_name: string, _options: unknown) => {}, + registerShortcut: (_name: string, _options: unknown) => {}, + registerTool: (_tool: unknown) => {}, + registerMessageRenderer: (_type: string) => {}, + sendMessage: (_message: unknown) => {}, + getAllTools: () => [], + setActiveTools: (_tools: string[]) => {}, + } as never); + + const ctx: FakeExtensionContext = { + sessionManager: { + getEntries: () => [], + } as unknown as FakeExtensionContext['sessionManager'], + ui: { + setHeader: (_factory) => {}, + setFooter: (_factory) => {}, + setStatus: (_key, _text) => {}, + setWidget: (_key: string, _content: unknown) => {}, + setWorkingIndicator: (_options) => {}, + setTitle: (_title: string) => {}, + notify: (_message: string, _type?: 'info' | 'warning' | 'error') => {}, + addAutocompleteProvider: (factory: typeof providerFactory) => { + providerFactory = factory; + }, + } as FakeExtensionUi & { + addAutocompleteProvider: (factory: typeof providerFactory) => void; + }, + }; + + for (const handler of sessionStart) await handler({}, ctx); + + const fallback: FakeAutocompleteProvider = { + getSuggestions: async () => ({ items: [], prefix: '' }), + applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }), + shouldTriggerFileCompletion: () => true, + }; + const provider = providerFactory?.(fallback); + + await expect(provider?.getSuggestions(['Discuss #'], 0, 9, {} as never)).resolves.toEqual({ + prefix: '#', + items: [], }); }); diff --git a/src/app/brunch-tui.ts b/src/app/brunch-tui.ts index 9e0e73c40..e4a3b8d3d 100644 --- a/src/app/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -13,7 +13,14 @@ import { import { chromeStateForWorkspace, createBrunchPiExtensions } from '../.pi/brunch-pi-extensions.js'; import { applyBrunchOfflineDefault, createBrunchPiSettings } from '../.pi/brunch-pi-settings.js'; import { runWorkspaceDialogPreflight } from '../.pi/components/workspace-dialog.js'; -import { openWorkspaceGraphRuntime } from '../graph/index.js'; +import { + openWorkspaceGraphRuntime, + type EdgeCategory, + type GraphSlice, + type NodeKind, + type ReadinessBand, + type WorkspaceGraphRuntime, +} from '../graph/index.js'; import { createProductUpdatePublisher, type ProductUpdatePublisher } from '../rpc/product-updates.js'; import { startWebHost, type RunningWebHost } from '../rpc/web-host.js'; import { @@ -39,24 +46,19 @@ export { createBrunchPiExtensions, projectBrunchChromeFooterLines, renderBrunchChrome, - type BrunchChromeCoherenceVerdict, - type BrunchChromeFooterTelemetry, - type BrunchChromeStage, - type BrunchChromeState, - type BrunchChromeWorkerStatus, } from '../.pi/brunch-pi-extensions.js'; export { runWorkspaceDialogPreflight } from '../.pi/components/workspace-dialog.js'; -export type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator; +type BrunchTuiCoordinator = SpecSessionActivationCoordinator & WorkspaceSessionBoundaryCoordinator; -export interface BrunchWebSidecarRunnerOptions { +interface BrunchWebSidecarRunnerOptions { cwd: string; coordinator: BrunchTuiCoordinator; productUpdates: ProductUpdatePublisher; routePath: string; } -export type BrunchWebSidecar = Pick; +type BrunchWebSidecar = Pick; export interface BrunchTuiLaunchContext { workspace: WorkspaceSessionReadyState; @@ -133,6 +135,61 @@ async function chooseSpecSessionActivationDecision( return runWorkspaceDialogPreflight(inventory); } +type EdgeCompatibleNodeKinds = readonly NodeKind[]; +type EdgeCompatibleReadinessBands = readonly ReadinessBand[]; + +type LegacyGraphOptions = { readonly show?: 'active' | 'all' } | undefined; + +function toReadOptions(options: LegacyGraphOptions): { readonly visibility?: 'active' | 'all' } | undefined { + return options?.show === undefined ? undefined : { visibility: options.show }; +} + +function graphSliceWithCounts(slice: GraphSlice) { + return { ...slice, nodeCount: slice.nodes.length, edgeCount: slice.edges.length }; +} + +function isPresent(value: T | undefined): value is T { + return value !== undefined; +} + +function legacyRelatedNodes( + readers: ReturnType, + options: { + readonly anchorIds: readonly number[]; + readonly edgeCategory: EdgeCategory; + readonly direction?: 'outgoing' | 'incoming' | 'both'; + readonly hops?: number; + readonly show?: 'active' | 'all'; + }, +) { + const results = readers.getNodes( + options.anchorIds.map((id) => ({ id })), + { ...toReadOptions(options), hops: options.hops ?? 1 }, + ); + if (results.some((result) => result.status === 'not_found')) { + return { status: 'not_found' as const }; + } + const anchors = results + .map((result) => (result.status === 'found' ? result.node : undefined)) + .filter(isPresent); + const edges = results + .flatMap((result) => (result.status === 'found' ? result.edges : [])) + .filter((edge) => edge.category === options.edgeCategory); + const relatedIds = new Set(edges.flatMap((edge) => [edge.sourceId, edge.targetId])); + for (const anchor of anchors) relatedIds.delete(anchor.id); + const relatedNodesById = new Map( + results.flatMap((result) => + result.status === 'found' ? result.related.map((node) => [node.id, node] as const) : [], + ), + ); + return { + status: 'success' as const, + anchors, + relatedNodes: [...relatedIds].map((id) => relatedNodesById.get(id)).filter(isPresent), + edges, + }; +} + export function createBrunchAgentSessionRuntimeFactory({ coordinator, productUpdates, @@ -146,53 +203,85 @@ export function createBrunchAgentSessionRuntimeFactory({ }, commandExecutor: graph.commandExecutor, 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), + queryGraph: ( + filter?: Parameters['queryGraph']>[0], + options?: Parameters['queryGraph']>[1], + ) => graph.forSpec(currentWorkspace.spec.id).queryGraph(filter, options), + getOverview: (options?: { show?: 'active' | 'all' }) => + graphSliceWithCounts( + graph.forSpec(currentWorkspace.spec.id).queryGraph(undefined, toReadOptions(options)), + ), + getGraphOverview: (options?: { show?: 'active' | 'all' }) => + graphSliceWithCounts( + graph.forSpec(currentWorkspace.spec.id).queryGraph(undefined, toReadOptions(options)), + ), + getGraphSliceByKinds: (options: { show?: 'active' | 'all'; kinds: readonly string[] }) => + graphSliceWithCounts( + graph + .forSpec(currentWorkspace.spec.id) + .queryGraph({ kinds: options.kinds as EdgeCompatibleNodeKinds }, toReadOptions(options)), + ), getGraphSliceByReadinessBands: (options: { - projection?: 'active_context' | 'graph_truth'; + show?: 'active' | 'all'; readinessBands: readonly string[]; - }) => graph.forSpec(currentWorkspace.spec.id).getGraphSliceByReadinessBands(options), + }) => + graphSliceWithCounts( + graph + .forSpec(currentWorkspace.spec.id) + .queryGraph( + { bands: options.readinessBands as EdgeCompatibleReadinessBands }, + toReadOptions(options), + ), + ), getGraphGaps: (options: { - projection?: 'active_context' | 'graph_truth'; + show?: 'active' | 'all'; kinds?: readonly string[]; readinessBands?: readonly string[]; - absentEdgeCategory: - | 'dependency' - | 'proof' - | 'support' - | 'realization' - | 'boundary' - | 'composition' - | 'association' - | 'supersession'; + absentEdgeCategory: EdgeCategory; direction?: 'outgoing' | 'incoming' | 'both'; - }) => graph.forSpec(currentWorkspace.spec.id).getGraphGaps(options), + }) => + graphSliceWithCounts( + graph.forSpec(currentWorkspace.spec.id).queryGraph( + { + ...(options.kinds != null ? { kinds: options.kinds as EdgeCompatibleNodeKinds } : {}), + ...(options.readinessBands != null + ? { bands: options.readinessBands as EdgeCompatibleReadinessBands } + : {}), + lacksEdge: { + categories: [options.absentEdgeCategory], + ...(options.direction !== undefined ? { direction: options.direction } : {}), + }, + }, + toReadOptions(options), + ), + ), getRelatedNodes: (options: { anchorIds: readonly number[]; - edgeCategory: - | 'dependency' - | 'proof' - | 'support' - | 'realization' - | 'boundary' - | 'composition' - | 'association' - | 'supersession'; + edgeCategory: EdgeCategory; 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), + show?: 'active' | 'all'; + }) => legacyRelatedNodes(graph.forSpec(currentWorkspace.spec.id), options), + getNodes: ( + selectors: readonly ({ id: number } | { code: string })[], + options?: { hops?: number; show?: 'active' | 'all' }, + ) => graph.forSpec(currentWorkspace.spec.id).getNodes(selectors, toReadOptions(options)), + getNodeNeighborhood: (nodeId: number, options?: { hops?: number; show?: 'active' | 'all' }) => { + const [result] = graph + .forSpec(currentWorkspace.spec.id) + .getNodes([{ id: nodeId }], { ...toReadOptions(options), hops: options?.hops ?? 1 }); + return !result || result.status === 'not_found' + ? { status: 'not_found' as const } + : { + status: 'success' as const, + anchor: result.node, + neighbors: result.related, + edges: result.edges, + }; + }, resolveNodeCode: (code: string) => graph.forSpec(currentWorkspace.spec.id).resolveNodeCode(code), }, - ...(productUpdates ? { productUpdates } : {}), + ...(productUpdates && { productUpdates }), }; const bindCurrentWorkspace = async (replacementSessionManager: typeof sessionManager) => { currentWorkspace = await coordinator.bindCurrentSpecToReplacementSession(replacementSessionManager); diff --git a/src/graph/README.md b/src/graph/README.md index 7b72f3e80..21e7bf538 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -34,16 +34,20 @@ SPEC decisions: D4-L, D20-L, D27-L, D51-L, D52-L, D53-L, D54-L, D60-L, D62-L, D6 open elicitation-backlog entries. These return typed domain objects or internal ids, not Drizzle rows. -- **Preview harness helpers** (`render-preview.ts`) — deterministic fixture-seed - + selected-spec read helpers for render-preview scripts/tests that need real - graph data without bypassing the command/read seams. - **Domain schema types** (`schema/`) — `GraphNode`, `GraphEdge`, `ReconciliationNeed`, `ElicitationBacklogEntry`, kind/category types, per-kind node ordinals, and derived intent-kind grouping. -- **Policy** (`policy/category-policy.ts`) — edge-category semantics such as - cascade behavior, reconciliation triggers, and projection effects. +- **Policy** (`policy/category-policy.ts`) — the single per-category + metadata table (`EDGE_CATEGORY_METADATA`): endpoint roles, impact + direction/strength (cascade vs advisory), criteria-help signal, and + projection effects. + +- **Projection** (`projection/`) — anchor-relative derivations over the + policy table: `labels.ts` (direction-aware semantic phrasing) and + `direction.ts` (upstream/downstream/lateral for the reconciliation + flow). Pure functions; no DB access. - **Workspace graph runtime** (`workspace-store.ts`) — opens `.brunch/data.db` through `db/connection.ts` and returns a `CommandExecutor` plus bound query @@ -139,8 +143,6 @@ graph/ getOpenReconciliationNeeds row -> domain mapping - render-preview.ts - deterministic seeded-fixture render-preview helpers for scripts/tests workspace-store.ts openWorkspaceGraphRuntime(cwd) @@ -154,6 +156,10 @@ graph/ policy/ category-policy.ts + + projection/ + labels.ts + direction.ts ``` ## Boundary flow diff --git a/src/graph/capture/structured-response.test.ts b/src/graph/capture/structured-response.test.ts index 376df7363..5ba78f987 100644 --- a/src/graph/capture/structured-response.test.ts +++ b/src/graph/capture/structured-response.test.ts @@ -24,7 +24,7 @@ describe('captureStructuredResponseFacts', () => { goal: { id: 11, code: 'G1' }, context: { id: 12, code: 'CTX1' }, constraint: { id: 13, code: 'CON1' }, - criterion: { id: 14, code: 'CR1' }, + criterion: { id: 14, code: 'AC1' }, }, edges: [], }); @@ -51,7 +51,7 @@ describe('captureStructuredResponseFacts', () => { goal: { id: 11, code: 'G1' }, context: { id: 12, code: 'CTX1' }, constraint: { id: 13, code: 'CON1' }, - criterion: { id: 14, code: 'CR1' }, + criterion: { id: 14, code: 'AC1' }, }, }); expect(executor.calls).toEqual([ diff --git a/src/graph/command-executor.ts b/src/graph/command-executor.ts index 0ac0d5912..4e9bf03cb 100644 --- a/src/graph/command-executor.ts +++ b/src/graph/command-executor.ts @@ -48,10 +48,7 @@ export type { CommitGraphInput, CommitGraphResult, CommitGraphSuccess, - CreatedGraphNodeResult, - CreatedGraphNodes, Diagnostic, - DryRunSuccess, StructuralIllegal, } from './command-executor/commit-graph-types.js'; @@ -60,62 +57,62 @@ export type { // --------------------------------------------------------------------------- /** Successful command execution. */ -export interface CommandSuccess { +interface CommandSuccess { readonly status: 'success'; readonly nodeId: number; readonly lsn: number; } /** Action requires human confirmation (M6 placeholder). */ -export interface NeedsHuman { +interface NeedsHuman { readonly status: 'needs_human'; } /** Action blocked by authority policy (M6 placeholder). */ -export interface PolicyBlocked { +interface PolicyBlocked { readonly status: 'policy_blocked'; } /** Optimistic concurrency conflict (M6 placeholder). */ -export interface VersionConflict { +interface VersionConflict { readonly status: 'version_conflict'; } /** Successful reconciliation-need creation. */ -export interface ReconNeedSuccess { +interface ReconNeedSuccess { readonly status: 'success'; readonly id: number; readonly lsn: number; } /** Successful reconciliation-need resolution. */ -export interface ReconNeedResolveSuccess { +interface ReconNeedResolveSuccess { readonly status: 'success'; readonly lsn: number; } /** Successful spec creation. */ -export interface CreateSpecSuccess { +interface CreateSpecSuccess { readonly status: 'success'; readonly specId: number; readonly lsn: number; } /** Successful elicitation-backlog creation. */ -export interface ElicitationBacklogSuccess { +interface ElicitationBacklogSuccess { readonly status: 'success'; readonly id: number; readonly lsn: number; } /** Successful elicitation-backlog close. */ -export interface ElicitationBacklogCloseSuccess { +interface ElicitationBacklogCloseSuccess { readonly status: 'success'; readonly lsn: number; } /** Successful spec readiness-grade update. */ -export interface UpdateReadinessGradeSuccess { +interface UpdateReadinessGradeSuccess { readonly status: 'success'; readonly lsn: number; } @@ -166,7 +163,7 @@ export type CloseElicitationBacklogEntryResult = ElicitationBacklogCloseSuccess export type UpdateReadinessGradeResult = UpdateReadinessGradeSuccess | StructuralIllegal; /** Successful accepted review-set graph batch execution. */ -export interface AcceptReviewSetSuccess extends CommitGraphSuccess {} +interface AcceptReviewSetSuccess extends CommitGraphSuccess {} /** Result of an acceptReviewSet command. */ export type AcceptReviewSetResult = AcceptReviewSetSuccess | StructuralIllegal; @@ -235,20 +232,20 @@ export interface CreateNodeInput { // --------------------------------------------------------------------------- /** Target for a reconciliation need — edge or node pair. */ -export type ReconNeedTargetEdge = { +type ReconNeedTargetEdge = { readonly kind: 'edge'; readonly edgeId: number; }; /** Target for a reconciliation need — node pair. */ -export type ReconNeedTargetNodePair = { +type ReconNeedTargetNodePair = { readonly kind: 'node_pair'; readonly aId: number; readonly bId: number; }; /** Target for a reconciliation need. */ -export type ReconNeedTarget = ReconNeedTargetEdge | ReconNeedTargetNodePair; +type ReconNeedTarget = ReconNeedTargetEdge | ReconNeedTargetNodePair; /** Input for creating a reconciliation need. */ export interface CreateReconNeedInput { diff --git a/src/graph/command-executor/commit-graph-batch.test.ts b/src/graph/command-executor/commit-graph-batch.test.ts index 19f71fc74..94b23c283 100644 --- a/src/graph/command-executor/commit-graph-batch.test.ts +++ b/src/graph/command-executor/commit-graph-batch.test.ts @@ -295,7 +295,7 @@ describe('CommandExecutor commitGraph', () => { expect(result.status).toBe('success'); if (result.status !== 'success') throw new Error('unreachable'); - expect(result.createdNodes).toEqual({ n1: { id: expect.any(Number), code: 'R1' } }); + expect(result.createdNodes).toEqual({ n1: { id: expect.any(Number), code: 'REQ1' } }); }); it('returns one created-node identity shape and edges array in success result', () => { diff --git a/src/graph/command-executor/commit-graph-types.ts b/src/graph/command-executor/commit-graph-types.ts index 5ec19d87b..3d1b98f95 100644 --- a/src/graph/command-executor/commit-graph-types.ts +++ b/src/graph/command-executor/commit-graph-types.ts @@ -13,7 +13,7 @@ export interface StructuralIllegal { } /** Successful dry-run validation without mutation. */ -export interface DryRunSuccess { +interface DryRunSuccess { readonly status: 'success'; } diff --git a/src/graph/export-fixtures.test.ts b/src/graph/export-fixtures.test.ts index 9cb6542f6..d12073ebb 100644 --- a/src/graph/export-fixtures.test.ts +++ b/src/graph/export-fixtures.test.ts @@ -108,7 +108,7 @@ describe('exportSeedFixture', () => { expect(committed.status).toBe('success'); const graphTruth = exportSeedFixture(db, { specId: created.specId }); - const activeContext = exportSeedFixture(db, { specId: created.specId, projection: 'active_context' }); + const activeContext = exportSeedFixture(db, { specId: created.specId, show: 'active' }); expect(graphTruth.nodes.map((node) => node.title)).toEqual(['Old requirement', 'New requirement']); expect(graphTruth.edges).toHaveLength(1); diff --git a/src/graph/export-fixtures.ts b/src/graph/export-fixtures.ts index f077b715a..2184389ac 100644 --- a/src/graph/export-fixtures.ts +++ b/src/graph/export-fixtures.ts @@ -13,23 +13,23 @@ 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 { queryGraph, type GraphVisibility } from './queries.js'; import type { SeedFixture, SeedFixtureEdge, SeedFixtureNode } from './seed-fixtures.js'; export interface ExportSeedFixtureInput { readonly specId: number; /** - * Defaults to graph truth so captured fixtures preserve any superseded + * Defaults to all graph truth so captured fixtures preserve any superseded * predecessors that remain in accepted graph history. */ - readonly projection?: GraphProjection; + readonly show?: GraphVisibility; } export function exportSeedFixture(db: BrunchDb, input: ExportSeedFixtureInput): SeedFixture { const spec = db.select().from(schema.specs).where(eq(schema.specs.id, input.specId)).get(); if (!spec) throw new Error(`exportSeedFixture: spec ${input.specId} does not exist`); - const overview = getGraphOverview(db, input.specId, { projection: input.projection ?? 'graph_truth' }); + const overview = queryGraph(db, input.specId, undefined, { visibility: input.show ?? 'all' }); const orderedNodes = [...overview.nodes].sort((a, b) => a.id - b.id); const localIdByNodeId = new Map(orderedNodes.map((node, index) => [node.id, index + 1])); @@ -51,7 +51,7 @@ export function exportSeedFixture(db: BrunchDb, input: ExportSeedFixtureInput): const targetLocalId = localIdByNodeId.get(edge.targetId); if (sourceLocalId == null || targetLocalId == null) { throw new Error( - `exportSeedFixture: edge ${edge.id} references a node outside the ${input.projection ?? 'graph_truth'} projection`, + `exportSeedFixture: edge ${edge.id} references a node outside the ${input.show ?? 'all'} visibility`, ); } return { @@ -83,14 +83,14 @@ interface CliArgs { readonly workspace: string; readonly specId: number; readonly out?: string; - readonly projection?: GraphProjection; + readonly show?: GraphVisibility; } function parseCliArgs(argv: readonly string[]): CliArgs { let workspace = process.cwd(); let specId: number | undefined; let out: string | undefined; - let projection: GraphProjection | undefined; + let show: GraphVisibility | undefined; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; @@ -101,12 +101,12 @@ function parseCliArgs(argv: readonly string[]): CliArgs { specId = parsePositiveInt(requiredValue(argv, ++index, arg), arg); } else if (arg === '--out' || arg === '-o') { out = requiredValue(argv, ++index, arg); - } else if (arg === '--projection') { + } else if (arg === '--show') { const value = requiredValue(argv, ++index, arg); - if (value !== 'graph_truth' && value !== 'active_context') { - throw new Error('--projection must be graph_truth or active_context'); + if (value !== 'all' && value !== 'active') { + throw new Error('--show must be all or active'); } - projection = value; + show = value; } else if (arg === '--help' || arg === '-h') { throw new UsageRequested(); } else { @@ -119,7 +119,7 @@ function parseCliArgs(argv: readonly string[]): CliArgs { workspace, specId, ...(out === undefined ? {} : { out }), - ...(projection === undefined ? {} : { projection }), + ...(show === undefined ? {} : { show }), }; } @@ -145,7 +145,7 @@ function usage(): string { ' -w, --workspace Brunch workspace directory (default: cwd)', ' --spec-id Spec id to capture', ' -o, --out Output fixture JSON path (default: stdout)', - ' --projection graph_truth | active_context (default: graph_truth)', + ' --show all | active (default: all)', ].join('\n'); } @@ -154,7 +154,7 @@ async function main(): Promise { const db = createDb(join(resolve(args.workspace), '.brunch', 'data.db')); const fixture = exportSeedFixture(db, { specId: args.specId, - ...(args.projection === undefined ? {} : { projection: args.projection }), + ...(args.show === undefined ? {} : { show: args.show }), }); const rendered = formatSeedFixture(fixture); diff --git a/src/graph/fixture-reads.test-support.ts b/src/graph/fixture-reads.test-support.ts new file mode 100644 index 000000000..d4e240c5d --- /dev/null +++ b/src/graph/fixture-reads.test-support.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createDb } from '../db/connection.js'; +import { CommandExecutor } from './command-executor.js'; +import { getNodes, queryGraph, type GraphSlice, type NodeNeighborhood } 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 SeedFixtureRef { + readonly set: string; + readonly fixture: string; +} + +export function readGraphSliceFixture(ref: SeedFixtureRef): GraphSlice { + const { db, specId } = seedSelectedSpec(ref); + return queryGraph(db, specId); +} + +export function readNodeNeighborhoodFixture( + ref: SeedFixtureRef & { readonly anchorCode: string; readonly hops?: number }, +): Extract { + const { db, specId } = seedSelectedSpec(ref); + const result = getNodes(db, specId, [{ code: ref.anchorCode }], { + hops: ref.hops ?? 1, + })[0]; + + if (!result || result.status === 'not_found') { + throw new Error(`Node code "${ref.anchorCode}" not found in ${ref.set}/${ref.fixture}`); + } + + return result; +} + +function seedSelectedSpec(ref: SeedFixtureRef) { + const fixturePath = resolve(SEEDS_ROOT, ref.set, `${ref.fixture}.json`); + const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as SeedFixture; + const db = createDb(':memory:'); + const executor = new CommandExecutor(db); + const seeded = seedFixture(executor, fixture); + + return { db, specId: seeded.specId }; +} diff --git a/src/graph/index.ts b/src/graph/index.ts index 028fd2b78..c4cc04d21 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -8,8 +8,6 @@ * M4 skeleton: CommandExecutor + result types. */ -export type { EdgeId, Lsn, NodeId } from './atoms.js'; - // Re-export shared enum const arrays so extensions can build // tool parameter schemas without importing db/ directly (I26-L). export { @@ -23,130 +21,54 @@ export { READINESS_GRADES, } from '../db/schema.js'; -export type { EdgeBasis, EdgeCategory, EdgeStance, GraphEdge } from './schema/edges.js'; +export type { EdgeCategory, GraphEdge } from './schema/edges.js'; -export type { - DecisionDetail, - DesignKind, - GraphNode, - IntentKind, - IntentKindCategory, - NodeBasis, - NodeKindMetadata, - NodeDetail, - NodeKind, - NodePlane, - OracleKind, - PlanKind, - TermDetail, - ReadinessBand, -} from './schema/nodes.js'; +export type { GraphNode, NodeKind, ReadinessBand } from './schema/nodes.js'; export { formatGraphNodeCode, intentKindCategory, parseGraphNodeCode } from './schema/nodes.js'; +export { EDGE_CATEGORY_METADATA, edgeEndpointRole } from './policy/category-policy.js'; export type { - ReconciliationNeed, - ReconciliationNeedKind, - ReconciliationNeedTarget, -} from './schema/reconciliation-need.js'; - -export type { - ElicitationBacklogEntry, - ElicitationBacklogLensAffinity, - ElicitationBacklogStatus, -} from './schema/elicitation-backlog.js'; - -export { - CATEGORY_POLICY, - type CategoryPolicy, - type ProjectionEffect, - type ReconNeedTrigger, + EdgeCategoryMetadata, + EdgeEndpoint, + EdgeEndpointRole, + EdgeImpactStrength, + ProjectionEffect, } from './policy/category-policy.js'; +export { edgeLabel } from './projection/labels.js'; +export type { AnchorRole, EdgeLabelInput } from './projection/labels.js'; +export { edgeImpact, relationFromAnchor } from './projection/direction.js'; +export type { AnchoredRelation, EdgeImpact, EdgeRelation } from './projection/direction.js'; + export { - getGraphOverview, - getGraphGaps, - getGraphSliceByKinds, - getGraphSliceByReadinessBands, - getRelatedNodes, - getNodeNeighborhood, + queryGraph, + getNodes, getOpenElicitationBacklogEntries, getOpenReconciliationNeeds, } from './queries.js'; export type { - GraphOverview, - GraphOverviewOptions, - GraphGapsOptions, - GraphProjection, - GraphSliceByKindsOptions, - GraphSliceByReadinessBandsOptions, - NeighborhoodOptions, - NeighborhoodNotFound, - NeighborhoodResult, - NeighborhoodSuccess, - RelatedDirection, - RelatedNodesOptions, - RelatedNodesResult, - RelatedNodesSuccess, + EdgeDirection, + GraphSlice, + GraphVisibility, + GraphFilter, + NodeNeighborhood, + NodeSelector, } from './queries.js'; export { CommandExecutor } from './command-executor.js'; export { openWorkspaceCommandExecutor, openWorkspaceGraphRuntime } from './workspace-store.js'; export type { WorkspaceGraphRuntime } from './workspace-store.js'; export type { - AcceptReviewSetInput, - AcceptReviewSetResult, - AcceptReviewSetSuccess, BatchEdgeInput, BatchEdgeRef, BatchNodeInput, - CommandResult, - CommandSuccess, CommitGraphInput, - CommitGraphDryRunResult, - CommitGraphResult, CommitGraphSuccess, - CloseElicitationBacklogEntryInput, - CloseElicitationBacklogEntryResult, - CreateElicitationBacklogEntryInput, - CreateElicitationBacklogEntryResult, - CreateNodeInput, - CreateNodeResult, - CreateReconNeedInput, - CreateSpecInput, - CreateSpecResult, - CreateSpecSuccess, - DryRunSuccess, - ElicitationBacklogCloseSuccess, - ElicitationBacklogSuccess, - CreateReconNeedResult, Diagnostic, - NeedsHuman, - PolicyBlocked, ReadinessGrade, - ReconNeedResolveSuccess, - ReconNeedSuccess, - ReconNeedTarget, - ResolveReconNeedInput, - ResolveReconNeedResult, SpecRecord, StructuralIllegal, - UpdateReadinessGradeInput, - UpdateReadinessGradeResult, - UpdateReadinessGradeSuccess, - VersionConflict, } from './command-executor.js'; export { translateReviewSetPayloadToCommitGraph } from './review-set.js'; -export type { - ReviewSetEdgeDraft, - ReviewSetEndpointRef, - ReviewSetEpistemicStatus, - ReviewSetEntityDraft, - ReviewSetLens, - ReviewSetProposalGrounding, - ReviewSetProposalPayload, - ReviewSetProposalPitch, - ReviewSetTranslationResult, - ReviewSetTranslationSuccess, -} from './review-set.js'; diff --git a/src/graph/policy/category-policy.test.ts b/src/graph/policy/category-policy.test.ts new file mode 100644 index 000000000..080c4481d --- /dev/null +++ b/src/graph/policy/category-policy.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { EDGE_CATEGORIES } from '../../db/schema.js'; +import { EDGE_CATEGORY_METADATA, edgeEndpointRole } from './category-policy.js'; + +const EXPECTED_EDGE_CATEGORY_METADATA = { + dependency: { + sourceRole: 'dependency', + targetRole: 'dependent', + impactOnSourceChange: 'cascade', + impactOnTargetChange: 'none', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + proof: { + sourceRole: 'oracle', + targetRole: 'claim', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', + criteriaHelpSignal: true, + projectionEffect: 'none', + }, + support: { + sourceRole: 'support', + targetRole: 'claim', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + realization: { + sourceRole: 'abstract', + targetRole: 'concrete', + impactOnSourceChange: 'advisory', + impactOnTargetChange: 'none', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + boundary: { + sourceRole: 'boundary', + targetRole: 'subject', + impactOnSourceChange: 'advisory', + impactOnTargetChange: 'none', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + composition: { + sourceRole: 'whole', + targetRole: 'part', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + association: { + sourceRole: 'peer', + targetRole: 'peer', + impactOnSourceChange: 'none', + impactOnTargetChange: 'none', + criteriaHelpSignal: false, + projectionEffect: 'none', + }, + supersession: { + sourceRole: 'successor', + targetRole: 'predecessor', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', + criteriaHelpSignal: false, + projectionEffect: 'hide_predecessor_from_active_context', + }, +} as const satisfies typeof EDGE_CATEGORY_METADATA; + +describe('EDGE_CATEGORY_METADATA', () => { + it('covers every stored edge category exactly once', () => { + expect(Object.keys(EDGE_CATEGORY_METADATA).sort()).toEqual([...EDGE_CATEGORIES].sort()); + expect(EDGE_CATEGORY_METADATA).toEqual(EXPECTED_EDGE_CATEGORY_METADATA); + }); + + it('only dependency drives a hard cascade; reconciling categories are advisory', () => { + for (const [category, metadata] of Object.entries(EDGE_CATEGORY_METADATA)) { + const strengths = [metadata.impactOnSourceChange, metadata.impactOnTargetChange]; + if (category === 'dependency') { + expect(strengths).toContain('cascade'); + } else { + expect(strengths).not.toContain('cascade'); + } + // A well-formed category drives impact in at most one direction. + const driven = strengths.filter((s) => s !== 'none'); + expect(driven.length).toBeLessThanOrEqual(1); + } + }); + + it('maps endpoint geometry to semantic roles', () => { + expect(edgeEndpointRole('dependency', 'source')).toBe('dependency'); + expect(edgeEndpointRole('dependency', 'target')).toBe('dependent'); + expect(edgeEndpointRole('proof', 'source')).toBe('oracle'); + expect(edgeEndpointRole('proof', 'target')).toBe('claim'); + expect(edgeEndpointRole('association', 'source')).toBe('peer'); + expect(edgeEndpointRole('association', 'target')).toBe('peer'); + }); +}); diff --git a/src/graph/policy/category-policy.ts b/src/graph/policy/category-policy.ts index cf8764894..2aa0d40e4 100644 --- a/src/graph/policy/category-policy.ts +++ b/src/graph/policy/category-policy.ts @@ -1,92 +1,153 @@ /** - * Per-edge-category policy table. + * Per-edge-category metadata — the single source of edge-category semantics. * * Canonical reference: docs/design/GRAPH_MODEL.md §"Per-category policy" * - * This table replaces the prior multi-axis per-relation policy - * registry. Only the axes that have a present reader in M4 or M5 - * are encoded here: + * This table is the one place that maps a structural edge `category` to its + * derived semantics. Two projection families read from it: * - * - `cascadeOnSourceChange` — automatic block / mark-stale on the - * dependent (assumption-invalidation - * cascade). Only `dependency` cascades. - * - `reconNeedOnSourceChange` — generate a ReconciliationNeed pointing - * at the edge. `"advisory"` = generated - * only if a coherence rule asks for it; - * `true` = generated unconditionally. - * - `criteriaHelpSignal` — the interviewer uses this edge when - * suggesting criteria for the target - * node ("requirement with no `proof` - * incoming → suggest criterion"). - * - `projectionEffect` — non-default effect on active-context / - * neighborhood builders. `"none"` means - * the edge is rendered ordinarily. + * - **endpoint roles** (`sourceRole` / `targetRole`) feed the semantic-label + * projection (`projection/labels.ts`) — direction-aware phrasing like + * "depends on" / "realizes" rendered from one endpoint's perspective. + * - **impact direction** (`impactOnSourceChange` / `impactOnTargetChange`) + * feeds the directional projection (`projection/direction.ts`) — the + * upstream/downstream grouping the reconciliation flow logs against. * - * Phase 1 lock-and-materialize: data only. The CommandExecutor, - * coherence triggers, projection builders, and interviewer prompts - * consume this table in subsequent M4/M5 slices. + * It supersedes the prior split between the role/reconciliation metadata that + * briefly lived in `schema/edges.ts` and the drifted `CATEGORY_POLICY` that + * lived here (the two disagreed on impact direction for `proof`/`support`). + * + * Axes: + * + * - `sourceRole` / `targetRole` — the semantic role each endpoint plays. + * - `impactOnSourceChange` — if the SOURCE node changes, how is the TARGET + * affected: `cascade` (hard — auto block/mark-stale; dependency only), + * `advisory` (soft — surface a ReconciliationNeed), or `none`. + * - `impactOnTargetChange` — symmetric: if the TARGET node changes, how is + * the SOURCE affected. + * - `criteriaHelpSignal` — the interviewer uses this edge when suggesting + * criteria for the claim ("requirement with no incoming `proof` → suggest + * a criterion"). + * - `projectionEffect` — non-default effect on active-context builders. + * + * Phase 1 lock-and-materialize: data only. Coherence triggers, the + * interviewer, and active-context filters consume this in later M4/M5 slices. */ import type { EdgeCategory } from '../schema/edges.js'; -export type ReconNeedTrigger = false | 'advisory' | true; +/** Which end of a stored edge an endpoint sits on. */ +export type EdgeEndpoint = 'source' | 'target'; + +/** The semantic role an endpoint plays within its category. */ +export type EdgeEndpointRole = + | 'dependency' + | 'dependent' + | 'oracle' + | 'claim' + | 'support' + | 'abstract' + | 'concrete' + | 'boundary' + | 'subject' + | 'whole' + | 'part' + | 'successor' + | 'predecessor' + | 'peer'; + +/** + * How strongly a change at one endpoint impacts the other. + * - `none` — no reconciliation flows in this direction. + * - `advisory` — soft; surface a ReconciliationNeed for review. + * - `cascade` — hard; may auto block / mark-stale (dependency only). + */ +export type EdgeImpactStrength = 'none' | 'advisory' | 'cascade'; export type ProjectionEffect = 'none' | 'hide_predecessor_from_active_context'; -export interface CategoryPolicy { - readonly cascadeOnSourceChange: boolean; - readonly reconNeedOnSourceChange: ReconNeedTrigger; +export interface EdgeCategoryMetadata { + readonly sourceRole: EdgeEndpointRole; + readonly targetRole: EdgeEndpointRole; + readonly impactOnSourceChange: EdgeImpactStrength; + readonly impactOnTargetChange: EdgeImpactStrength; readonly criteriaHelpSignal: boolean; readonly projectionEffect: ProjectionEffect; } -export const CATEGORY_POLICY: Readonly> = { +type EdgeCategoryMetadataByCategory = { + readonly [Category in EdgeCategory]: EdgeCategoryMetadata; +}; + +export const EDGE_CATEGORY_METADATA = { dependency: { - cascadeOnSourceChange: true, - reconNeedOnSourceChange: true, + sourceRole: 'dependency', + targetRole: 'dependent', + impactOnSourceChange: 'cascade', + impactOnTargetChange: 'none', criteriaHelpSignal: false, projectionEffect: 'none', }, proof: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: 'advisory', + sourceRole: 'oracle', + targetRole: 'claim', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', criteriaHelpSignal: true, projectionEffect: 'none', }, support: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: 'advisory', + sourceRole: 'support', + targetRole: 'claim', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', criteriaHelpSignal: false, projectionEffect: 'none', }, realization: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: 'advisory', + sourceRole: 'abstract', + targetRole: 'concrete', + impactOnSourceChange: 'advisory', + impactOnTargetChange: 'none', criteriaHelpSignal: false, projectionEffect: 'none', }, boundary: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: true, + sourceRole: 'boundary', + targetRole: 'subject', + impactOnSourceChange: 'advisory', + impactOnTargetChange: 'none', criteriaHelpSignal: false, projectionEffect: 'none', }, composition: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: false, + sourceRole: 'whole', + targetRole: 'part', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', criteriaHelpSignal: false, projectionEffect: 'none', }, association: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: false, + sourceRole: 'peer', + targetRole: 'peer', + impactOnSourceChange: 'none', + impactOnTargetChange: 'none', criteriaHelpSignal: false, projectionEffect: 'none', }, supersession: { - cascadeOnSourceChange: false, - reconNeedOnSourceChange: false, + sourceRole: 'successor', + targetRole: 'predecessor', + impactOnSourceChange: 'none', + impactOnTargetChange: 'advisory', criteriaHelpSignal: false, projectionEffect: 'hide_predecessor_from_active_context', }, -}; +} as const satisfies EdgeCategoryMetadataByCategory; + +/** The semantic role the given endpoint plays for this category. */ +export function edgeEndpointRole(category: EdgeCategory, endpoint: EdgeEndpoint): EdgeEndpointRole { + const metadata = EDGE_CATEGORY_METADATA[category]; + return endpoint === 'source' ? metadata.sourceRole : metadata.targetRole; +} diff --git a/src/graph/projection/direction.test.ts b/src/graph/projection/direction.test.ts new file mode 100644 index 000000000..837e5717a --- /dev/null +++ b/src/graph/projection/direction.test.ts @@ -0,0 +1,106 @@ +/** + * The full reconciliation-impact matrix, executable. + * + * Direction is derived mechanically from per-category impact metadata, NOT from + * source→target storage geometry: for `dependency`/`realization`/`boundary` the + * source is upstream, but for `proof`/`support`/`composition`/`supersession` the + * target is upstream. Renderers rely on this coverage and never re-derive + * direction themselves. + */ + +import { describe, expect, it } from 'vitest'; + +import type { EdgeEndpoint, EdgeImpactStrength } from '../policy/category-policy.js'; +import type { EdgeCategory } from '../schema/edges.js'; +import { edgeImpact, relationFromAnchor, type EdgeImpact, type EdgeRelation } from './direction.js'; + +interface ImpactCell { + readonly category: EdgeCategory; + readonly impact: EdgeImpact; + /** Neighbor relation + strength when the anchor sits at each endpoint. */ + readonly fromSource: { relation: EdgeRelation; strength: EdgeImpactStrength }; + readonly fromTarget: { relation: EdgeRelation; strength: EdgeImpactStrength }; +} + +const MATRIX: readonly ImpactCell[] = [ + // source upstream → neighbor downstream when anchor is the source. + { + category: 'dependency', + impact: { downstreamEndpoint: 'target', strength: 'cascade' }, + fromSource: { relation: 'downstream', strength: 'cascade' }, + fromTarget: { relation: 'upstream', strength: 'cascade' }, + }, + { + category: 'realization', + impact: { downstreamEndpoint: 'target', strength: 'advisory' }, + fromSource: { relation: 'downstream', strength: 'advisory' }, + fromTarget: { relation: 'upstream', strength: 'advisory' }, + }, + { + category: 'boundary', + impact: { downstreamEndpoint: 'target', strength: 'advisory' }, + fromSource: { relation: 'downstream', strength: 'advisory' }, + fromTarget: { relation: 'upstream', strength: 'advisory' }, + }, + // target upstream → neighbor upstream when anchor is the source. + { + category: 'proof', + impact: { downstreamEndpoint: 'source', strength: 'advisory' }, + fromSource: { relation: 'upstream', strength: 'advisory' }, + fromTarget: { relation: 'downstream', strength: 'advisory' }, + }, + { + category: 'support', + impact: { downstreamEndpoint: 'source', strength: 'advisory' }, + fromSource: { relation: 'upstream', strength: 'advisory' }, + fromTarget: { relation: 'downstream', strength: 'advisory' }, + }, + { + category: 'composition', + impact: { downstreamEndpoint: 'source', strength: 'advisory' }, + fromSource: { relation: 'upstream', strength: 'advisory' }, + fromTarget: { relation: 'downstream', strength: 'advisory' }, + }, + { + category: 'supersession', + impact: { downstreamEndpoint: 'source', strength: 'advisory' }, + fromSource: { relation: 'upstream', strength: 'advisory' }, + fromTarget: { relation: 'downstream', strength: 'advisory' }, + }, + // symmetric → lateral both ways. + { + category: 'association', + impact: { downstreamEndpoint: 'none', strength: 'none' }, + fromSource: { relation: 'lateral', strength: 'none' }, + fromTarget: { relation: 'lateral', strength: 'none' }, + }, +]; + +describe('edgeImpact', () => { + it.each(MATRIX)('$category impact axis', ({ category, impact }) => { + expect(edgeImpact(category)).toEqual(impact); + }); +}); + +describe('relationFromAnchor', () => { + const roles: readonly { + endpoint: EdgeEndpoint; + pick: keyof Pick; + }[] = [ + { endpoint: 'source', pick: 'fromSource' }, + { endpoint: 'target', pick: 'fromTarget' }, + ]; + + for (const { endpoint, pick } of roles) { + it.each(MATRIX)(`$category from ${endpoint} anchor`, (cell) => { + expect(relationFromAnchor(cell.category, endpoint)).toEqual(cell[pick]); + }); + } + + it('the E2 worked example: realizes is upstream, motivated-by is downstream', () => { + // realization(check → evidence): E2 is the concrete target → upstream. + expect(relationFromAnchor('realization', 'target').relation).toBe('upstream'); + // support(requirement → evidence): E2 is the claim target → downstream. + expect(relationFromAnchor('support', 'target').relation).toBe('downstream'); + }); +}); diff --git a/src/graph/projection/direction.ts b/src/graph/projection/direction.ts new file mode 100644 index 000000000..391239a78 --- /dev/null +++ b/src/graph/projection/direction.ts @@ -0,0 +1,66 @@ +/** + * Directional projection — upstream / downstream / lateral. + * + * Canonical reference: docs/design/GRAPH_MODEL.md §"Context projections" + * + * Derives the reconciliation-impact axis from the per-category metadata. + * "Downstream" is the endpoint that needs reconciliation when the other + * endpoint changes; the reconciliation flow logs downstream impacts when a + * node is edited. This axis does NOT track the source→target storage geometry: + * for `dependency`/`realization`/`boundary` the source is upstream, but for + * `proof`/`support`/`composition`/`supersession` the target is upstream. + */ + +import { + EDGE_CATEGORY_METADATA, + type EdgeEndpoint, + type EdgeImpactStrength, +} from '../policy/category-policy.js'; +import type { EdgeCategory } from '../schema/edges.js'; + +/** Where a neighbor sits relative to the anchor along the impact axis. */ +export type EdgeRelation = 'upstream' | 'downstream' | 'lateral'; + +export interface EdgeImpact { + /** The downstream (impacted) endpoint, or `none` for symmetric edges. */ + readonly downstreamEndpoint: EdgeEndpoint | 'none'; + /** Impact strength along that direction. */ + readonly strength: EdgeImpactStrength; +} + +/** + * Which endpoint is downstream of the other, derived from impact metadata. + * A well-formed category drives impact in at most one direction. + */ +export function edgeImpact(category: EdgeCategory): EdgeImpact { + const metadata = EDGE_CATEGORY_METADATA[category]; + if (metadata.impactOnSourceChange !== 'none') { + return { downstreamEndpoint: 'target', strength: metadata.impactOnSourceChange }; + } + if (metadata.impactOnTargetChange !== 'none') { + return { downstreamEndpoint: 'source', strength: metadata.impactOnTargetChange }; + } + return { downstreamEndpoint: 'none', strength: 'none' }; +} + +export interface AnchoredRelation { + readonly relation: EdgeRelation; + /** Impact strength of the relationship (`none` for lateral). */ + readonly strength: EdgeImpactStrength; +} + +/** + * Classify a neighbor relative to the anchor. + * + * @param anchorRole which endpoint of the edge the anchor occupies. + * - anchor at the upstream end → neighbor is `downstream` (changing the + * anchor impacts the neighbor; log for reconciliation). + * - anchor at the downstream end → neighbor is `upstream` (changing the + * neighbor impacts the anchor; review the anchor). + */ +export function relationFromAnchor(category: EdgeCategory, anchorRole: EdgeEndpoint): AnchoredRelation { + const { downstreamEndpoint, strength } = edgeImpact(category); + if (downstreamEndpoint === 'none') return { relation: 'lateral', strength: 'none' }; + const anchorIsDownstream = anchorRole === downstreamEndpoint; + return { relation: anchorIsDownstream ? 'upstream' : 'downstream', strength }; +} diff --git a/src/graph/projection/labels.test.ts b/src/graph/projection/labels.test.ts new file mode 100644 index 000000000..89fd470f0 --- /dev/null +++ b/src/graph/projection/labels.test.ts @@ -0,0 +1,103 @@ +/** + * The full label lookup matrix, executable. + * + * Tier 1 enumerates every `(category, anchorRole, stance)` base cell; Tier 2 + * enumerates every refined `(category, sourceKind, targetKind)` cell. Renderers + * rely on this coverage and never re-assert label text themselves. + */ + +import { describe, expect, it } from 'vitest'; + +import type { EdgeStance } from '../schema/edges.js'; +import type { NodeKind } from '../schema/nodes.js'; +import { edgeLabel } from './labels.js'; +import type { AnchorRole } from './labels.js'; + +interface BaseCell { + readonly category: Parameters[0]['category']; + readonly anchorRole: AnchorRole; + readonly stance?: EdgeStance; + readonly label: string; +} + +// Tier 1 — every base cell, read from the anchor's perspective. +const BASE_MATRIX: readonly BaseCell[] = [ + { category: 'dependency', anchorRole: 'source', label: 'required by' }, + { category: 'dependency', anchorRole: 'target', label: 'depends on' }, + + { category: 'proof', anchorRole: 'source', stance: 'for', label: 'witnesses' }, + { category: 'proof', anchorRole: 'source', stance: 'against', label: 'refutes' }, + { category: 'proof', anchorRole: 'target', stance: 'for', label: 'witnessed by' }, + { category: 'proof', anchorRole: 'target', stance: 'against', label: 'challenged by' }, + + { category: 'support', anchorRole: 'source', stance: 'for', label: 'supports' }, + { category: 'support', anchorRole: 'source', stance: 'against', label: 'argues against' }, + { category: 'support', anchorRole: 'target', stance: 'for', label: 'motivated by' }, + { category: 'support', anchorRole: 'target', stance: 'against', label: 'opposed by' }, + + { category: 'realization', anchorRole: 'source', label: 'realized by' }, + { category: 'realization', anchorRole: 'target', label: 'realizes' }, + + { category: 'boundary', anchorRole: 'source', label: 'bounds' }, + { category: 'boundary', anchorRole: 'target', label: 'bounded by' }, + + { category: 'composition', anchorRole: 'source', label: 'contains' }, + { category: 'composition', anchorRole: 'target', label: 'part of' }, + + { category: 'supersession', anchorRole: 'source', label: 'supersedes' }, + { category: 'supersession', anchorRole: 'target', label: 'superseded by' }, + + { category: 'association', anchorRole: 'source', label: 'related to' }, + { category: 'association', anchorRole: 'target', label: 'related to' }, +]; + +interface RefineCell { + readonly sourceKind: NodeKind; + readonly targetKind: NodeKind; + readonly fromSource: string; + readonly fromTarget: string; +} + +// Tier 2 — every refined realization cell (sourceKind → targetKind). +const REFINE_MATRIX: readonly RefineCell[] = [ + { sourceKind: 'requirement', targetKind: 'module', fromSource: 'implemented by', fromTarget: 'implements' }, + { sourceKind: 'interface', targetKind: 'module', fromSource: 'implemented by', fromTarget: 'implements' }, + { sourceKind: 'requirement', targetKind: 'slice', fromSource: 'established by', fromTarget: 'establishes' }, + { sourceKind: 'invariant', targetKind: 'requirement', fromSource: 'expressed by', fromTarget: 'expresses' }, +]; + +describe('edgeLabel — Tier 1 base matrix', () => { + it.each(BASE_MATRIX)( + '$category/$anchorRole${stance} → $label', + ({ category, anchorRole, stance, label }) => { + expect(edgeLabel({ category, anchorRole, stance })).toBe(label); + }, + ); +}); + +describe('edgeLabel — Tier 2 refinement matrix', () => { + it.each(REFINE_MATRIX)( + 'realization $sourceKind→$targetKind reads as $fromTarget / $fromSource', + ({ sourceKind, targetKind, fromSource, fromTarget }) => { + expect(edgeLabel({ category: 'realization', anchorRole: 'source', sourceKind, targetKind })).toBe( + fromSource, + ); + expect(edgeLabel({ category: 'realization', anchorRole: 'target', sourceKind, targetKind })).toBe( + fromTarget, + ); + }, + ); + + it('applies refinement only when both endpoint kinds are supplied', () => { + expect(edgeLabel({ category: 'realization', anchorRole: 'target' })).toBe('realizes'); + expect(edgeLabel({ category: 'realization', anchorRole: 'target', sourceKind: 'requirement' })).toBe( + 'realizes', + ); + }); + + it('falls back to the base heading for an unrefined kind tuple', () => { + expect( + edgeLabel({ category: 'realization', anchorRole: 'target', sourceKind: 'goal', targetKind: 'context' }), + ).toBe('realizes'); + }); +}); diff --git a/src/graph/projection/labels.ts b/src/graph/projection/labels.ts new file mode 100644 index 000000000..c4dcb8a53 --- /dev/null +++ b/src/graph/projection/labels.ts @@ -0,0 +1,120 @@ +/** + * Semantic-label projection — direction-aware phrasing from one endpoint. + * + * Canonical reference: docs/design/GRAPH_MODEL.md §"Tuple-label lookup" + * + * Produces plain-language headings for an edge read from the anchor's + * perspective ("depends on", "realizes", "motivated by"), so renderers never + * leak the raw structural vocabulary (`category`, endpoint roles) into context. + * + * Two tiers: + * - Tier 1 (base): keyed on `(category, anchorRole, stance)`. ~18 cells; + * covers every edge. + * - Tier 2 (refine): keyed on `(category, sourceKind, targetKind)`. Optional + * finer verbs where the neighbor's kind alone is too vague — primarily the + * realization sub-types. Kept deliberately small. + */ + +import type { EdgeEndpoint } from '../policy/category-policy.js'; +import type { EdgeCategory, EdgeStance } from '../schema/edges.js'; +import type { NodeKind } from '../schema/nodes.js'; + +/** Which endpoint of the edge the anchor occupies. */ +export type AnchorRole = EdgeEndpoint; + +type StanceKey = EdgeStance | 'none'; + +function stanceKey(stance: EdgeStance | undefined): StanceKey { + return stance ?? 'none'; +} + +// Tier 1 — base headings, read from the anchor's perspective. +// The neighbor's kind is rendered separately, so headings never embed it. +const BASE: Record>>> = { + dependency: { + source: { none: 'required by' }, // anchor is the dependency; neighbor depends on it + target: { none: 'depends on' }, // anchor is the dependent + }, + proof: { + source: { for: 'witnesses', against: 'refutes' }, // anchor is the oracle + target: { for: 'witnessed by', against: 'challenged by' }, // anchor is the claim + }, + support: { + source: { for: 'supports', against: 'argues against' }, // anchor is the support + target: { for: 'motivated by', against: 'opposed by' }, // anchor is the claim + }, + realization: { + source: { none: 'realized by' }, // anchor is abstract + target: { none: 'realizes' }, // anchor is concrete + }, + boundary: { + source: { none: 'bounds' }, // anchor is the boundary + target: { none: 'bounded by' }, // anchor is the subject + }, + composition: { + source: { none: 'contains' }, // anchor is the whole + target: { none: 'part of' }, // anchor is a part + }, + supersession: { + source: { none: 'supersedes' }, // anchor is the successor + target: { none: 'superseded by' }, // anchor is the predecessor + }, + association: { + source: { none: 'related to' }, + target: { none: 'related to' }, + }, +}; + +// Tier 2 — finer verbs, oriented (sourceVerb = view from source). +interface Refinement { + readonly sourceVerb: string; + readonly targetVerb: string; +} + +type RefineKey = `${EdgeCategory}|${NodeKind}|${NodeKind}`; + +function refineKey(category: EdgeCategory, sourceKind: NodeKind, targetKind: NodeKind): RefineKey { + return `${category}|${sourceKind}|${targetKind}`; +} + +const REFINE: Partial> = { + [refineKey('realization', 'requirement', 'module')]: { + sourceVerb: 'implemented by', + targetVerb: 'implements', + }, + [refineKey('realization', 'interface', 'module')]: { + sourceVerb: 'implemented by', + targetVerb: 'implements', + }, + [refineKey('realization', 'requirement', 'slice')]: { + sourceVerb: 'established by', + targetVerb: 'establishes', + }, + [refineKey('realization', 'invariant', 'requirement')]: { + sourceVerb: 'expressed by', + targetVerb: 'expresses', + }, +}; + +export interface EdgeLabelInput { + readonly category: EdgeCategory; + readonly anchorRole: AnchorRole; + readonly stance?: EdgeStance | undefined; + /** Endpoint kinds enable Tier-2 refinement; omit to get the base heading. */ + readonly sourceKind?: NodeKind | undefined; + readonly targetKind?: NodeKind | undefined; +} + +/** Plain-language heading for an edge read from the anchor's perspective. */ +export function edgeLabel(input: EdgeLabelInput): string { + const { category, anchorRole, stance, sourceKind, targetKind } = input; + + if (sourceKind && targetKind) { + const refinement = REFINE[refineKey(category, sourceKind, targetKind)]; + if (refinement) { + return anchorRole === 'source' ? refinement.sourceVerb : refinement.targetVerb; + } + } + + return BASE[category][anchorRole][stanceKey(stance)] ?? category; +} diff --git a/src/graph/queries.test.ts b/src/graph/queries.test.ts index b047466fb..a7709b094 100644 --- a/src/graph/queries.test.ts +++ b/src/graph/queries.test.ts @@ -1,27 +1,9 @@ -/** - * Graph read helper tests — acceptance criteria for I35-L. - * - * SPEC: D52-L (graph/ reads db/), I35-L (cursory + neighborhood) - * Scope card: Graph reads at cursory and neighborhood detail levels - * - * All graph state is seeded via CommandExecutor (no direct db writes). - */ - 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, - getOpenElicitationBacklogEntries, - getGraphSliceByKinds, - getGraphSliceByReadinessBands, - getNodeNeighborhood, - getRelatedNodes, - getOpenReconciliationNeeds, -} from './queries.js'; +import { getOpenElicitationBacklogEntries, getOpenReconciliationNeeds } from './queries.js'; import { NODE_KIND_METADATA, parseGraphNodeCode } from './schema/nodes.js'; function createTestDb(): BrunchDb { @@ -38,630 +20,8 @@ describe('graph node code metadata', () => { ); expect(parseGraphNodeCode('A1')).toEqual({ kind: 'assumption', kindOrdinal: 1 }); expect(parseGraphNodeCode('CON2')).toEqual({ kind: 'constraint', kindOrdinal: 2 }); - expect(parseGraphNodeCode('CR3')).toEqual({ kind: 'criterion', kindOrdinal: 3 }); - }); -}); -describe('getGraphOverview', () => { - let db: BrunchDb; - let executor: CommandExecutor; - let specId: number; - - beforeEach(() => { - db = createTestDb(); - executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); - specId = db.select({ id: specs.id }).from(specs).get()!.id; - db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); - }); - - it('returns empty arrays and zero counts on an empty graph', () => { - const overview = getGraphOverview(db, specId); - expect(overview.nodes).toEqual([]); - expect(overview.edges).toEqual([]); - expect(overview.nodeCount).toBe(0); - expect(overview.edgeCount).toBe(0); - expect(overview.lsn).toBe(0); - }); - - it('returns current LSN from graph_clock', () => { - executor.createNode({ specId, plane: 'intent', kind: 'goal', title: 'G1' }); - executor.createNode({ specId, plane: 'intent', kind: 'thesis', title: 'T1' }); - const overview = getGraphOverview(db, specId); - expect(overview.lsn).toBe(2); - }); - - it('returns the selected spec LSN without sibling-spec mutations', () => { - const specA = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); - const specB = executor.createSpec({ name: 'Spec B', slug: 'spec-b' }); - if (specA.status !== 'success' || specB.status !== 'success') throw new Error('unreachable'); - - const before = getGraphOverview(db, specA.specId); - executor.createNode({ specId: specB.specId, plane: 'intent', kind: 'goal', title: 'Spec B goal' }); - const after = getGraphOverview(db, specA.specId); - - expect(before.lsn).toBe(1); - expect(after.lsn).toBe(1); - }); - - it('returns typed domain objects with parsed detail JSON', () => { - executor.createNode({ - specId, - plane: 'intent', - kind: 'decision', - title: 'Use SQLite', - body: 'Settled on SQLite', - detail: { - chosen_option: 'SQLite', - rejected: ['PostgreSQL'], - rationale: 'Simpler local deployment', - }, - }); - - const overview = getGraphOverview(db, specId); - expect(overview.nodes).toHaveLength(1); - const node = overview.nodes[0]!; - expect(node.id).toBeTypeOf('number'); - expect(node.plane).toBe('intent'); - expect(node.kind).toBe('decision'); - expect(node.title).toBe('Use SQLite'); - expect(node.body).toBe('Settled on SQLite'); - expect(node.basis).toBe('explicit'); - expect(node.detail).toEqual({ - chosen_option: 'SQLite', - rejected: ['PostgreSQL'], - rationale: 'Simpler local deployment', - }); - expect(node.createdAtLsn).toBe(1); - expect(node.updatedAtLsn).toBe(1); - expect(node.kindOrdinal).toBe(1); - }); - - it('returns nodes and edges with correct counts', () => { - const batch = executor.commitGraph({ - specId, - nodes: [ - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, - ], - edges: [{ category: 'dependency', source: 'r1', target: 'a1' }], - }); - expect(batch.status).toBe('success'); - - const overview = getGraphOverview(db, specId); - expect(overview.nodeCount).toBe(2); - expect(overview.edgeCount).toBe(1); - expect(overview.nodes).toHaveLength(2); - expect(overview.edges).toHaveLength(1); - expect(overview.edges[0]!.category).toBe('dependency'); - }); - - it('excludes superseded predecessors from overview', () => { - // Create R_v0, then R_v1 that supersedes R_v0 - const r0 = executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'R_offline_v0' }); - expect(r0.status).toBe('success'); - if (r0.status !== 'success') throw new Error('unreachable'); - const r0Id = r0.nodeId; - - const batch = executor.commitGraph({ - specId, - nodes: [ - { - ref: 'r1', - plane: 'intent', - kind: 'requirement', - title: 'R_offline_v1', - }, - ], - edges: [ - { - category: 'supersession', - source: 'r1', - target: { existing: r0Id }, - }, - ], - }); - expect(batch.status).toBe('success'); - - const activeOverview = getGraphOverview(db, specId); - const activeTitles = activeOverview.nodes.map((n) => n.title); - expect(activeTitles).toContain('R_offline_v1'); - expect(activeTitles).not.toContain('R_offline_v0'); - expect(activeOverview.edges).toHaveLength(0); - - const truthOverview = getGraphOverview(db, specId, { projection: 'graph_truth' }); - expect(truthOverview.nodes.map((n) => n.title)).toEqual( - expect.arrayContaining(['R_offline_v0', 'R_offline_v1']), - ); - expect(truthOverview.edges).toHaveLength(1); - }); -}); - -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; - 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('returns error for non-existent nodeId', () => { - const result = getNodeNeighborhood(db, specId, 999); - expect(result.status).toBe('not_found'); - }); - - it('returns anchor node and directly connected nodes/edges at 1 hop (default)', () => { - const batch = executor.commitGraph({ - specId, - nodes: [ - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, - { ref: 'g1', plane: 'intent', kind: 'goal', title: 'G1' }, - ], - edges: [ - { category: 'dependency', source: 'r1', target: 'a1' }, - { category: 'support', source: 'g1', target: 'r1', stance: 'for' }, - ], - }); - expect(batch.status).toBe('success'); - if (batch.status !== 'success') throw new Error('unreachable'); - - const r1Id = batch.createdNodes['r1']!.id; - const result = getNodeNeighborhood(db, specId, r1Id); - expect(result.status).toBe('success'); - if (result.status !== 'success') throw new Error('unreachable'); - - expect(result.anchor.title).toBe('R1'); - // Should include A1 (dependency target) and G1 (support source) - expect(result.neighbors).toHaveLength(2); - const neighborTitles = result.neighbors.map((n) => n.title).sort(); - expect(neighborTitles).toEqual(['A1', 'G1']); - expect(result.edges).toHaveLength(2); - }); - - it('reaches 2-hop neighbors', () => { - // G1 -> R1 -> A1 (chain of depth 2 from G1) - const batch = executor.commitGraph({ - specId, - nodes: [ - { ref: 'g1', plane: 'intent', kind: 'goal', title: 'G1' }, - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, - ], - edges: [ - { category: 'support', source: 'g1', target: 'r1', stance: 'for' }, - { category: 'dependency', source: 'r1', target: 'a1' }, - ], - }); - expect(batch.status).toBe('success'); - if (batch.status !== 'success') throw new Error('unreachable'); - - const g1Id = batch.createdNodes['g1']!.id; - - // 1 hop: only R1 - const hop1 = getNodeNeighborhood(db, specId, g1Id, { hops: 1 }); - expect(hop1.status).toBe('success'); - if (hop1.status !== 'success') throw new Error('unreachable'); - expect(hop1.neighbors.map((n) => n.title)).toEqual(['R1']); - - // 2 hops: R1 and A1 - const hop2 = getNodeNeighborhood(db, specId, g1Id, { hops: 2 }); - expect(hop2.status).toBe('success'); - if (hop2.status !== 'success') throw new Error('unreachable'); - const titles = hop2.neighbors.map((n) => n.title).sort(); - expect(titles).toEqual(['A1', 'R1']); - }); - - it('excludes superseded predecessors from neighborhood (unless anchor)', () => { - // R_v0 superseded by R_v1, with A1 depending on R_v1 - const r0 = executor.createNode({ specId, plane: 'intent', kind: 'requirement', title: 'R_v0' }); - expect(r0.status).toBe('success'); - if (r0.status !== 'success') throw new Error('unreachable'); - const r0Id = r0.nodeId; - - const batch = executor.commitGraph({ - specId, - nodes: [ - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R_v1' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, - ], - edges: [ - { category: 'supersession', source: 'r1', target: { existing: r0Id } }, - { category: 'dependency', source: 'r1', target: 'a1' }, - ], - }); - expect(batch.status).toBe('success'); - if (batch.status !== 'success') throw new Error('unreachable'); - - const r1Id = batch.createdNodes['r1']!.id; - - // Neighborhood of R_v1: should include A1 but exclude R_v0 - const result = getNodeNeighborhood(db, specId, r1Id); - expect(result.status).toBe('success'); - if (result.status !== 'success') throw new Error('unreachable'); - - const neighborTitles = result.neighbors.map((n) => n.title); - expect(neighborTitles).toContain('A1'); - expect(neighborTitles).not.toContain('R_v0'); - - // But if R_v0 is the anchor, it should still be returned - const r0Result = getNodeNeighborhood(db, specId, r0Id); - expect(r0Result.status).toBe('success'); - if (r0Result.status !== 'success') throw new Error('unreachable'); - expect(r0Result.anchor.title).toBe('R_v0'); - }); - - it('returns typed GraphNode and GraphEdge domain objects', () => { - const batch = executor.commitGraph({ - specId, - nodes: [ - { - ref: 't1', - plane: 'intent', - kind: 'term', - title: 'Widget', - detail: { definition: 'A reusable component', aliases: ['gadget'] }, - }, - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, - ], - edges: [{ category: 'boundary', source: 't1', target: 'r1' }], - }); - expect(batch.status).toBe('success'); - if (batch.status !== 'success') throw new Error('unreachable'); - - const t1Id = batch.createdNodes['t1']!.id; - const result = getNodeNeighborhood(db, specId, t1Id); - expect(result.status).toBe('success'); - if (result.status !== 'success') throw new Error('unreachable'); - - // Anchor has parsed detail - expect(result.anchor.detail).toEqual({ - definition: 'A reusable component', - aliases: ['gadget'], - }); - - // Edge has typed fields - const edge = result.edges[0]!; - expect(edge.category).toBe('boundary'); - expect(edge.sourceId).toBe(t1Id); - expect(edge.createdAtLsn).toBeTypeOf('number'); - }); -}); - -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' }); + expect(parseGraphNodeCode('REQ3')).toEqual({ kind: 'requirement', kindOrdinal: 3 }); + expect(parseGraphNodeCode('AC4')).toEqual({ kind: 'criterion', kindOrdinal: 4 }); }); }); @@ -680,12 +40,7 @@ describe('getOpenReconciliationNeeds', () => { db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); - it('returns empty array when no needs exist', () => { - const needs = getOpenReconciliationNeeds(db, specId); - expect(needs).toEqual([]); - }); - - it('returns open needs as typed domain objects', () => { + it('returns open needs as typed domain objects and excludes resolved needs', () => { const batch = executor.commitGraph({ specId, nodes: [ @@ -706,38 +61,16 @@ describe('getOpenReconciliationNeeds', () => { expect(create.status).toBe('success'); if (create.status !== 'success') throw new Error('unreachable'); - const needs = getOpenReconciliationNeeds(db, specId); - expect(needs).toHaveLength(1); - expect(needs[0]!.kind).toBe('edge_revalidation'); - expect(needs[0]!.target).toEqual({ kind: 'edge', edgeId: batch.edges[0]! }); - expect(needs[0]!.rationale).toBe('upstream changed'); - expect(needs[0]!.createdAtLsn).toBeTypeOf('number'); - }); - - it('excludes resolved needs', () => { - const batch = executor.commitGraph({ - specId, - nodes: [ - { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'R1' }, - { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'A1' }, - ], - edges: [{ category: 'dependency', source: 'r1', target: 'a1' }], - }); - expect(batch.status).toBe('success'); - if (batch.status !== 'success') throw new Error('unreachable'); - - const create = executor.createReconciliationNeed({ - specId, - target: { kind: 'edge', edgeId: batch.edges[0]! }, - needKind: 'edge_revalidation', - }); - expect(create.status).toBe('success'); - if (create.status !== 'success') throw new Error('unreachable'); + expect(getOpenReconciliationNeeds(db, specId)).toMatchObject([ + { + kind: 'edge_revalidation', + target: { kind: 'edge', edgeId: batch.edges[0]! }, + rationale: 'upstream changed', + }, + ]); executor.resolveReconciliationNeed({ specId, id: create.id }); - - const needs = getOpenReconciliationNeeds(db, specId); - expect(needs).toEqual([]); + expect(getOpenReconciliationNeeds(db, specId)).toEqual([]); }); }); @@ -755,65 +88,7 @@ describe('getOpenElicitationBacklogEntries', () => { specId = created.specId; }); - it('returns seeded grounding entries as typed domain objects', () => { - const entries = getOpenElicitationBacklogEntries(db, specId); - - expect( - entries.map((entry) => ({ - kind: entry.kind, - question: entry.question, - status: entry.status, - basis: entry.basis, - readinessBand: entry.readinessBand, - planeAffinity: entry.planeAffinity, - lensAffinity: entry.lensAffinity, - createdAtLsn: entry.createdAtLsn, - })), - ).toEqual([ - { - kind: 'domain_anchor_question', - question: 'What is the thing or domain we are specifying?', - status: 'open', - basis: 'explicit', - readinessBand: 'grounding', - planeAffinity: 'intent', - lensAffinity: 'intent', - createdAtLsn: 1, - }, - { - kind: 'protagonist_anchor_question', - question: 'Who is this for, or who is most affected by it?', - status: 'open', - basis: 'explicit', - readinessBand: 'grounding', - planeAffinity: 'intent', - lensAffinity: 'intent', - createdAtLsn: 1, - }, - { - kind: 'pain_anchor_question', - question: 'What problem, pain, or pull is driving this work?', - status: 'open', - basis: 'explicit', - readinessBand: 'grounding', - planeAffinity: 'intent', - lensAffinity: 'intent', - createdAtLsn: 1, - }, - { - kind: 'constraint_anchor_question', - question: 'What constraints or non-negotiable boundaries already shape it?', - status: 'open', - basis: 'explicit', - readinessBand: 'grounding', - planeAffinity: 'intent', - lensAffinity: 'intent', - createdAtLsn: 1, - }, - ]); - }); - - it('filters to open entries for the requested spec only', () => { + it('returns only open entries for the requested spec', () => { const other = executor.createSpec({ name: 'Other Spec', slug: 'other-spec' }); expect(other.status).toBe('success'); if (other.status !== 'success') throw new Error('unreachable'); @@ -838,12 +113,13 @@ describe('getOpenElicitationBacklogEntries', () => { expect(resolvedNode.status).toBe('success'); if (resolvedNode.status !== 'success') throw new Error('unreachable'); - const close = executor.closeElicitationBacklogEntry({ - specId, - id: created.id, - resolvedByNodeId: resolvedNode.nodeId, - }); - expect(close.status).toBe('success'); + expect( + executor.closeElicitationBacklogEntry({ + specId, + id: created.id, + resolvedByNodeId: resolvedNode.nodeId, + }).status, + ).toBe('success'); expect(getOpenElicitationBacklogEntries(db, specId)).toHaveLength(4); expect(getOpenElicitationBacklogEntries(db, other.specId)).toHaveLength(4); diff --git a/src/graph/queries.ts b/src/graph/queries.ts index 16b0a5716..8e5001b71 100644 --- a/src/graph/queries.ts +++ b/src/graph/queries.ts @@ -1,12 +1,10 @@ /** - * Graph read helpers — cursory overview and node neighborhood. + * Graph read helpers. * - * SPEC: I35-L (two detail levels), D52-L (graph/ reads db/) + * SPEC: D52-L (graph/ reads db/), D60-L (PULL owns fact selection). * - * 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. + * These are pure read functions over BrunchDb. They return typed domain objects + * (GraphNode, GraphEdge), not raw Drizzle rows. */ import { and, eq, inArray, or } from 'drizzle-orm'; @@ -14,7 +12,7 @@ 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 type { EdgeCategory, GraphEdge } from './schema/edges.js'; import type { ElicitationBacklogEntry } from './schema/elicitation-backlog.js'; import { NODE_KIND_METADATA, @@ -26,87 +24,53 @@ import { } from './schema/nodes.js'; import type { ReconciliationNeed, ReconciliationNeedTarget } from './schema/reconciliation-need.js'; -// --------------------------------------------------------------------------- -// Return types -// --------------------------------------------------------------------------- +export type GraphVisibility = 'active' | 'all'; -/** Full-graph cursory overview. */ -export type GraphProjection = 'active_context' | 'graph_truth'; +export interface GraphReadOptions { + readonly visibility?: GraphVisibility; +} -/** Full-graph cursory overview. */ -export interface GraphOverview { +export interface GraphSlice { 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 type NodeSelector = { readonly id: number } | { readonly code: string }; -export interface RelatedNodesOptions extends GraphOverviewOptions { - readonly anchorIds: readonly number[]; - readonly edgeCategory: GraphEdge['category']; - readonly direction?: RelatedDirection; +export interface GetNodesOptions extends GraphReadOptions { 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 NodeNeighborhood = + | { + readonly selector: NodeSelector; + readonly status: 'found'; + readonly node: GraphNode; + readonly related: readonly GraphNode[]; + readonly edges: readonly GraphEdge[]; + } + | { + readonly selector: NodeSelector; + readonly status: 'not_found'; + readonly related: readonly []; + readonly edges: readonly []; + }; + +export type EdgeDirection = 'outgoing' | 'incoming' | 'both'; + +interface EdgePresenceFilter { + readonly categories?: readonly EdgeCategory[]; + readonly direction?: EdgeDirection; } -export type NeighborhoodResult = NeighborhoodSuccess | NeighborhoodNotFound; - -export interface RelatedNodesSuccess { - readonly status: 'success'; - readonly anchors: readonly GraphNode[]; - readonly relatedNodes: readonly GraphNode[]; - readonly edges: readonly GraphEdge[]; +export interface GraphFilter { + readonly kinds?: readonly NodeKind[]; + readonly bands?: readonly ReadinessBand[]; + readonly hasEdge?: EdgePresenceFilter; + readonly lacksEdge?: EdgePresenceFilter; } -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, @@ -148,460 +112,194 @@ function rowToEdge(row: typeof schema.edges.$inferSelect): GraphEdge { : base; } -// --------------------------------------------------------------------------- -// Supersession helpers -// --------------------------------------------------------------------------- +function graphVisibility(options?: GraphReadOptions): GraphVisibility { + return options?.visibility ?? 'active'; +} -/** 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)); + return new Set(rows.map((row) => row.targetId)); } -function getProjectionState(db: BrunchDb, specId: number, projection: GraphProjection) { - const supersededIds = projection === 'active_context' ? getSupersededIds(db, specId) : new Set(); +function visibleGraphState(db: BrunchDb, specId: number, visibility: GraphVisibility) { + const supersededIds = visibility === 'active' ? 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), + const visibleEdgeRows = allEdgeRows.filter( + (edge) => + visibility === 'all' || (visibleNodeIds.has(edge.source_id) && visibleNodeIds.has(edge.target_id)), ); -} -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, - }; + return { allNodeRows, visibleNodeRows, visibleNodeIds, allEdgeRows, visibleEdgeRows }; } -function withClock(db: BrunchDb, specId: number, overview: Omit): GraphOverview { +function withClock(db: BrunchDb, specId: number, slice: Omit): GraphSlice { const clockRow = db.select().from(schema.graphClock).where(eq(schema.graphClock.spec_id, specId)).get(); - return { - ...overview, - lsn: clockRow?.lsn ?? 0, - }; + return { ...slice, 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( +export function queryGraph( 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); + filter: GraphFilter = {}, + options: GraphReadOptions = {}, +): GraphSlice { + const state = visibleGraphState(db, specId, graphVisibility(options)); + const matchingIds = new Set( + state.visibleNodeRows + .filter((row) => nodeMatchesFilter(row, state.visibleEdgeRows, filter)) + .map((row) => row.id), + ); + const nodeRows = state.visibleNodeRows.filter((row) => matchingIds.has(row.id)); + const edgeRows = state.visibleEdgeRows.filter( + (edge) => matchingIds.has(edge.source_id) && matchingIds.has(edge.target_id), + ); return withClock(db, specId, { - nodes, - edges, - nodeCount: nodes.length, - edgeCount: edges.length, + nodes: nodeRows.map(rowToNode), + edges: edgeRows.map(rowToEdge), }); } -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( +export function getNodes( 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, - }; + selectors: readonly NodeSelector[], + options: GetNodesOptions = {}, +): readonly NodeNeighborhood[] { + return selectors.map((selector) => getOneNode(db, specId, selector, options)); } -// --------------------------------------------------------------------------- -// 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( +function getOneNode( db: BrunchDb, specId: number, - nodeId: number, - options?: NeighborhoodOptions, -): NeighborhoodResult { - const hops = options?.hops ?? 1; - const projection = options?.projection ?? 'active_context'; + selector: NodeSelector, + options: GetNodesOptions, +): NodeNeighborhood { + const nodeId = 'id' in selector ? selector.id : resolveGraphNodeCode(db, specId, selector.code); + if (nodeId === undefined) return { selector, status: 'not_found', related: [], edges: [] }; - // 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 { selector, status: 'not_found', related: [], edges: [] }; - 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 visibility = graphVisibility(options); + const hops = options.hops ?? 0; + const supersededIds = visibility === 'active' ? getSupersededIds(db, specId) : new Set(); const visited = new Set([nodeId]); let frontier = new Set([nodeId]); - const collectedEdgeIds = new Set(); + const edgeIds = 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 frontierIds = [...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)), + or(inArray(schema.edges.source_id, frontierIds), inArray(schema.edges.target_id, frontierIds)), ), ) .all(); const nextFrontier = new Set(); for (const edge of edgeRows) { - collectedEdgeIds.add(edge.id); + edgeIds.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); - } + if (visited.has(peerId)) continue; + if (visibility === 'active' && supersededIds.has(peerId)) 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 + const relatedIds = [...visited].filter((id) => id !== nodeId); + const visibleIds = new Set([nodeId, ...relatedIds]); + const related = relatedIds.length + ? db + .select() + .from(schema.nodes) + .where(and(eq(schema.nodes.spec_id, specId), inArray(schema.nodes.id, relatedIds))) + .all() + .map(rowToNode) + : []; + const edges = edgeIds.size + ? db + .select() + .from(schema.edges) + .where(and(eq(schema.edges.spec_id, specId), inArray(schema.edges.id, [...edgeIds]))) + .all() .filter( - (row) => - projection === 'graph_truth' || (visibleIds.has(row.source_id) && visibleIds.has(row.target_id)), + (edge) => + visibility === 'all' || (visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id)), ) - .map(rowToEdge), - ); + .map(rowToEdge) + : []; + + return { selector, status: 'found', node: rowToNode(anchorRow), related, edges }; +} + +function nodeMatchesFilter( + row: typeof schema.nodes.$inferSelect, + edges: readonly (typeof schema.edges.$inferSelect)[], + filter: GraphFilter, +): boolean { + if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(row.kind as NodeKind)) { + return false; + } + if (filter.bands && filter.bands.length > 0) { + const metadata = NODE_KIND_METADATA[row.kind as NodeKind]; + if (!metadata.readinessBands.some((band) => filter.bands?.includes(band))) return false; } + if (filter.hasEdge && !hasMatchingEdge(row.id, edges, filter.hasEdge)) return false; + if (filter.lacksEdge && hasMatchingEdge(row.id, edges, filter.lacksEdge)) return false; + return true; +} - return { - status: 'success', - anchor, - neighbors: neighborNodes, - edges: edgeNodes, - }; +function hasMatchingEdge( + nodeId: number, + edges: readonly (typeof schema.edges.$inferSelect)[], + filter: EdgePresenceFilter, +): boolean { + const direction = filter.direction ?? 'both'; + return edges.some((edge) => { + if (filter.categories && filter.categories.length > 0 && !filter.categories.includes(edge.category)) { + return false; + } + if (direction === 'incoming') return edge.target_id === nodeId; + if (direction === 'outgoing') return edge.source_id === nodeId; + return edge.source_id === nodeId || edge.target_id === nodeId; + }); } -// --------------------------------------------------------------------------- -// getOpenReconciliationNeeds -// --------------------------------------------------------------------------- +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; +} function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): ReconciliationNeed { const target: ReconciliationNeedTarget = @@ -620,9 +318,6 @@ function rowToReconNeed(row: typeof schema.reconciliationNeed.$inferSelect): Rec }; } -/** - * Return all open (unresolved) reconciliation needs for a single spec. - */ export function getOpenReconciliationNeeds(db: BrunchDb, specId: number): ReconciliationNeed[] { const rows = db .select() @@ -677,9 +372,6 @@ function rowToElicitationBacklogEntry( return entry; } -/** - * Return all open elicitation-backlog entries for a single spec. - */ export function getOpenElicitationBacklogEntries(db: BrunchDb, specId: number): ElicitationBacklogEntry[] { const rows = db .select() diff --git a/src/graph/read-api.test.ts b/src/graph/read-api.test.ts new file mode 100644 index 000000000..361c7084c --- /dev/null +++ b/src/graph/read-api.test.ts @@ -0,0 +1,116 @@ +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 { getNodes, queryGraph } from './queries.js'; + +function createTestDb(): BrunchDb { + return createDb(':memory:'); +} + +describe('graph read API', () => { + 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('queryGraph returns the selected-spec graph and applies active/all visibility', () => { + 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: 'r2', plane: 'intent', kind: 'requirement', title: 'Current requirement' }], + edges: [{ category: 'supersession', source: 'r2', target: { existing: legacy.nodeId } }], + }); + expect(batch.status).toBe('success'); + + expect(queryGraph(db, specId).nodes.map((node) => node.title)).toEqual(['Current requirement']); + expect( + queryGraph(db, specId, undefined, { visibility: 'all' }) + .nodes.map((node) => node.title) + .sort(), + ).toEqual(['Current requirement', 'Legacy requirement']); + }); + + it('getNodes resolves ids and codes, preserving selector order and per-node context', () => { + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'g1', plane: 'intent', kind: 'goal', title: 'Goal' }, + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Requirement' }, + { ref: 'a1', plane: 'intent', kind: 'assumption', title: 'Assumption' }, + ], + edges: [ + { category: 'realization', source: 'g1', target: 'r1' }, + { category: 'dependency', source: 'r1', target: 'a1' }, + ], + }); + expect(batch.status).toBe('success'); + if (batch.status !== 'success') throw new Error('unreachable'); + + const results = getNodes( + db, + specId, + [{ code: 'G1' }, { id: batch.createdNodes.r1!.id }, { code: 'NOPE' }], + { hops: 1 }, + ); + + expect(results.map((result) => result.status)).toEqual(['found', 'found', 'not_found']); + const [goal, requirement] = results; + if (goal?.status !== 'found' || requirement?.status !== 'found') throw new Error('unreachable'); + expect(goal.node.title).toBe('Goal'); + expect(goal.related.map((node) => node.title)).toEqual(['Requirement']); + expect(requirement.related.map((node) => node.title).sort()).toEqual(['Assumption', 'Goal']); + expect(requirement.edges).toHaveLength(2); + }); + + it('queryGraph supports positive and negative node/edge predicates', () => { + const batch = executor.commitGraph({ + specId, + nodes: [ + { ref: 'r1', plane: 'intent', kind: 'requirement', title: 'Proved requirement' }, + { ref: 'r2', plane: 'intent', kind: 'requirement', title: 'Unproved requirement' }, + { ref: 'e1', plane: 'oracle', kind: 'evidence', title: 'Evidence' }, + ], + edges: [{ category: 'proof', source: 'e1', target: 'r1', stance: 'for' }], + }); + expect(batch.status).toBe('success'); + + expect( + queryGraph(db, specId, { + kinds: ['requirement'], + hasEdge: { categories: ['proof'], direction: 'incoming' }, + }).nodes.map((node) => node.title), + ).toEqual(['Proved requirement']); + + expect( + queryGraph(db, specId, { + kinds: ['requirement'], + lacksEdge: { categories: ['proof'], direction: 'incoming' }, + }).nodes.map((node) => node.title), + ).toEqual(['Unproved requirement']); + + expect( + queryGraph(db, specId, { bands: ['commitment'] }) + .nodes.map((node) => node.kind) + .sort(), + ).toEqual(['evidence', 'requirement', 'requirement']); + }); +}); diff --git a/src/graph/render-preview.ts b/src/graph/render-preview.ts deleted file mode 100644 index eb3f11873..000000000 --- a/src/graph/render-preview.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 aeb8e2610..f54265302 100644 --- a/src/graph/review-set.test.ts +++ b/src/graph/review-set.test.ts @@ -2,7 +2,7 @@ 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 { queryGraph } from './queries.js'; import { translateReviewSetPayloadToCommitGraph, type ReviewSetProposalPayload } from './review-set.js'; function seedSpec(db: BrunchDb): number { @@ -77,7 +77,7 @@ describe('review-set graph payload translation', () => { expect(result.command.nodes).toHaveLength(3); expect(result.command.edges).toHaveLength(2); expect(executor.dryRunCommitGraph(result.command)).toEqual({ status: 'success' }); - expect(getGraphOverview(db, specId)).toMatchObject({ nodeCount: 0, edgeCount: 0, lsn: 1 }); + expect(queryGraph(db, specId)).toMatchObject({ nodes: [], edges: [], lsn: 1 }); }); it('rejects retired relation fields, missing epistemic or grounding data, and invalid edge stance', () => { @@ -160,7 +160,7 @@ describe('review-set graph payload translation', () => { edgeDrafts: [ { category: 'realization', - source: { existingCode: 'R1' }, + source: { existingCode: 'REQ1' }, target: { draftId: 'req-rollback' }, }, ], diff --git a/src/graph/review-set.ts b/src/graph/review-set.ts index 8b2e55263..0550f963b 100644 --- a/src/graph/review-set.ts +++ b/src/graph/review-set.ts @@ -13,20 +13,20 @@ import type { import type { NodePlane } from './schema/nodes.js'; import { parseGraphNodeCode } from './schema/nodes.js'; -export type ReviewSetLens = 'intent' | 'design' | 'oracle'; -export type ReviewSetEpistemicStatus = 'inferred' | 'assumed' | 'asserted' | 'observed'; +type ReviewSetLens = 'intent' | 'design' | 'oracle'; +type ReviewSetEpistemicStatus = 'inferred' | 'assumed' | 'asserted' | 'observed'; -export interface ReviewSetProposalGrounding { +interface ReviewSetProposalGrounding { readonly summary: string; readonly support: readonly string[]; } -export interface ReviewSetProposalPitch { +interface ReviewSetProposalPitch { readonly title: string; readonly narrative: string; } -export interface ReviewSetEntityDraft { +interface ReviewSetEntityDraft { readonly draftId: string; readonly plane: NodePlane; readonly kind: string; @@ -35,9 +35,9 @@ export interface ReviewSetEntityDraft { readonly detail?: unknown; } -export type ReviewSetEndpointRef = { readonly draftId: string } | { readonly existingCode: string }; +type ReviewSetEndpointRef = { readonly draftId: string } | { readonly existingCode: string }; -export interface ReviewSetEdgeDraft { +interface ReviewSetEdgeDraft { readonly category: string; readonly source: ReviewSetEndpointRef; readonly target: ReviewSetEndpointRef; @@ -57,7 +57,7 @@ export interface ReviewSetProposalPayload { readonly supersedes?: string | undefined; } -export interface ReviewSetTranslationSuccess { +interface ReviewSetTranslationSuccess { readonly status: 'success'; readonly payload: ReviewSetProposalPayload; readonly command: CommitGraphInput; diff --git a/src/graph/schema/edges.ts b/src/graph/schema/edges.ts index f688dbd3b..1a2b5699f 100644 --- a/src/graph/schema/edges.ts +++ b/src/graph/schema/edges.ts @@ -44,7 +44,7 @@ export type EdgeStance = (typeof EDGE_STANCES)[number]; * `implicit` means an approved concept was materialized without per-item review. * The mutation path lives in `change_log.operation`, not in `basis` (D63-L). */ -export type EdgeBasis = (typeof NODE_BASES)[number]; +type EdgeBasis = (typeof NODE_BASES)[number]; // EdgeProvenance retired — change_log owns the full audit trail. diff --git a/src/graph/schema/elicitation-backlog.ts b/src/graph/schema/elicitation-backlog.ts index 96aba4b6d..4f4783d80 100644 --- a/src/graph/schema/elicitation-backlog.ts +++ b/src/graph/schema/elicitation-backlog.ts @@ -12,7 +12,7 @@ import * as schema from '../../db/schema.js'; import type { Lsn, NodeId } from '../atoms.js'; import type { NodeBasis, NodePlane, ReadinessBand } from './nodes.js'; -export type ElicitationBacklogStatus = (typeof schema.ELICITATION_BACKLOG_STATUSES)[number]; +type ElicitationBacklogStatus = (typeof schema.ELICITATION_BACKLOG_STATUSES)[number]; export type ElicitationBacklogLensAffinity = (typeof schema.LENS_AFFINITIES)[number]; diff --git a/src/graph/schema/nodes.ts b/src/graph/schema/nodes.ts index fbe2713c0..494e47177 100644 --- a/src/graph/schema/nodes.ts +++ b/src/graph/schema/nodes.ts @@ -44,16 +44,16 @@ export type NodeBasis = (typeof NODE_BASES)[number]; * - structural: `requirement`, `assumption`, `constraint`, `invariant` * - reasoning: `decision`, `criterion`, `example` */ -export type IntentKind = (typeof INTENT_KINDS)[number]; +type IntentKind = (typeof INTENT_KINDS)[number]; /** Oracle-plane kinds. */ -export type OracleKind = (typeof ORACLE_KINDS)[number]; +type OracleKind = (typeof ORACLE_KINDS)[number]; /** Design-plane kinds. */ -export type DesignKind = (typeof DESIGN_KINDS)[number]; +type DesignKind = (typeof DESIGN_KINDS)[number]; /** Plan-plane kinds. */ -export type PlanKind = (typeof PLAN_KINDS)[number]; +type PlanKind = (typeof PLAN_KINDS)[number]; /** Union of every node kind across all planes. */ export type NodeKind = IntentKind | OracleKind | DesignKind | PlanKind; @@ -67,7 +67,7 @@ export type NodeKind = IntentKind | OracleKind | DesignKind | PlanKind; * * Never persisted — computed via {@link intentKindCategory}. */ -export type IntentKindCategory = 'basic' | 'structural' | 'reasoning'; +type IntentKindCategory = 'basic' | 'structural' | 'reasoning'; export type ReadinessBand = 'grounding' | 'elicitation' | 'commitment'; @@ -76,47 +76,61 @@ export interface NodeKindMetadata { readonly readinessBands: readonly ReadinessBand[]; } -export const NODE_KIND_METADATA: Record = { +type NodeKindMetadataByKind = { + readonly [Kind in NodeKind]: NodeKindMetadata; +}; + +export const NODE_KIND_METADATA = { goal: { label: 'G', readinessBands: ['grounding'] }, thesis: { label: 'TH', readinessBands: ['grounding'] }, term: { label: 'T', readinessBands: ['grounding', 'elicitation'] }, context: { label: 'CTX', readinessBands: ['grounding'] }, - requirement: { label: 'R', readinessBands: ['elicitation', 'commitment'] }, + requirement: { label: 'REQ', readinessBands: ['elicitation', 'commitment'] }, assumption: { label: 'A', readinessBands: ['grounding', 'elicitation'] }, constraint: { label: 'CON', readinessBands: ['grounding', 'elicitation'] }, invariant: { label: 'INV', readinessBands: ['commitment'] }, decision: { label: 'D', readinessBands: ['commitment'] }, - criterion: { label: 'CR', readinessBands: ['elicitation', 'commitment'] }, + criterion: { label: 'AC', readinessBands: ['elicitation', 'commitment'] }, example: { label: 'EX', readinessBands: ['elicitation'] }, - check: { label: 'CHK', readinessBands: ['commitment'] }, - validation_method: { label: 'VM', readinessBands: ['commitment'] }, + check: { label: 'CH', readinessBands: ['commitment'] }, + validation_method: { label: 'VV', readinessBands: ['commitment'] }, evidence: { label: 'E', readinessBands: ['commitment'] }, obligation: { label: 'O', readinessBands: ['commitment'] }, - module: { label: 'M', readinessBands: ['elicitation'] }, - interface: { label: 'IF', readinessBands: ['elicitation'] }, - milestone: { label: 'MS', readinessBands: ['commitment'] }, + module: { label: 'MOD', readinessBands: ['elicitation'] }, + interface: { label: 'API', readinessBands: ['elicitation'] }, + milestone: { label: 'M', readinessBands: ['commitment'] }, frontier: { label: 'F', readinessBands: ['commitment'] }, - slice: { label: 'SL', readinessBands: ['commitment'] }, -}; + slice: { label: 'S', readinessBands: ['commitment'] }, +} as const satisfies NodeKindMetadataByKind; + +export type GraphNodeKindCode = (typeof NODE_KIND_METADATA)[NodeKind]['label']; export interface ParsedGraphNodeCode { readonly kind: NodeKind; readonly kindOrdinal: number; } -const NODE_KIND_BY_LABEL = new Map( +const NODE_KIND_BY_LABEL: ReadonlyMap = new Map( Object.entries(NODE_KIND_METADATA).map(([kind, metadata]) => [metadata.label, kind as NodeKind]), ); +const MAX_NODE_KIND_CODE_LENGTH = Math.max( + ...Object.values(NODE_KIND_METADATA).map((metadata) => metadata.label.length), +); + export function formatGraphNodeCode(kind: NodeKind, kindOrdinal: number): string { return `${NODE_KIND_METADATA[kind].label}${kindOrdinal}`; } export function parseGraphNodeCode(code: string): ParsedGraphNodeCode | undefined { const normalized = code.trim().toUpperCase(); - for (let prefixLength = Math.min(3, normalized.length - 1); prefixLength > 0; prefixLength--) { + for ( + let prefixLength = Math.min(MAX_NODE_KIND_CODE_LENGTH, normalized.length - 1); + prefixLength > 0; + prefixLength-- + ) { const label = normalized.slice(0, prefixLength); - const kind = NODE_KIND_BY_LABEL.get(label); + const kind = NODE_KIND_BY_LABEL.get(label as GraphNodeKindCode); if (!kind) continue; const ordinalText = normalized.slice(prefixLength); if (!/^[1-9]\d*$/.test(ordinalText)) return undefined; @@ -150,14 +164,14 @@ export function intentKindCategory(kind: IntentKind): IntentKindCategory { // --------------------------------------------------------------------------- /** Detail payload for `decision` nodes. */ -export interface DecisionDetail { +interface DecisionDetail { readonly chosen_option: string; readonly rejected: readonly string[]; readonly rationale: string; } /** Detail payload for `term` nodes. */ -export interface TermDetail { +interface TermDetail { readonly definition: string; readonly aliases?: readonly string[]; } diff --git a/src/graph/schema/reconciliation-need.ts b/src/graph/schema/reconciliation-need.ts index 9482eae3f..a8f7c60be 100644 --- a/src/graph/schema/reconciliation-need.ts +++ b/src/graph/schema/reconciliation-need.ts @@ -25,7 +25,7 @@ import type { EdgeId, Lsn, NodeId } from '../atoms.js'; * Open extension — new kinds may be added as concrete needs surface. * Most needs are `edge_revalidation`. */ -export type ReconciliationNeedKind = +type ReconciliationNeedKind = | 'edge_revalidation' | 'possible_relation' | 'possible_duplicate' diff --git a/src/graph/spec-ownership.test.ts b/src/graph/spec-ownership.test.ts index 0bf938d11..963873b4c 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 './queries.js'; +import { getNodes, getOpenReconciliationNeeds, queryGraph } from './queries.js'; function freshDbWithTwoSpecs(): { db: BrunchDb; @@ -59,15 +59,15 @@ describe('graph items are owned by spec', () => { }); expect(commitB.status).toBe('success'); - const overviewA = getGraphOverview(db, specA); - const overviewB = getGraphOverview(db, specB); + const overviewA = queryGraph(db, specA); + const overviewB = queryGraph(db, specB); - expect(overviewA.nodeCount).toBe(2); - expect(overviewA.edgeCount).toBe(1); + expect(overviewA.nodes).toHaveLength(2); + expect(overviewA.edges).toHaveLength(1); expect(overviewA.nodes.every((n) => n.title.startsWith('A '))).toBe(true); - expect(overviewB.nodeCount).toBe(1); - expect(overviewB.edgeCount).toBe(0); + expect(overviewB.nodes).toHaveLength(1); + expect(overviewB.edges).toHaveLength(0); expect(overviewB.nodes[0]!.title).toBe('B goal'); }); @@ -93,8 +93,8 @@ describe('graph items are owned by spec', () => { } // Nothing was written for spec B - const overviewB = getGraphOverview(db, specB); - expect(overviewB.nodeCount).toBe(0); + const overviewB = queryGraph(db, specB); + expect(overviewB.nodes).toHaveLength(0); }); it('endpoint guard: an edge cannot connect nodes from different specs', () => { @@ -139,11 +139,11 @@ describe('graph items are owned by spec', () => { if (seedA.status !== 'success') throw new Error('seed failed'); const aNodeId = seedA.createdNodes['n1']!.id; - const wrongSpec = getNodeNeighborhood(db, specB, aNodeId); - expect(wrongSpec.status).toBe('not_found'); + const [wrongSpec] = getNodes(db, specB, [{ id: aNodeId }]); + expect(wrongSpec?.status).toBe('not_found'); - const rightSpec = getNodeNeighborhood(db, specA, aNodeId); - expect(rightSpec.status).toBe('success'); + const [rightSpec] = getNodes(db, specA, [{ id: aNodeId }]); + expect(rightSpec?.status).toBe('found'); }); it('reconciliation needs are spec-scoped and reject cross-spec targets', () => { diff --git a/src/graph/validate-fixture.ts b/src/graph/validate-fixture.ts new file mode 100644 index 000000000..53533bb12 --- /dev/null +++ b/src/graph/validate-fixture.ts @@ -0,0 +1,58 @@ +/** + * Dev CLI: validate one seed fixture against the real propose-graph validator. + * + * Seeds `.fixtures/seeds//.json` into an in-memory database through + * the same `CommandExecutor` mutation boundary the live product uses, so a + * fixture that loads here is structurally legal (valid plane/kind, per-kind + * detail rules, edge category/stance rules, no self-loops, acyclic + * supersession). On rejection it prints the command-layer diagnostics; on + * success it prints stored totals plus the active-context projection totals + * (which hide superseded predecessors and their dangling edges). + * + * This is the fast authoring loop for porting prose spec/plan docs into + * fixtures — it touches no shared test file, so multiple fixtures can be + * authored and validated independently. + * + * npx tsx src/graph/validate-fixture.ts / + * npx tsx src/graph/validate-fixture.ts brunch-self/spec-graph + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createDb } from '../db/connection.js'; +import { CommandExecutor } from './command-executor.js'; +import { queryGraph } from './queries.js'; +import { seedFixture, type SeedFixture } from './seed-fixtures.js'; + +const SEEDS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../.fixtures/seeds'); + +function validateFixture(ref: string): void { + const path = resolve(SEEDS_ROOT, `${ref}.json`); + const fixture = JSON.parse(readFileSync(path, 'utf8')) as SeedFixture; + + const db = createDb(':memory:'); + const seeded = seedFixture(new CommandExecutor(db), fixture); + const slice = queryGraph(db, seeded.specId); + + console.log(`✓ ${ref} is structurally legal`); + console.log(` authored: ${fixture.nodes.length} nodes, ${fixture.edges.length} edges`); + console.log(` stored: ${seeded.nodeCount} nodes, ${seeded.edgeCount} edges`); + console.log(` active-context: ${slice.nodes.length} nodes, ${slice.edges.length} edges`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const ref = process.argv[2]; + if (!ref) { + console.error('usage: tsx src/graph/validate-fixture.ts /'); + process.exit(2); + } + try { + validateFixture(ref); + } catch (error: unknown) { + console.error(`✗ ${ref} is NOT structurally legal:\n`); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index 58905b51a..d438e118d 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -3,25 +3,14 @@ import { join } from 'node:path'; import { createDb } from '../db/connection.js'; import { CommandExecutor } from './command-executor.js'; -import { - getGraphGaps, - getGraphOverview, - getGraphSliceByKinds, - getGraphSliceByReadinessBands, - getRelatedNodes, - getNodeNeighborhood, - resolveGraphNodeCode, -} from './queries.js'; +import { queryGraph, getNodes, resolveGraphNodeCode } from './queries.js'; import type { - GraphOverview, - GraphOverviewOptions, - GraphGapsOptions, - GraphSliceByKindsOptions, - GraphSliceByReadinessBandsOptions, - NeighborhoodOptions, - NeighborhoodResult, - RelatedNodesOptions, - RelatedNodesResult, + GetNodesOptions, + GraphReadOptions, + GraphSlice, + GraphFilter, + NodeNeighborhood, + NodeSelector, } from './queries.js'; const BRUNCH_DIR = '.brunch'; @@ -29,16 +18,15 @@ const DATA_DB_FILE = 'data.db'; /** * 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. + * so callers interact with one spec's graph without threading `specId` through + * every read. */ -export interface SpecScopedReaders { - 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; +interface SpecScopedReaders { + readonly queryGraph: (filter?: GraphFilter, options?: GraphReadOptions) => GraphSlice; + readonly getNodes: ( + selectors: readonly NodeSelector[], + options?: GetNodesOptions, + ) => readonly NodeNeighborhood[]; readonly resolveNodeCode: (code: string) => number | undefined; } @@ -54,12 +42,8 @@ export async function openWorkspaceGraphRuntime(cwd: string): Promise 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), + queryGraph: (filter, options) => queryGraph(db, specId, filter, options), + getNodes: (selectors, options) => getNodes(db, specId, selectors, options), resolveNodeCode: (code) => resolveGraphNodeCode(db, specId, code), }; }, diff --git a/src/probes/capture-quality-loop.ts b/src/probes/capture-quality-loop.ts index d05e22f11..273174374 100644 --- a/src/probes/capture-quality-loop.ts +++ b/src/probes/capture-quality-loop.ts @@ -7,10 +7,10 @@ import { portableCwd } from './portable-report.js'; const PROBE_ID = 'capture-quality' as const; -export type CaptureFactKind = 'goal' | 'context' | 'constraint' | 'criterion' | 'requirement' | 'assumption'; -export type CaptureRecommendation = 'graduate' | 'narrow' | 'keep_parked'; +type CaptureFactKind = 'goal' | 'context' | 'constraint' | 'criterion' | 'requirement' | 'assumption'; +type CaptureRecommendation = 'graduate' | 'narrow' | 'keep_parked'; -export interface CaptureQualityExpectedFact { +interface CaptureQualityExpectedFact { readonly id: string; readonly kind: CaptureFactKind; readonly title: string; @@ -26,7 +26,7 @@ export interface CaptureQualityScenario { readonly expectedFacts: readonly CaptureQualityExpectedFact[]; } -export interface CaptureQualityExtractedFact { +interface CaptureQualityExtractedFact { readonly expectedId?: string; readonly kind: CaptureFactKind; readonly title: string; @@ -39,7 +39,7 @@ export interface CaptureQualityScenarioExtraction { readonly facts: readonly CaptureQualityExtractedFact[]; } -export interface CaptureQualityScenarioResult { +interface CaptureQualityScenarioResult { readonly scenarioId: string; readonly label: string; readonly category: CaptureQualityScenario['category']; diff --git a/src/probes/capture-response-to-graph-proof.test.ts b/src/probes/capture-response-to-graph-proof.test.ts index 75e0a5bfa..a0a44e8af 100644 --- a/src/probes/capture-response-to-graph-proof.test.ts +++ b/src/probes/capture-response-to-graph-proof.test.ts @@ -20,7 +20,7 @@ describe('capture response to graph proof', () => { capture: { status: 'captured', nodeCount: 4, lsn: expect.any(Number) }, graph: { nodeCount: 4, - codes: ['G1', 'CTX1', 'CON1', 'CR1'], + codes: ['G1', 'CTX1', 'CON1', 'AC1'], lsn: expect.any(Number), }, updates: expect.arrayContaining([ @@ -54,7 +54,7 @@ describe('capture response to graph proof', () => { 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.graph.codes).toEqual(['G1', 'CTX1', 'CON1', 'AC1']); expect(persistedReport.friction).toEqual([]); // Persisted refs stay fixture-root-relative and the temp cwd is scrubbed. expect(JSON.stringify(persistedReport.artifacts)).not.toContain(fixtureRoot); diff --git a/src/probes/capture-response-to-graph-proof.ts b/src/probes/capture-response-to-graph-proof.ts index 39626f639..ef13c8cb8 100644 --- a/src/probes/capture-response-to-graph-proof.ts +++ b/src/probes/capture-response-to-graph-proof.ts @@ -2,7 +2,7 @@ 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 type { GraphSlice } 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'; @@ -37,7 +37,7 @@ interface CaptureOutcome { readonly createdNodes: Record; } -export interface CaptureResponseToGraphProofArtifacts { +interface CaptureResponseToGraphProofArtifacts { readonly runDir: string; readonly sessionJsonl: string; readonly transcriptMarkdown: string; @@ -140,7 +140,7 @@ export async function runCaptureResponseToGraphProof( throw new Error(`Expected capture success, got ${JSON.stringify(submitted.capture)}`); } - const overview = success( + const overview = success( await handlers.handle({ jsonrpc: '2.0', id: 6, @@ -148,9 +148,9 @@ export async function runCaptureResponseToGraphProof( params: { specId: workspace.spec.id }, }), ); - if (overview.nodeCount !== submitted.capture.nodeCount) { + if (overview.nodes.length !== submitted.capture.nodeCount) { friction.push( - `Overview node count ${overview.nodeCount} did not match capture count ${submitted.capture.nodeCount}.`, + `Overview node count ${overview.nodes.length} did not match capture count ${submitted.capture.nodeCount}.`, ); } @@ -159,8 +159,8 @@ export async function runCaptureResponseToGraphProof( ); const graph = { - nodeCount: overview.nodeCount, - edgeCount: overview.edgeCount, + nodeCount: overview.nodes.length, + edgeCount: overview.edges.length, lsn: overview.lsn, codes: orderedNodes.map((node) => formatGraphNodeCode(node.kind, node.kindOrdinal)), titles: orderedNodes.map((node) => node.title), diff --git a/src/probes/fixture-contract-residue.test.ts b/src/probes/fixture-contract-residue.test.ts new file mode 100644 index 000000000..c5b1e70c6 --- /dev/null +++ b/src/probes/fixture-contract-residue.test.ts @@ -0,0 +1,23 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const RETIRED_CONTRACT_TOKENS = ['graphSnapshotJson', 'graph-snapshot', 'workspace.snapshot'] as const; + +describe('committed probe reports', () => { + it('do not contain retired graph artifact or workspace topic tokens', () => { + const reportPaths = execFileSync('git', ['ls-files', '.fixtures/**/report.json'], { + encoding: 'utf8', + }) + .split('\n') + .filter(Boolean); + + const residue = reportPaths.flatMap((path) => { + const text = readFileSync(path, 'utf8'); + return RETIRED_CONTRACT_TOKENS.flatMap((token) => (text.includes(token) ? [`${path}: ${token}`] : [])); + }); + + expect(residue).toEqual([]); + }); +}); diff --git a/src/probes/fixture-curation-loop.test.ts b/src/probes/fixture-curation-loop.test.ts index 8d021bb1c..74bb2f7e3 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/queries.js'; +import type { GraphSlice } from '../graph/queries.js'; import { summarizeFixtureCurationRun, writeFixtureCurationArtifacts, @@ -23,7 +23,7 @@ function toolResultEntry(toolName: string, details: unknown): string { }); } -const mixedBasisOverview: GraphOverview = { +const mixedBasisOverview: GraphSlice = { nodes: [ { id: 1, @@ -61,8 +61,7 @@ const mixedBasisOverview: GraphOverview = { updatedAtLsn: 3, }, ], - nodeCount: 2, - edgeCount: 1, + lsn: 3, }; @@ -102,7 +101,7 @@ describe('fixture curation loop report', () => { expect(report.createdNodes).toEqual([ { id: 2, - code: 'R1', + code: 'REQ1', plane: 'intent', kind: 'requirement', title: 'Rollback path is named', @@ -146,8 +145,6 @@ describe('fixture curation loop report', () => { ...mixedBasisOverview, nodes: [mixedBasisOverview.nodes[0]!], edges: [], - nodeCount: 1, - edgeCount: 0, }, }); diff --git a/src/probes/fixture-curation-loop.ts b/src/probes/fixture-curation-loop.ts index 3673f3784..43bab5880 100644 --- a/src/probes/fixture-curation-loop.ts +++ b/src/probes/fixture-curation-loop.ts @@ -14,7 +14,7 @@ import { type CommitGraphSuccess, type Diagnostic, type GraphNode, - type GraphOverview, + type GraphSlice, type StructuralIllegal, } from '../graph/index.js'; import { seedFixture, type SeedFixture } from '../graph/seed-fixtures.js'; @@ -26,7 +26,7 @@ const PROBE_ID = 'fixture-curation' as const; const DEFAULT_SEED_SET = 'bilal-port-variants'; const DEFAULT_SEED_SLUG = 'macro-view-grounded-intent'; -export type FixtureCurationCommitStatus = +type FixtureCurationCommitStatus = | CommitGraphSuccess['status'] | StructuralIllegal['status'] | 'needs_human' @@ -34,14 +34,14 @@ export type FixtureCurationCommitStatus = | 'version_conflict' | 'unknown'; -export interface FixtureCurationRuntimeStateReport { +interface FixtureCurationRuntimeStateReport { readonly operationalMode: 'elicit'; readonly agentStrategy: 'propose-graph'; readonly agentLens: 'intent'; readonly agentGoal: 'commit-converge'; } -export interface FixtureCurationRunOptions { +interface FixtureCurationRunOptions { readonly cwd?: string; readonly fixtureRoot?: string; readonly seedSet?: string; @@ -60,7 +60,7 @@ export interface FixtureCurationArtifacts { readonly graphOverviewJson: string; } -export interface FixtureCurationCommitAttempt { +interface FixtureCurationCommitAttempt { readonly index: number; readonly status: FixtureCurationCommitStatus; readonly lsn?: number; @@ -70,7 +70,7 @@ export interface FixtureCurationCommitAttempt { readonly content?: string; } -export interface FixtureCurationCreatedNode { +interface FixtureCurationCreatedNode { readonly id: number; readonly code: string; readonly plane: GraphNode['plane']; @@ -123,7 +123,7 @@ export interface FixtureCurationSummaryInput { readonly runtimeState: FixtureCurationRuntimeStateReport; readonly model?: string; readonly sessionText: string; - readonly overview: GraphOverview; + readonly overview: GraphSlice; readonly friction?: readonly string[]; } @@ -178,7 +178,7 @@ export async function runFixtureCurationLoop( await session.sendUserMessage(prompt); await session.agent.waitForIdle(); const sessionText = await readFile(activated.session.file, 'utf8'); - const overview = graph.forSpec(seedResult.specId).getGraphOverview(); + const overview = graph.forSpec(seedResult.specId).queryGraph(); let report = summarizeFixtureCurationRun({ runId, generatedAt, @@ -265,8 +265,8 @@ export function summarizeFixtureCurationRun(input: FixtureCurationSummaryInput): commitGraphAttempts, createdNodes, finalGraph: { - nodeCount: input.overview.nodeCount, - edgeCount: input.overview.edgeCount, + nodeCount: input.overview.nodes.length, + edgeCount: input.overview.edges.length, lsn: input.overview.lsn, explicitNodeCount, implicitNodeCount, @@ -282,7 +282,7 @@ export async function writeFixtureCurationArtifacts(options: { readonly runId: string; readonly sessionText: string; readonly report: FixtureCurationReport; - readonly graphOverview: GraphOverview; + readonly graphOverview: GraphSlice; }): Promise { // Persisted artifact references are fixture-root-relative so committed // reports stay portable; the disk paths used for writing are resolved diff --git a/src/probes/project-graph-review-cycle-proof.test.ts b/src/probes/project-graph-review-cycle-proof.test.ts index ca2568513..b68bc5759 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/queries.js'; +import type { GraphSlice } from '../graph/queries.js'; import type { JsonRpcResponse } from '../rpc/protocol.js'; import { summarizeProjectGraphReviewCycleProof, @@ -12,7 +12,7 @@ import { type ProjectGraphReviewCycleReport, } from './project-graph-review-cycle-proof.js'; -const baseOverview: GraphOverview = { +const baseOverview: GraphSlice = { nodes: [ { id: 1, @@ -27,12 +27,11 @@ const baseOverview: GraphOverview = { }, ], edges: [], - nodeCount: 1, - edgeCount: 0, + lsn: 2, }; -const approvedOverview: GraphOverview = { +const approvedOverview: GraphSlice = { nodes: [ ...baseOverview.nodes, { @@ -60,8 +59,7 @@ const approvedOverview: GraphOverview = { updatedAtLsn: 3, }, ], - nodeCount: 2, - edgeCount: 1, + lsn: 3, }; @@ -191,7 +189,7 @@ describe('project-graph review-cycle proof report', () => { expect(report.createdNodes).toEqual([ { id: 2, - code: 'R1', + code: 'REQ1', plane: 'intent', kind: 'requirement', title: 'Macro view names impasse resolution state', diff --git a/src/probes/project-graph-review-cycle-proof.ts b/src/probes/project-graph-review-cycle-proof.ts index 1a9ca7114..1ad6a630d 100644 --- a/src/probes/project-graph-review-cycle-proof.ts +++ b/src/probes/project-graph-review-cycle-proof.ts @@ -8,7 +8,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent'; import { appendBrunchAgentRuntimeSwitch, type BrunchAgentState } from '../.pi/extensions/runtime/index.js'; import { createBrunchAgentSessionRuntimeFactory } from '../app/brunch-tui.js'; -import { openWorkspaceGraphRuntime, type GraphNode, type GraphOverview } from '../graph/index.js'; +import { openWorkspaceGraphRuntime, type GraphNode, type GraphSlice } from '../graph/index.js'; import { formatGraphNodeCode } from '../graph/schema/nodes.js'; import { seedFixture, type SeedFixture } from '../graph/seed-fixtures.js'; import { createRpcHandlers } from '../rpc/handlers.js'; @@ -22,14 +22,14 @@ const PROBE_ID = 'project-graph-review-cycle' as const; const DEFAULT_SEED_SET = 'bilal-port-variants'; const DEFAULT_SEED_SLUG = 'macro-view-grounded-intent'; -export interface ProjectGraphReviewRuntimeStateReport { +interface ProjectGraphReviewRuntimeStateReport { readonly operationalMode: 'elicit'; readonly agentStrategy: 'project-graph'; readonly agentLens: 'intent'; readonly agentGoal: 'commit-converge'; } -export interface ProjectGraphReviewCycleProofOptions { +interface ProjectGraphReviewCycleProofOptions { readonly cwd?: string; readonly fixtureRoot?: string; readonly seedSet?: string; @@ -47,14 +47,14 @@ export interface ProjectGraphReviewCycleArtifacts { readonly graphOverviewJson: string; } -export interface ReviewCycleToolEvidence { +interface ReviewCycleToolEvidence { readonly presentReviewSetCount: number; readonly requestReviewCount: number; readonly successfulPresentReviewSetCount: number; readonly structuralIllegalPresentReviewSetCount: number; } -export interface ReviewCycleApprovalEvidence { +interface ReviewCycleApprovalEvidence { readonly attempted: boolean; readonly status?: | 'approved' @@ -69,7 +69,7 @@ export interface ReviewCycleApprovalEvidence { readonly error?: string; } -export interface ProjectGraphReviewCycleCreatedNode { +interface ProjectGraphReviewCycleCreatedNode { readonly id: number; readonly code: string; readonly plane: GraphNode['plane']; @@ -139,8 +139,8 @@ export interface ProjectGraphReviewCycleSummaryInput { readonly runtimeState: ProjectGraphReviewRuntimeStateReport; readonly model?: string; readonly sessionText: string; - readonly baseOverview: GraphOverview; - readonly finalOverview: GraphOverview; + readonly baseOverview: GraphSlice; + readonly finalOverview: GraphSlice; readonly pendingResponse?: JsonRpcResponse; readonly approvalResponse?: JsonRpcResponse; readonly productUpdates?: readonly ProductUpdate[]; @@ -188,7 +188,7 @@ export async function runProjectGraphReviewCycleProof( if (gradeResult.status !== 'success') { throw new Error('failed to advance probe spec to commitments_ready'); } - const baseOverview = graph.forSpec(seedResult.specId).getGraphOverview(); + const baseOverview = graph.forSpec(seedResult.specId).queryGraph(); const coordinator = createWorkspaceSessionCoordinator({ cwd }); await coordinator.openDefaultWorkspace(); await selectSpecForSetupSession(cwd, seedResult.specId); @@ -251,7 +251,7 @@ export async function runProjectGraphReviewCycleProof( : undefined; const sessionText = await readFile(activated.session.file, 'utf8'); - const finalOverview = graph.forSpec(seedResult.specId).getGraphOverview(); + const finalOverview = graph.forSpec(seedResult.specId).queryGraph(); let report = summarizeProjectGraphReviewCycleProof({ runId, generatedAt, @@ -314,8 +314,8 @@ export function summarizeProjectGraphReviewCycleProof( }); const graphDelta = { lsnAdvanced: input.finalOverview.lsn > input.baseOverview.lsn, - nodeDelta: input.finalOverview.nodeCount - input.baseOverview.nodeCount, - edgeDelta: input.finalOverview.edgeCount - input.baseOverview.edgeCount, + nodeDelta: input.finalOverview.nodes.length - input.baseOverview.nodes.length, + edgeDelta: input.finalOverview.edges.length - input.baseOverview.edges.length, }; const friction = [...(input.friction ?? [])]; @@ -370,13 +370,13 @@ export function summarizeProjectGraphReviewCycleProof( ...(input.model !== undefined ? { model: input.model } : {}), success, baseGraph: { - nodeCount: input.baseOverview.nodeCount, - edgeCount: input.baseOverview.edgeCount, + nodeCount: input.baseOverview.nodes.length, + edgeCount: input.baseOverview.edges.length, lsn: input.baseOverview.lsn, }, finalGraph: { - nodeCount: input.finalOverview.nodeCount, - edgeCount: input.finalOverview.edgeCount, + nodeCount: input.finalOverview.nodes.length, + edgeCount: input.finalOverview.edges.length, lsn: input.finalOverview.lsn, explicitNodeCount, explicitEdgeCount, @@ -398,7 +398,7 @@ export async function writeProjectGraphReviewCycleArtifacts(options: { readonly runId: string; readonly sessionText: string; readonly report: ProjectGraphReviewCycleReport; - readonly graphOverview: GraphOverview; + readonly graphOverview: GraphSlice; }): Promise { // Persisted artifact references are fixture-root-relative so committed // reports stay portable; the disk paths used for writing are resolved diff --git a/src/probes/propose-graph-commit-proof.test.ts b/src/probes/propose-graph-commit-proof.test.ts index 70bf9edd8..289275b64 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/queries.js'; +import type { GraphSlice } from '../graph/queries.js'; import { summarizeProposeGraphCommitProof, writeProposeGraphCommitProofArtifacts, @@ -23,7 +23,7 @@ function messageEntry(toolName: string, details: unknown, content: string): stri }); } -const successfulOverview: GraphOverview = { +const successfulOverview: GraphSlice = { nodes: [ { id: 1, @@ -60,8 +60,6 @@ const successfulOverview: GraphOverview = { updatedAtLsn: 1, }, ], - nodeCount: 2, - edgeCount: 1, lsn: 1, }; @@ -81,7 +79,7 @@ describe('propose-graph commit proof report', () => { { status: 'success', lsn: 1, - createdNodes: { goal: { id: 1, code: 'G1' }, rollback: { id: 2, code: 'R1' } }, + createdNodes: { goal: { id: 1, code: 'G1' }, rollback: { id: 2, code: 'REQ1' } }, edges: [1], }, 'Graph committed successfully', @@ -132,7 +130,7 @@ describe('propose-graph commit proof report', () => { { status: 'success', lsn: 2, - createdNodes: { r1: { id: 2, code: 'R1' } }, + createdNodes: { r1: { id: 2, code: 'REQ1' } }, edges: [1], }, 'Graph committed successfully', @@ -163,7 +161,7 @@ describe('propose-graph commit proof report', () => { }); expect(report.committedNodes).toEqual([ { code: 'G1', title: 'Clarify launch readiness' }, - { code: 'R1', title: 'Expose rollback criteria' }, + { code: 'REQ1', title: 'Expose rollback criteria' }, ]); }); @@ -182,7 +180,7 @@ describe('propose-graph commit proof report', () => { { status: 'success', lsn: 2, - createdNodes: { p1: { id: 1, code: 'CR1' }, p2: { id: 2, code: 'G1' } }, + createdNodes: { p1: { id: 1, code: 'AC1' }, p2: { id: 2, code: 'G1' } }, edges: [1], }, 'Graph committed successfully', @@ -232,7 +230,7 @@ describe('propose-graph commit proof report', () => { sessionId: 'session-1', maxAttempts: 2, sessionText, - overview: { ...successfulOverview, nodes: [], edges: [], nodeCount: 0, edgeCount: 0, lsn: 1 }, + overview: { ...successfulOverview, nodes: [], edges: [], lsn: 1 }, prompt: 'Maybe update the graph if useful.', scenarioId: 'ambiguity-no-overcommit', }); @@ -285,7 +283,7 @@ describe('propose-graph commit proof report', () => { sessionId: 'session-1', maxAttempts: 1, sessionText, - overview: { ...successfulOverview, nodes: [], edges: [], nodeCount: 0, edgeCount: 0, lsn: 0 }, + overview: { ...successfulOverview, nodes: [], edges: [], lsn: 0 }, prompt: 'Commit the accepted concept.', }); diff --git a/src/probes/propose-graph-commit-proof.ts b/src/probes/propose-graph-commit-proof.ts index e8d9f58bf..68a3eb7e3 100644 --- a/src/probes/propose-graph-commit-proof.ts +++ b/src/probes/propose-graph-commit-proof.ts @@ -14,7 +14,7 @@ import { type Diagnostic, type StructuralIllegal, } from '../graph/index.js'; -import type { GraphOverview } from '../graph/queries.js'; +import type { GraphSlice } from '../graph/queries.js'; import { formatGraphNodeCode } from '../graph/schema/nodes.js'; import { renderSessionTranscript } from '../session/session-transcript.js'; import { createWorkspaceSessionCoordinator } from '../session/workspace-session-coordinator.js'; @@ -23,15 +23,15 @@ import { portableCwd } from './portable-report.js'; const PROBE_ID = 'propose-graph-commit' as const; const DEFAULT_MAX_ATTEMPTS = 2; -export type ProposeGraphCommitScenarioId = +type ProposeGraphCommitScenarioId = | 'direct-commit' | 'existing-code-ref' | 'retry-diagnostics' | 'ambiguity-no-overcommit'; -export type AmbiguityNoOvercommitOutcome = 'no_op_or_clarification' | 'overcommit' | 'unexpected_tool_use'; +type AmbiguityNoOvercommitOutcome = 'no_op_or_clarification' | 'overcommit' | 'unexpected_tool_use'; -export interface ProposeGraphCommitProofOptions { +interface ProposeGraphCommitProofOptions { cwd?: string; fixtureRoot?: string; runId?: string; @@ -48,7 +48,7 @@ export interface ProposeGraphCommitProofArtifacts { reportJson: string; } -export type CommitGraphAttemptStatus = +type CommitGraphAttemptStatus = | CommitGraphSuccess['status'] | StructuralIllegal['status'] | 'needs_human' @@ -56,7 +56,7 @@ export type CommitGraphAttemptStatus = | 'version_conflict' | 'unknown'; -export interface CommitGraphAttemptReport { +interface CommitGraphAttemptReport { index: number; status: CommitGraphAttemptStatus; lsn?: number; @@ -112,7 +112,7 @@ export interface ProposeGraphCommitProofSummaryInput { sessionId: string; maxAttempts: number; sessionText: string; - overview: GraphOverview; + overview: GraphSlice; prompt: string; model?: string; scenarioId?: ProposeGraphCommitScenarioId; @@ -174,7 +174,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: specReads.getGraphOverview(), + overview: specReads.queryGraph(), prompt, scenarioId, ...(expectedExistingCode !== undefined ? { expectedExistingCode } : {}), @@ -193,7 +193,7 @@ export async function runProposeGraphCommitProof( sessionId: workspace.session.id, maxAttempts, sessionFile: workspace.session.file, - overview: specReads.getGraphOverview(), + overview: specReads.queryGraph(), prompt, scenarioId, ...(expectedExistingCode !== undefined ? { expectedExistingCode } : {}), @@ -226,7 +226,7 @@ async function summarizeCurrentRun(options: { sessionId: string; maxAttempts: number; sessionFile: string; - overview: GraphOverview; + overview: GraphSlice; prompt: string; model?: string; scenarioId?: ProposeGraphCommitScenarioId; @@ -267,8 +267,8 @@ export function summarizeProposeGraphCommitProof( const successfulAttempt = lastSuccessfulAttempt(attempts); const scenarioId = input.scenarioId ?? 'direct-commit'; const finalGraph = { - nodeCount: input.overview.nodeCount, - edgeCount: input.overview.edgeCount, + nodeCount: input.overview.nodes.length, + edgeCount: input.overview.edges.length, lsn: input.overview.lsn, }; const committedNodes = input.overview.nodes.map((node) => ({ @@ -278,7 +278,7 @@ export function summarizeProposeGraphCommitProof( const committedNodeTitles = committedNodes.map((node) => node.title); const projectedCodeEvidence = projectedCodeEvidenceFromSummaryInput(input); const friction = [...(input.friction ?? [])]; - let success = successfulAttempt !== undefined && input.overview.nodeCount > 0; + let success = successfulAttempt !== undefined && input.overview.nodes.length > 0; if (scenarioId === 'existing-code-ref') { success = success && @@ -294,13 +294,13 @@ export function summarizeProposeGraphCommitProof( success = attempts.some((attempt) => attempt.status === 'structural_illegal') && successfulAttempt !== undefined && - input.overview.nodeCount > 0; + input.overview.nodes.length > 0; } if (scenarioId === 'ambiguity-no-overcommit') { success = ambiguityOutcome === 'no_op_or_clarification' && - input.overview.nodeCount === 0 && - input.overview.edgeCount === 0; + input.overview.nodes.length === 0 && + input.overview.edges.length === 0; } if (attempts.length === 0 && scenarioId !== 'ambiguity-no-overcommit') { @@ -312,7 +312,7 @@ export function summarizeProposeGraphCommitProof( if (successfulAttempt === undefined && attempts.length > 0) { friction.push(`No commit_graph attempt succeeded; final status was ${finalStatus}.`); } - if (successfulAttempt !== undefined && input.overview.nodeCount === 0) { + if (successfulAttempt !== undefined && input.overview.nodes.length === 0) { friction.push('commit_graph reported success but graph overview is empty.'); } if (scenarioId === 'existing-code-ref') { @@ -344,7 +344,7 @@ export function summarizeProposeGraphCommitProof( if (ambiguityOutcome !== 'no_op_or_clarification') { friction.push(`Ambiguity scenario outcome was ${ambiguityOutcome ?? 'unknown'}.`); } - if (input.overview.nodeCount > 0 || input.overview.edgeCount > 0) { + if (input.overview.nodes.length > 0 || input.overview.edges.length > 0) { friction.push('Ambiguity scenario wrote graph state despite underspecified prompt.'); } } @@ -391,12 +391,12 @@ export function summarizeProposeGraphCommitProof( function ambiguityNoOvercommitOutcome( sessionText: string, attempts: readonly CommitGraphAttemptReport[], - overview: GraphOverview, + overview: GraphSlice, ): AmbiguityNoOvercommitOutcome { if ( attempts.some((attempt) => attempt.status === 'success') || - overview.nodeCount > 0 || - overview.edgeCount > 0 + overview.nodes.length > 0 || + overview.edges.length > 0 ) { return 'overcommit'; } diff --git a/src/probes/public-rpc-parity-proof.ts b/src/probes/public-rpc-parity-proof.ts index 9276c99fc..7a7a4d592 100644 --- a/src/probes/public-rpc-parity-proof.ts +++ b/src/probes/public-rpc-parity-proof.ts @@ -48,7 +48,7 @@ interface PendingResult { exchange: PendingExchange; } -export interface PublicRpcParityProofArtifacts { +interface PublicRpcParityProofArtifacts { runDir: string; sessionJsonl: string; transcriptMarkdown: string; diff --git a/src/probes/structured-exchange-ordering-proof.ts b/src/probes/structured-exchange-ordering-proof.ts index e26615821..8de368c21 100644 --- a/src/probes/structured-exchange-ordering-proof.ts +++ b/src/probes/structured-exchange-ordering-proof.ts @@ -158,7 +158,7 @@ async function writeOrderingExtension(cwd: string): Promise { fauxToolCall, registerFauxProvider, } from "@earendil-works/pi-ai" - import registerStructuredExchange from ${JSON.stringify(adapterPath)} + import { registerStructuredExchange } from ${JSON.stringify(adapterPath)} export default function(pi: ExtensionAPI): void { registerStructuredExchange(pi) @@ -308,6 +308,7 @@ class RpcProbeClient { #waiters: Array<{ predicate: (event: unknown) => boolean; resolve: (event: unknown) => void; + timeout: ReturnType; }> = []; constructor(child: ChildProcessWithoutNullStreams, timeoutMs: number) { @@ -324,24 +325,30 @@ class RpcProbeClient { if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject( - new Error( - `Timed out waiting for ordering proof event. Events:\n${JSON.stringify(this.events, null, 2)}\nStderr:\n${this.#stderr}`, - ), - ); - }, this.#timeoutMs); - this.#waiters.push({ + const waiter = { predicate, - resolve: (event) => { - clearTimeout(timeout); + resolve: (event: unknown) => { + clearTimeout(waiter.timeout); resolve(event as T); }, - }); + timeout: setTimeout(() => { + this.#waiters = this.#waiters.filter((candidate) => candidate !== waiter); + reject( + new Error( + `Timed out waiting for ordering proof event. Events:\n${JSON.stringify(this.events, null, 2)}\nStderr:\n${this.#stderr}`, + ), + ); + }, this.#timeoutMs), + }; + this.#waiters.push(waiter); }); } dispose(): void { + for (const waiter of this.#waiters) { + clearTimeout(waiter.timeout); + } + this.#waiters = []; this.#child.kill('SIGTERM'); } diff --git a/src/probes/submit-message-capture-proof.test.ts b/src/probes/submit-message-capture-proof.test.ts index 329df6ca1..4c59c3a46 100644 --- a/src/probes/submit-message-capture-proof.test.ts +++ b/src/probes/submit-message-capture-proof.test.ts @@ -20,7 +20,7 @@ describe('submit message capture proof', () => { capture: { status: 'captured', nodeCount: 4, lsn: expect.any(Number) }, graph: { nodeCount: 4, - codes: ['G1', 'CTX1', 'CON1', 'CR1'], + codes: ['G1', 'CTX1', 'CON1', 'AC1'], lsn: expect.any(Number), }, updates: expect.arrayContaining([ @@ -54,7 +54,7 @@ describe('submit message capture proof', () => { 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.graph.codes).toEqual(['G1', 'CTX1', 'CON1', 'AC1']); 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 index 657f42227..e673cfc0b 100644 --- a/src/probes/submit-message-capture-proof.ts +++ b/src/probes/submit-message-capture-proof.ts @@ -2,7 +2,7 @@ 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 type { GraphSlice } 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'; @@ -29,7 +29,7 @@ interface CaptureOutcome { readonly createdNodes: Record; } -export interface SubmitMessageCaptureProofArtifacts { +interface SubmitMessageCaptureProofArtifacts { readonly runDir: string; readonly sessionJsonl: string; readonly transcriptMarkdown: string; @@ -113,7 +113,7 @@ export async function runSubmitMessageCaptureProof( throw new Error(`Expected capture success, got ${JSON.stringify(submitted.capture)}`); } - const overview = success( + const overview = success( await handlers.handle({ jsonrpc: '2.0', id: 3, @@ -121,9 +121,9 @@ export async function runSubmitMessageCaptureProof( params: { specId: workspace.spec.id }, }), ); - if (overview.nodeCount !== submitted.capture.nodeCount) { + if (overview.nodes.length !== submitted.capture.nodeCount) { friction.push( - `Overview node count ${overview.nodeCount} did not match capture count ${submitted.capture.nodeCount}.`, + `Overview node count ${overview.nodes.length} did not match capture count ${submitted.capture.nodeCount}.`, ); } @@ -132,8 +132,8 @@ export async function runSubmitMessageCaptureProof( ); const graph = { - nodeCount: overview.nodeCount, - edgeCount: overview.edgeCount, + nodeCount: overview.nodes.length, + edgeCount: overview.edges.length, lsn: overview.lsn, codes: orderedNodes.map((node) => formatGraphNodeCode(node.kind, node.kindOrdinal)), titles: orderedNodes.map((node) => node.title), diff --git a/src/projections/README.md b/src/projections/README.md index f4a246e7d..6fadb6cb0 100644 --- a/src/projections/README.md +++ b/src/projections/README.md @@ -8,6 +8,43 @@ Structured DTOs derived from graph, session, workspace, or tool facts when the s Projection modules preserve information; they do not render markdown, perform Pi registration, own transport handlers, mutate graph/session state, or import web/RPC/app adapters. +## Projection shape ledger + +PROJECT is the info-preserving stage of the context pipeline (D60-L: PULL → **PROJECT** → RENDER → COMPOSE). Two gates decide each module's disposition, in order: + +1. **Earns-its-place.** A projection is justified only when it performs a real transform (selection, flattening, formatting, SDK conversion) *and* its shape is reused across multiple consumers (renderers / RPC / web / probes / agent context). A single-consumer module that only re-wraps its source shape is indirection: delete it and feed the source directly. Not every domain needs a projection. +2. **Oracle.** A surviving projection takes a **shape / no-loss invariant**, not a golden snapshot — the failure that matters is a projection silently dropping a field a downstream renderer also hides, which a golden cannot catch. + +This ledger is the closed inventory; every implemented module appears once. Domain folders stay split only while each owns at least one earned projection (e.g. `workspace/` is kept by `workspace-state`, not by `workspace-context`). + +Disposition: `✓` locked · `●` keep + lock (earns place, needs invariant) · `◐` keep, decide direct-vs-transitive · `✗` delete / inline (fails earns-its-place) · `○` leave (topology stub / policy data). Consumers = importing modules outside this file. + +| Module | Consumers | Disposition | Oracle / reason | +| --- | --- | --- | --- | +| `graph/neighborhood` | 4 | ● | Real `projectNeighborhood` (tagged not-found/success). Invariant: success preserves projected node code + every edge endpoint; not-found exhaustive. | +| `graph/overview` | — | ○ | `export {}` topology stub (Input/Output/Used-by named); no implementation to lock. | +| `graph/commit-result` | — | ○ | `export {}` topology stub. | +| `graph/reconciliation-needs` | — | ○ | `export {}` topology stub. | +| `session/transcript-context` | 2 | ● | Real transform: filters session entries + Pi-SDK convert. Invariant: no non-empty transcript entry dropped. Consumes the Pi SDK (external trust boundary), not a PULL surface we own. | +| `session/runtime-state` | 13 | ● | Most-consumed projection; flattens runtime state. Direct flattened-shape invariant guards the field set every consumer relies on. | +| `session/affordances` | 1 | ✓ | `affordances.test.ts` — legality + default-on-switch derivation tested directly. | +| `session/runtime-policy` | 4 | ○ | Policy/definitions data, not a DTO transform. Legality source already guarded via `affordances.test.ts` + `.pi` state tests. | +| `workspace/workspace-context` | 1 | ✗ | Pure `{ mode, data }` tag wrapper — zero transform, single consumer (`.pi/extensions/context/get-cwd.ts`). Source `session/workspace-context.ts` already exports the shapes + `inspect*` and can feed the consumer directly. Delete / inline. | +| `workspace/workspace-state` | 4 | ● | Real flatten of the `WorkspaceSessionState` union to a narrow DTO. Shape invariant across status variants (`ready` / `needs_human` / base). | +| `exchanges/request-choice` | 6 | ✓ | `request-choice.test.ts` (direct). | +| `exchanges/present-options` | 5 | ◐ | Builds `toolResult.details`; covered transitively via `.pi` structured-exchange tests. Decide direct-lock vs keep-transitive at design checkpoint. | +| `exchanges/present-question` | 5 | ◐ | As above. | +| `exchanges/present-review-set` | 5 | ◐ | As above. | +| `exchanges/request-answer` | 5 | ◐ | As above. | +| `exchanges/request-choices` | 6 | ◐ | As above. | +| `exchanges/request-review` | 5 | ◐ | As above. | +| `exchanges/review-set-payload` | 1 | ◐ | Covered transitively via the graph review-set path. | +| `exchanges/present-candidates` | 1 | ○ | `export {}` topology stub (candidate-family, all three layers); leave until the tool lands. | + +Aggregate DoD for the PROJECT stage: every `●` row carries a direct shape/no-loss invariant (co-located `*.test.ts`); every `✗` row is deleted/inlined with its consumer fed from the source read; `◐` rows are resolved by an explicit keep-transitive or add-direct decision; `○` rows stay untouched. `topology-boundaries.test.ts` continues to guard that `projections/` imports no adapter/transport layer. + +Upstream note (PULL): `●` projections lock against their read sources, so those must be stable first. `graph/neighborhood` sits on the locked, ledgered graph read surface (`graph/queries.ts` + `src/graph/README.md`). The session-domain projections sit on session read sources (`session/workspace-context.ts`, `session/workspace-session-coordinator.ts`, `session/runtime-state.ts`) which are behaviorally tested but not yet inventoried as a closed read-shape ledger — ledger that PULL half before freezing the session/workspace projection invariants. + ## Directory layout ```pseudo diff --git a/src/projections/graph/neighborhood.ts b/src/projections/graph/neighborhood.ts index 33c5b7b4f..370e105f2 100644 --- a/src/projections/graph/neighborhood.ts +++ b/src/projections/graph/neighborhood.ts @@ -1,99 +1,9 @@ /** - * Canonical projection for selected-spec node neighborhood context. + * Deprecated graph-neighborhood projection seam. * - * Input: - * - NeighborhoodResult from graph/queries.ts - * - * Output: - * - compact typed shape for anchor, neighbors, and connecting edges - * - omission counts, truncation policy, and not_found normalization - * - * Used by: - * - renderers/graph/neighborhood.ts - * - .pi/extensions/graph/index.ts via read_graph neighborhood results + * Node-local graph facts now arrive as `NodeNeighborhood` from graph/queries.ts, + * and model-facing flattening lives beside the graph renderer. Keep this module + * as a temporary empty topology marker until the projections ledger is reconciled. */ -import type { NeighborhoodResult } from '../../graph/queries.js'; -import { formatGraphNodeCode } from '../../graph/schema/nodes.js'; -import type { GraphNode } from '../../graph/schema/nodes.js'; - -export interface ProjectNeighborhoodOptions { - readonly maxNeighbors?: number; - readonly maxEdges?: number; -} - -export interface ProjectedNeighborhoodNotFound { - readonly status: 'not_found'; -} - -export interface ProjectedNeighborhoodSuccess { - readonly status: 'success'; - readonly anchor: { - readonly code: string; - readonly label: string; - readonly body?: string; - }; - readonly neighbors: { - readonly items: readonly string[]; - readonly omittedCount: number; - }; - readonly edges: { - readonly items: readonly string[]; - readonly omittedCount: number; - }; -} - -export type ProjectedNeighborhood = ProjectedNeighborhoodNotFound | ProjectedNeighborhoodSuccess; - -const DEFAULT_MAX_NEIGHBORS = 6; -const DEFAULT_MAX_EDGES = 8; - -export function projectNeighborhood( - result: NeighborhoodResult, - options: ProjectNeighborhoodOptions = {}, -): ProjectedNeighborhood { - if (result.status === 'not_found') { - return { status: 'not_found' }; - } - - const maxNeighbors = options.maxNeighbors ?? DEFAULT_MAX_NEIGHBORS; - const maxEdges = options.maxEdges ?? DEFAULT_MAX_EDGES; - const nodesById = new Map([ - [result.anchor.id, result.anchor], - ...result.neighbors.map((node) => [node.id, node] as const), - ]); - - return { - status: 'success', - anchor: { - code: formatGraphNodeCode(result.anchor.kind, result.anchor.kindOrdinal), - label: `${result.anchor.plane}/${result.anchor.kind}: ${result.anchor.title}`, - ...(result.anchor.body ? { body: truncate(result.anchor.body, 180) } : {}), - }, - neighbors: { - items: result.neighbors.slice(0, maxNeighbors).map((neighbor) => { - const code = formatGraphNodeCode(neighbor.kind, neighbor.kindOrdinal); - return `[${code}] ${neighbor.plane}/${neighbor.kind}: ${neighbor.title}`; - }), - omittedCount: Math.max(0, result.neighbors.length - maxNeighbors), - }, - edges: { - items: result.edges.slice(0, maxEdges).map((edge) => { - const stance = edge.stance ? `/${edge.stance}` : ''; - const rationale = edge.rationale ? ` — ${truncate(edge.rationale, 100)}` : ''; - const source = nodesById.get(edge.sourceId); - const target = nodesById.get(edge.targetId); - return `${formatEdgeEndpoint(edge.sourceId, source)} -[${edge.category}${stance}]-> ${formatEdgeEndpoint(edge.targetId, target)}${rationale}`; - }), - omittedCount: Math.max(0, result.edges.length - maxEdges), - }, - }; -} - -function formatEdgeEndpoint(id: number, node: Pick | undefined): string { - return node ? formatGraphNodeCode(node.kind, node.kindOrdinal) : `#${id}`; -} - -function truncate(value: string, maxLength: number): string { - return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; -} +export {}; diff --git a/src/projections/session/affordances.ts b/src/projections/session/affordances.ts index e6e228891..27f07cdc5 100644 --- a/src/projections/session/affordances.ts +++ b/src/projections/session/affordances.ts @@ -15,7 +15,7 @@ import { type ResolvedBrunchAgentState, } from './runtime-policy.js'; -export interface AxisAffordance { +interface AxisAffordance { readonly selection: TSelection; readonly legalOptions: readonly TId[]; readonly defaultOnSwitch: TSelection; diff --git a/src/renderers/graph/__previews__/graph-slice-alpha-grounding-compact-summary.md b/src/renderers/graph/__previews__/graph-slice-alpha-grounding-compact-summary.md new file mode 100644 index 000000000..d054cd8ee --- /dev/null +++ b/src/renderers/graph/__previews__/graph-slice-alpha-grounding-compact-summary.md @@ -0,0 +1,16 @@ +[Selected-spec graph: workspace-spread/alpha-grounding] +- lsn: 2 +- totals: 4 node(s), 2 edge(s) +- node groups: + - intent/constraint: 1 + - intent/context: 1 + - intent/goal: 1 + - intent/term: 1 +- edge categories: + - boundary: 1 + - support: 1 +- nodes: first 4 of 4 + - [CON1] intent/constraint: Selection must stay scoped to the chosen spec + - [CTX1] intent/context: A workspace may hold multiple specs + - [G1] intent/goal: Help a user orient inside one workspace + - [T1] intent/term: Selected spec diff --git a/src/renderers/graph/__previews__/graph-slice-brunch-self-grouped-list.md b/src/renderers/graph/__previews__/graph-slice-brunch-self-grouped-list.md new file mode 100644 index 000000000..f8be0f40e --- /dev/null +++ b/src/renderers/graph/__previews__/graph-slice-brunch-self-grouped-list.md @@ -0,0 +1,75 @@ +[Selected-spec graph: brunch-self (self-described)] +- lsn: 2 +- totals: 54 node(s), 30 edge(s) +- design/interface (2): + - [API1] design/interface: Public Brunch JSON-RPC session.* methods + - [API2] design/interface: commit_graph agent-facing tool schema +- design/module (4): + - [MOD1] design/module: CommandExecutor — the graph mutation authority + - [MOD2] design/module: .pi/agents/compose — runtime header plus gated prompt-resource manifest + - [MOD3] design/module: graph/queries — typed read layer (overview and neighborhood) + - …1 more node(s) omitted +- intent/assumption (2): + - [A1] intent/assumption: Pi linear JSONL sessions suffice as transcript truth for the POC + - [A2] intent/assumption: Local POC graph and session sizes stay small enough to defer performance budgets +- intent/constraint (3): + - [CON1] intent/constraint: Brunch must not fork Pi + - [CON2] intent/constraint: A Brunch-launched Pi runtime must not load ambient user/project .pi resources + - [CON3] intent/constraint: The browser must not require a second primary data plane +- intent/context (3): + - [CTX1] intent/context: Pi supplies the TUI harness, JSONL sessions, and extension hooks Brunch builds on + - [CTX2] intent/context: Stakeholders want the TUI and web to share one data plane, not two + - [CTX3] intent/context: A minority view wants Brunch to fork Pi for deeper control +- intent/criterion (2): + - [AC1] intent/criterion: After TUI interaction, .brunch/ exists with exactly one session_binding per session + - [AC2] intent/criterion: Dry-run validation at proposal time matches real-run validation at acceptance +- intent/decision (4): + - [D1] intent/decision: Adopt a single CommandExecutor mutation authority + - [D2] intent/decision: Split the session agent into orthogonal Strategy and Lens axes + - [D3] intent/decision: Compose prompts as a thin runtime header plus a gated resource manifest + - …1 more node(s) omitted +- intent/example (2): + - [EX1] intent/example: An offline / network-outage scenario the offline-first stance must withstand + - [EX2] intent/example: A proposal that fails dry-run never surfaces as a reviewable review set +- intent/goal (2): + - [G1] intent/goal: Build Brunch as a local spec-elicitation product layered on Pi without forking it + - [G2] intent/goal: Surface cross-session graph changes to the agent coherently at turn boundaries +- intent/invariant (4): + - [INV1] intent/invariant: One spec-local LSN per commit; exactly one graph_clock row per spec + - [INV2] intent/invariant: commitGraph batch validation is all-or-nothing + - [INV3] intent/invariant: Same-spec supersession edges form an acyclic directed graph + - …1 more node(s) omitted +- intent/requirement (5): + - [REQ1] intent/requirement: All durable graph mutations route through one CommandExecutor authority + - [REQ2] intent/requirement: A public RPC agent-as-user can drive structured exchanges without speaking raw Pi RPC + - [REQ3] intent/requirement: Graph context reads support a compact overview and a node-neighborhood detail view + - …2 more node(s) omitted +- intent/term (5): + - [T1] intent/term: Spec + - [T2] intent/term: Session exchange + - [T3] intent/term: Lens + - …2 more node(s) omitted +- intent/thesis (2): + - [TH1] intent/thesis: Offer-first structured exchange elicits better spec truth than free-form chat + - [TH2] intent/thesis: Pi's linear JSONL transcript can be the single canonical session substrate +- oracle/check (2): + - [CH1] oracle/check: Architectural boundary test: no db/ imports outside graph/ + - [CH2] oracle/check: commit-graph-batch structural tests: kind, stance, self-loop, acyclic supersession +- oracle/evidence (2): + - [E1] oracle/evidence: FE-744 public-RPC parity run: session.jsonl + transcript.md + report.json + - [E2] oracle/evidence: FE-809 project-graph review-cycle approval run with explicit-basis readback +- oracle/obligation (1): + - [O1] oracle/obligation: Structural invariants stay hard gates; behavioral metrics are tracked as fitness, not gated +- oracle/validation_method (2): + - [VV1] oracle/validation_method: Deterministic public-RPC parity probe (scripted agent-as-user) + - [VV2] oracle/validation_method: Transcript-backed probe runs with executable postcondition checkers +- plan/frontier (2): + - [F1] plan/frontier: Graph read/render projection context layer + - [F2] plan/frontier: Structured-exchange public-RPC parity +- plan/milestone (3): + - [M1] plan/milestone: M0 — Workspace and session bootstrap with the first probe oracle + - [M2] plan/milestone: M3 — Public RPC and structured-exchange parity + - [M3] plan/milestone: M5 — Graph context read and render projection +- plan/slice (2): + - [S1] plan/slice: node-neighborhood renderer with anchor-relative projection + - [S2] plan/slice: consolidate edge-category policy; add label and direction projections diff --git a/src/renderers/graph/__previews__/graph-slice-category-directions-full-debug.md b/src/renderers/graph/__previews__/graph-slice-category-directions-full-debug.md new file mode 100644 index 000000000..9993f929c --- /dev/null +++ b/src/renderers/graph/__previews__/graph-slice-category-directions-full-debug.md @@ -0,0 +1,50 @@ +[Selected-spec graph: edge-spread/category-directions] +- lsn: 2 +- totals: 31 node(s), 14 edge(s) +- nodes: first 31 of 31 + - [API1] design/interface: Preview CLI interface + - [MOD1] design/module: Neighborhood renderer module + - [MOD2] design/module: Module that realizes the preview CLI + - [MOD3] design/module: Renderer bounded by the constraint + - [A1] intent/assumption: Outbound dependency target + - [A3] intent/assumption: Superseding assumption successor + - [CON1] intent/constraint: Constraint bounding the renderer + - [CON2] intent/constraint: Constraint bounding a frontier + - [CTX1] intent/context: Inbound dependency source + - [CTX2] intent/context: Context that supports a requirement + - [AC1] intent/criterion: Criterion challenged by a check + - [D1] intent/decision: Decision questioned by an example + - [EX1] intent/example: Example that argues against a decision + - [G1] intent/goal: Goal with supporting evidence + - [G2] intent/goal: Associated goal A + - [G3] intent/goal: Associated goal B + - [REQ1] intent/requirement: Outbound dependency source + - [REQ2] intent/requirement: Inbound dependency target + - [REQ3] intent/requirement: Requirement with contextual support + - [REQ4] intent/requirement: Requirement realized by the module + - [REQ6] intent/requirement: Superseding requirement successor + - [T1] intent/term: Associated term A + - [T2] intent/term: Associated term B + - [TH1] intent/thesis: Unproven thesis exemplar + - [CH1] oracle/check: Check that refutes a criterion + - [E1] oracle/evidence: Proof evidence for a goal + - [F1] plan/frontier: Frontier bounded by the constraint + - [F2] plan/frontier: Frontier composed by a milestone + - [F3] plan/frontier: Frontier composing a slice + - [M1] plan/milestone: Milestone composing a frontier + - [S1] plan/slice: Slice composed by a frontier +- edges: first 14 of 14 + - REQ1 -[dependency]-> A1 + - CTX1 -[dependency]-> REQ2 + - E1 -[proof/for]-> G1 + - CH1 -[proof/against]-> AC1 + - CTX2 -[support/for]-> REQ3 + - EX1 -[support/against]-> D1 + - MOD1 -[realization]-> REQ4 + - MOD2 -[realization]-> API1 + - CON1 -[boundary]-> MOD3 + - CON2 -[boundary]-> F1 + - M1 -[composition]-> F2 + - F3 -[composition]-> S1 + - G2 -[association]-> G3 + - T1 -[association]-> T2 diff --git a/src/renderers/graph/__previews__/graph-slice-code-health-compact-summary.md b/src/renderers/graph/__previews__/graph-slice-code-health-compact-summary.md new file mode 100644 index 000000000..5baa1f1d9 --- /dev/null +++ b/src/renderers/graph/__previews__/graph-slice-code-health-compact-summary.md @@ -0,0 +1,27 @@ +[Selected-spec graph: bilal-port/code-health] +- lsn: 2 +- totals: 277 node(s), 446 edge(s) +- node groups: + - intent/constraint: 7 + - intent/context: 84 + - intent/criterion: 33 + - intent/decision: 23 + - intent/goal: 1 + - intent/requirement: 70 + - intent/term: 20 + - oracle/check: 1 + - oracle/evidence: 38 +- edge categories: + - dependency: 333 + - realization: 38 + - support: 75 +- nodes: first 8 of 277 + - [CON1] intent/constraint: Clean room agents (shaping, pinning, defining-done during re-derivation) get file read but not … + - [CON2] intent/constraint: Existing smoke test artifacts must still validate after changes. + - [CON3] intent/constraint: Tests must be deterministic and must not make LLM calls. + - [CON4] intent/constraint: Wiring cowReplace and markSuspectAndPropagate is a blocking prerequisite: the derivation loop c… + - [CON5] intent/constraint: The forward pass must remain working throughout the code health improvements. + - [CON6] intent/constraint: No changes may be made to the Effect AI or Routine abstractions. + - [CON7] intent/constraint: The resolution strategy must be maximally correct and must not take shortcuts. + - [CTX1] intent/context: Stakeholder preference: the recommended priority order for addressing open issues is tests (P18… + - …269 more node(s) omitted diff --git a/src/renderers/graph/__previews__/graph-slice-code-health-grouped-list.md b/src/renderers/graph/__previews__/graph-slice-code-health-grouped-list.md new file mode 100644 index 000000000..d3367318a --- /dev/null +++ b/src/renderers/graph/__previews__/graph-slice-code-health-grouped-list.md @@ -0,0 +1,42 @@ +[Selected-spec graph: bilal-port/code-health] +- lsn: 2 +- totals: 277 node(s), 446 edge(s) +- intent/constraint (7): + - [CON1] intent/constraint: Clean room agents (shaping, pinning, defining-done during re-derivation) get file read but not … + - [CON2] intent/constraint: Existing smoke test artifacts must still validate after changes. + - [CON3] intent/constraint: Tests must be deterministic and must not make LLM calls. + - …4 more node(s) omitted +- intent/context (84): + - [CTX1] intent/context: Stakeholder preference: the recommended priority order for addressing open issues is tests (P18… + - [CTX2] intent/context: The resolve_directly and sharpen outcomes from user escalation materialize as grounding nodes w… + - [CTX3] intent/context: Stakeholder preference: per-run stance toward alternatives is tracked at the finest granularity… + - …81 more node(s) omitted +- intent/criterion (33): + - [AC1] intent/criterion: A unit test must verify that after a refinement reconciliation outcome, the unresolved successo… + - [AC2] intent/criterion: A module test must drive a fan-in fixture with a witnessed source contradiction where some runs… + - [AC3] intent/criterion: A static dependency check (parsing deno.json import_map / import statements in engine/solver/**… + - …30 more node(s) omitted +- intent/decision (23): + - [D1] intent/decision: Use a four-layer pyramid: unit + module (scripted) + property + one VCR E2E. + - [D2] intent/decision: Extract format-handoff-report and derivation-agents-factory into separate modules. + - [D3] intent/decision: Wire nudging as negative-constraint prompt injection, gated on nudgingActive. + - …20 more node(s) omitted +- intent/goal (1): + - [G1] intent/goal: The spec elicitation prototype is an AI-assisted system that transforms raw source documents in… +- intent/requirement (70): + - [REQ1] intent/requirement: Stage 2 must compute three configuration spaces with the semantics defined in T20: M_current (s… + - [REQ2] intent/requirement: Conflict resolution during reconciliation must first attempt a deterministic graph traversal co… + - [REQ3] intent/requirement: The CLI must provide a `resume ` command that loads the latest WorkingGraph artifact, … + - …67 more node(s) omitted +- intent/term (20): + - [T1] intent/term: Node state is modeled across three independent axes: lifecycle (candidate/activ… + - [T2] intent/term: Guarded impasses are diagnostic blockers with a trigger condition (guard formul… + - [T3] intent/term: A checkpoint is an immutable snapshot of the spec graph produced when a full re… + - …17 more node(s) omitted +- oracle/check (1): + - [CH1] oracle/check: Code Health — code-audit pass +- oracle/evidence (38): + - [E1] oracle/evidence: Of the 34 code health issues, 8 have been fixed and 26 remain open; the full list is tracked in… + - [E2] oracle/evidence: The spec elicitation prototype has a working forward pass. + - [E3] oracle/evidence: P2: When the reconciler proposes disposition: "refined", the reconciliation engine marks the or… + - …35 more node(s) omitted diff --git a/src/renderers/graph/__previews__/neighborhood-brunch-self-MOD1-hops2.md b/src/renderers/graph/__previews__/neighborhood-brunch-self-MOD1-hops2.md new file mode 100644 index 000000000..84eeeb3e4 --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-brunch-self-MOD1-hops2.md @@ -0,0 +1,7 @@ +[Selected-spec node context] +- anchor: [MOD1] design/module: CommandExecutor — the graph mutation authority +- upstream (review anchor if these change): + - implements [REQ1] intent/requirement: All durable graph mutations route through one CommandExecutor authority +- downstream (reconcile if anchor changes): + - required by [API1] design/interface: Public Brunch JSON-RPC session.* methods {hard} +- (+4 edge(s) among neighbors, not incident on anchor) diff --git a/src/renderers/graph/__previews__/neighborhood-brunch-self-REQ1.md b/src/renderers/graph/__previews__/neighborhood-brunch-self-REQ1.md new file mode 100644 index 000000000..b0e1000c1 --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-brunch-self-REQ1.md @@ -0,0 +1,8 @@ +[Selected-spec node context] +- anchor: [REQ1] intent/requirement: All durable graph mutations route through one CommandExecutor authority +- upstream (review anchor if these change): + - depends on [T1] intent/term: Spec + - expresses [INV1] intent/invariant: One spec-local LSN per commit; exactly one graph_clock row per spec +- downstream (reconcile if anchor changes): + - implemented by [MOD1] design/module: CommandExecutor — the graph mutation authority {soft} + - witnessed by [CH1] oracle/check: Architectural boundary test: no db/ imports outside graph/ {soft} diff --git a/src/renderers/graph/__previews__/neighborhood-code-health-R1.md b/src/renderers/graph/__previews__/neighborhood-code-health-R1.md deleted file mode 100644 index 9ae8afd36..000000000 --- a/src/renderers/graph/__previews__/neighborhood-code-health-R1.md +++ /dev/null @@ -1,9 +0,0 @@ -[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 deleted file mode 100644 index beb98b2fa..000000000 --- a/src/renderers/graph/__previews__/neighborhood-code-health-R1.preview.md +++ /dev/null @@ -1,9 +0,0 @@ -[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/__previews__/neighborhood-code-health-REQ1.md b/src/renderers/graph/__previews__/neighborhood-code-health-REQ1.md new file mode 100644 index 000000000..26cb917dc --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-code-health-REQ1.md @@ -0,0 +1,6 @@ +[Selected-spec node context] +- anchor: [REQ1] 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… +- downstream (reconcile if anchor changes): + - required by [D11] intent/decision: Adopt the two-stage split with a hard schema boundary forbidding solver outputs from Stag… {hard} + - required by [T4] intent/term: Three configuration spaces are defined: M_current (satisfies constraints and cu… {hard} diff --git a/src/renderers/graph/__previews__/neighborhood-hub-REQ1-bounded.md b/src/renderers/graph/__previews__/neighborhood-hub-REQ1-bounded.md new file mode 100644 index 000000000..2910a3675 --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-hub-REQ1-bounded.md @@ -0,0 +1,5 @@ +[Selected-spec node context] +- anchor: [REQ1] intent/requirement: Stage 2 configuration-space requirement (hub anchor) +- upstream (review anchor if these change): + - depends on [A1] intent/assumption: Local-only execution assumption +- …12 more relation(s) omitted diff --git a/src/renderers/graph/__previews__/neighborhood-hub-REQ1-hops2.md b/src/renderers/graph/__previews__/neighborhood-hub-REQ1-hops2.md new file mode 100644 index 000000000..41748651b --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-hub-REQ1-hops2.md @@ -0,0 +1,19 @@ +[Selected-spec node context] +- anchor: [REQ1] intent/requirement: Stage 2 configuration-space requirement (hub anchor) +- upstream (review anchor if these change): + - depends on [A1] intent/assumption: Local-only execution assumption + - expresses [INV1] intent/invariant: No network call invariant + - bounded by [CON1] intent/constraint: No cloud dependencies constraint +- downstream (reconcile if anchor changes): + - required by [D1] intent/decision: Two-stage split decision {hard} + - implemented by [MOD1] design/module: SQLite configuration store module {soft} + - established by [S1] plan/slice: Persist configuration spaces slice {soft} + - witnessed by [AC1] intent/criterion: Airplane-mode acceptance criterion {soft} + - challenged by [EX1] intent/example: Network-outage counterexample {soft} + - motivated by [CTX1] intent/context: Stakeholder offline-first preference {soft} + - opposed by [CTX2] intent/context: Conflicting always-connected note {soft} + - part of [F1] plan/frontier: Configuration-space data frontier {soft} + - superseded by [REQ2] intent/requirement: Revised configuration-space requirement (successor) {soft} +- lateral (related): + - related to [G1] intent/goal: Offline-first product goal +- (+1 edge(s) among neighbors, not incident on anchor) diff --git a/src/renderers/graph/__previews__/neighborhood-hub-REQ1.md b/src/renderers/graph/__previews__/neighborhood-hub-REQ1.md new file mode 100644 index 000000000..dff441c36 --- /dev/null +++ b/src/renderers/graph/__previews__/neighborhood-hub-REQ1.md @@ -0,0 +1,18 @@ +[Selected-spec node context] +- anchor: [REQ1] intent/requirement: Stage 2 configuration-space requirement (hub anchor) +- upstream (review anchor if these change): + - depends on [A1] intent/assumption: Local-only execution assumption + - expresses [INV1] intent/invariant: No network call invariant + - bounded by [CON1] intent/constraint: No cloud dependencies constraint +- downstream (reconcile if anchor changes): + - required by [D1] intent/decision: Two-stage split decision {hard} + - implemented by [MOD1] design/module: SQLite configuration store module {soft} + - established by [S1] plan/slice: Persist configuration spaces slice {soft} + - witnessed by [AC1] intent/criterion: Airplane-mode acceptance criterion {soft} + - challenged by [EX1] intent/example: Network-outage counterexample {soft} + - motivated by [CTX1] intent/context: Stakeholder offline-first preference {soft} + - opposed by [CTX2] intent/context: Conflicting always-connected note {soft} + - part of [F1] plan/frontier: Configuration-space data frontier {soft} + - superseded by [REQ2] intent/requirement: Revised configuration-space requirement (successor) {soft} +- lateral (related): + - related to [G1] intent/goal: Offline-first product goal diff --git a/src/renderers/graph/fixture-reads.test-support.ts b/src/renderers/graph/fixture-reads.test-support.ts new file mode 100644 index 000000000..925c65c2b --- /dev/null +++ b/src/renderers/graph/fixture-reads.test-support.ts @@ -0,0 +1,5 @@ +export { + readGraphSliceFixture, + readNodeNeighborhoodFixture, + type SeedFixtureRef, +} from '../../graph/fixture-reads.test-support.js'; diff --git a/src/renderers/graph/graph-slice.ts b/src/renderers/graph/graph-slice.ts new file mode 100644 index 000000000..e639dbf83 --- /dev/null +++ b/src/renderers/graph/graph-slice.ts @@ -0,0 +1,177 @@ +/** + * Formats selected-spec GraphSlice reads into model-facing text. + */ + +import type { GraphEdge, GraphSlice } from '../../graph/index.js'; +import { formatGraphNodeCode, type GraphNode } from '../../graph/schema/nodes.js'; +import { markdownBullet } from '../markdown.js'; + +type RenderGraphSliceVariant = 'compact-summary' | 'grouped-list' | 'full-debug'; + +export interface RenderGraphSliceOptions { + readonly heading?: string; + readonly variant?: RenderGraphSliceVariant; + readonly maxNodes?: number; + readonly maxNodesPerGroup?: number; + readonly maxEdges?: number; + readonly maxTitleLength?: number; +} + +const DEFAULT_MAX_NODES = 8; +const DEFAULT_MAX_NODES_PER_GROUP = 3; +const DEFAULT_MAX_EDGES = 12; +const DEFAULT_MAX_TITLE_LENGTH = 96; + +export function formatGraphSlice(slice: GraphSlice, options: RenderGraphSliceOptions = {}): string { + const variant = options.variant ?? 'compact-summary'; + if (variant === 'grouped-list') return formatGroupedList(slice, options); + if (variant === 'full-debug') return formatFullDebug(slice, options); + return formatCompactSummary(slice, options); +} + +function formatCompactSummary(slice: GraphSlice, options: RenderGraphSliceOptions): string { + const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES; + const maxTitleLength = options.maxTitleLength ?? DEFAULT_MAX_TITLE_LENGTH; + const nodeGroups = countNodesByGroup(slice.nodes); + const edgeGroups = countEdgesByCategory(slice.edges); + const selectedNodes = slice.nodes.slice(0, maxNodes); + const lines = summaryHeader(slice, options.heading ?? 'Selected-spec graph summary'); + + lines.push(markdownBullet('node groups:')); + appendCounts(lines, nodeGroups); + lines.push(markdownBullet('edge categories:')); + appendCounts(lines, edgeGroups); + lines.push(markdownBullet(`nodes: first ${selectedNodes.length} of ${slice.nodes.length}`)); + for (const node of selectedNodes) { + lines.push(` ${markdownBullet(formatNode(node, maxTitleLength))}`); + } + appendOmitted(lines, slice.nodes.length - selectedNodes.length, 'node(s)'); + + return lines.join('\n'); +} + +function formatGroupedList(slice: GraphSlice, options: RenderGraphSliceOptions): string { + const maxNodesPerGroup = options.maxNodesPerGroup ?? DEFAULT_MAX_NODES_PER_GROUP; + const maxTitleLength = options.maxTitleLength ?? DEFAULT_MAX_TITLE_LENGTH; + const groups = groupNodes(slice.nodes); + const lines = summaryHeader(slice, options.heading ?? 'Selected-spec graph grouped list'); + + if (groups.length === 0) { + lines.push(markdownBullet('node groups: none')); + return lines.join('\n'); + } + + for (const [label, nodes] of groups) { + const selectedNodes = nodes.slice(0, maxNodesPerGroup); + lines.push(markdownBullet(`${label} (${nodes.length}):`)); + for (const node of selectedNodes) { + lines.push(` ${markdownBullet(formatNode(node, maxTitleLength))}`); + } + appendOmitted(lines, nodes.length - selectedNodes.length, 'node(s)'); + } + + return lines.join('\n'); +} + +function formatFullDebug(slice: GraphSlice, options: RenderGraphSliceOptions): string { + const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES; + const maxEdges = options.maxEdges ?? DEFAULT_MAX_EDGES; + const maxTitleLength = options.maxTitleLength ?? DEFAULT_MAX_TITLE_LENGTH; + const selectedNodes = slice.nodes.slice(0, maxNodes); + const selectedEdges = slice.edges.slice(0, maxEdges); + const nodesById = new Map(slice.nodes.map((node) => [node.id, node] as const)); + const lines = summaryHeader(slice, options.heading ?? 'Selected-spec graph debug'); + + lines.push(markdownBullet(`nodes: first ${selectedNodes.length} of ${slice.nodes.length}`)); + for (const node of selectedNodes) { + lines.push(` ${markdownBullet(formatNode(node, maxTitleLength))}`); + } + appendOmitted(lines, slice.nodes.length - selectedNodes.length, 'node(s)'); + + lines.push(markdownBullet(`edges: first ${selectedEdges.length} of ${slice.edges.length}`)); + for (const edge of selectedEdges) { + lines.push(` ${markdownBullet(formatEdge(edge, nodesById))}`); + } + appendOmitted(lines, slice.edges.length - selectedEdges.length, 'edge(s)'); + + return lines.join('\n'); +} + +function summaryHeader(slice: GraphSlice, heading: string): string[] { + return [ + `[${heading}]`, + markdownBullet(`lsn: ${slice.lsn}`), + markdownBullet(`totals: ${slice.nodes.length} node(s), ${slice.edges.length} edge(s)`), + ]; +} + +function countNodesByGroup(nodes: readonly GraphNode[]): readonly (readonly [string, number])[] { + const counts = new Map(); + for (const node of nodes) { + const key = `${node.plane}/${node.kind}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return sortedCounts(counts); +} + +function countEdgesByCategory(edges: readonly GraphEdge[]): readonly (readonly [string, number])[] { + const counts = new Map(); + for (const edge of edges) { + counts.set(edge.category, (counts.get(edge.category) ?? 0) + 1); + } + return sortedCounts(counts); +} + +function sortedCounts(counts: ReadonlyMap): readonly (readonly [string, number])[] { + return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)); +} + +function groupNodes(nodes: readonly GraphNode[]): readonly (readonly [string, readonly GraphNode[]])[] { + const groups = new Map(); + for (const node of nodes) { + const key = `${node.plane}/${node.kind}`; + const group = groups.get(key) ?? []; + group.push(node); + groups.set(key, group); + } + return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); +} + +function appendCounts(lines: string[], counts: readonly (readonly [string, number])[]): void { + if (counts.length === 0) { + lines.push(` ${markdownBullet('none')}`); + return; + } + for (const [label, count] of counts) { + lines.push(` ${markdownBullet(`${label}: ${count}`)}`); + } +} + +function appendOmitted(lines: string[], omitted: number, label: string): void { + if (omitted > 0) { + lines.push(` ${markdownBullet(`…${omitted} more ${label} omitted`)}`); + } +} + +function formatNode(node: GraphNode, maxTitleLength: number): string { + return `[${formatGraphNodeCode(node.kind, node.kindOrdinal)}] ${node.plane}/${node.kind}: ${truncate(node.title, maxTitleLength)}`; +} + +function formatEdge( + edge: GraphEdge, + nodesById: ReadonlyMap>, +): string { + const source = nodesById.get(edge.sourceId); + const target = nodesById.get(edge.targetId); + const stance = edge.stance ? `/${edge.stance}` : ''; + const rationale = edge.rationale ? ` — ${truncate(edge.rationale, 100)}` : ''; + return `${formatEndpoint(edge.sourceId, source)} -[${edge.category}${stance}]-> ${formatEndpoint(edge.targetId, target)}${rationale}`; +} + +function formatEndpoint(id: number, node: Pick | undefined): string { + return node ? formatGraphNodeCode(node.kind, node.kindOrdinal) : `#${id}`; +} + +function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; +} diff --git a/src/renderers/graph/neighborhood.test.ts b/src/renderers/graph/neighborhood.test.ts deleted file mode 100644 index ea8b16d96..000000000 --- a/src/renderers/graph/neighborhood.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 3f9843cab..000000000 --- a/src/renderers/graph/neighborhood.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Formats projected node neighborhood context into model-facing text. - * - * Input: - * - projected output from projections/graph/neighborhood.ts - * - * Output: - * - markdown-framed TOON or equivalent compact text for LLM consumption - * - * Replaces/adapts: - * - .pi/agents/contexts/node.ts - * - .pi/extensions/graph/index.ts neighborhood result formatting - */ - -import type { ProjectedNeighborhood } from '../../projections/graph/neighborhood.js'; -import { markdownBullet } from '../markdown.js'; - -export function formatNeighborhood(projection: ProjectedNeighborhood): string { - if (projection.status === 'not_found') { - return '[Selected-spec node context]\n- node: not found in selected spec'; - } - - const lines = [ - '[Selected-spec node context]', - markdownBullet(`anchor: [${projection.anchor.code}] ${projection.anchor.label}`), - ]; - - if (projection.anchor.body) { - lines.push(markdownBullet(`anchor body: ${projection.anchor.body}`)); - } - - if (projection.neighbors.items.length === 0) { - lines.push(markdownBullet('neighbors: none within requested hops')); - } else { - lines.push(markdownBullet('neighbors:')); - for (const neighbor of projection.neighbors.items) { - lines.push(` ${markdownBullet(neighbor)}`); - } - if (projection.neighbors.omittedCount > 0) { - lines.push(` ${markdownBullet(`…${projection.neighbors.omittedCount} more neighbor(s) omitted`)}`); - } - } - - if (projection.edges.items.length === 0) { - lines.push(markdownBullet('edges: none')); - } else { - lines.push(markdownBullet('edges:')); - for (const edge of projection.edges.items) { - lines.push(` ${markdownBullet(edge)}`); - } - if (projection.edges.omittedCount > 0) { - lines.push(` ${markdownBullet(`…${projection.edges.omittedCount} more edge(s) omitted`)}`); - } - } - - return lines.join('\n'); -} diff --git a/src/renderers/graph/node-neighborhood.ts b/src/renderers/graph/node-neighborhood.ts new file mode 100644 index 000000000..11ae496ef --- /dev/null +++ b/src/renderers/graph/node-neighborhood.ts @@ -0,0 +1,137 @@ +/** + * Formats selected-spec node neighborhoods into model-facing text. + * + * Edges are projected from the anchor's perspective: grouped by the + * reconciliation-impact axis (upstream / downstream / lateral) and labelled + * with direction-aware semantic phrasing. Raw categories and endpoint roles + * never reach context. See src/graph/projection/. + */ + +import type { NodeNeighborhood } from '../../graph/index.js'; +import type { EdgeEndpoint } from '../../graph/policy/category-policy.js'; +import { relationFromAnchor, type EdgeRelation } from '../../graph/projection/direction.js'; +import { edgeLabel } from '../../graph/projection/labels.js'; +import { formatGraphNodeCode, type GraphNode } from '../../graph/schema/nodes.js'; +import { markdownBullet } from '../markdown.js'; + +export interface RenderNodeNeighborhoodOptions { + readonly maxEdges?: number; +} + +const DEFAULT_MAX_EDGES = 12; + +const SECTION_ORDER: readonly EdgeRelation[] = ['upstream', 'downstream', 'lateral']; + +const SECTION_HEADING: Record = { + upstream: 'upstream (review anchor if these change)', + downstream: 'downstream (reconcile if anchor changes)', + lateral: 'lateral (related)', +}; + +interface ProjectedEdge { + readonly relation: EdgeRelation; + readonly text: string; +} + +export function formatNeighborhood( + result: NodeNeighborhood, + options: RenderNodeNeighborhoodOptions = {}, +): string { + if (result.status === 'not_found') { + return '[Selected-spec node context]\n- node: not found in selected spec'; + } + + const maxEdges = options.maxEdges ?? DEFAULT_MAX_EDGES; + const nodesById = new Map([ + [result.node.id, result.node], + ...result.related.map((node) => [node.id, node] as const), + ]); + + const lines = [ + '[Selected-spec node context]', + markdownBullet( + `anchor: [${formatGraphNodeCode(result.node.kind, result.node.kindOrdinal)}] ${result.node.plane}/${result.node.kind}: ${result.node.title}`, + ), + ]; + if (result.node.body) { + lines.push(markdownBullet(`anchor body: ${truncate(result.node.body, 180)}`)); + } + + const { projected, ambient } = projectEdges(result, nodesById); + + if (projected.length === 0) { + lines.push(markdownBullet('relations: none')); + } else { + const shown = projected.slice(0, maxEdges); + for (const relation of SECTION_ORDER) { + const inSection = shown.filter((edge) => edge.relation === relation); + if (inSection.length === 0) continue; + lines.push(markdownBullet(`${SECTION_HEADING[relation]}:`)); + for (const edge of inSection) { + lines.push(` ${markdownBullet(edge.text)}`); + } + } + const omitted = projected.length - shown.length; + if (omitted > 0) { + lines.push(markdownBullet(`…${omitted} more relation(s) omitted`)); + } + } + + if (ambient > 0) { + lines.push(markdownBullet(`(+${ambient} edge(s) among neighbors, not incident on anchor)`)); + } + + return lines.join('\n'); +} + +function projectEdges( + result: Extract, + nodesById: ReadonlyMap, +): { readonly projected: readonly ProjectedEdge[]; readonly ambient: number } { + const anchorId = result.node.id; + const projected: ProjectedEdge[] = []; + const seen = new Set(); + let ambient = 0; + + for (const edge of result.edges) { + if (edge.sourceId !== anchorId && edge.targetId !== anchorId) { + ambient++; + continue; + } + const anchorRole: EdgeEndpoint = edge.sourceId === anchorId ? 'source' : 'target'; + const otherId = anchorRole === 'source' ? edge.targetId : edge.sourceId; + + const dedupeKey = `${edge.category}|${otherId}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const { relation, strength } = relationFromAnchor(edge.category, anchorRole); + const source = nodesById.get(edge.sourceId); + const target = nodesById.get(edge.targetId); + const label = edgeLabel({ + category: edge.category, + anchorRole, + stance: edge.stance, + sourceKind: source?.kind, + targetKind: target?.kind, + }); + + const other = nodesById.get(otherId); + const strengthTag = relation === 'downstream' ? ` {${strength === 'cascade' ? 'hard' : 'soft'}}` : ''; + projected.push({ + relation, + text: `${label} ${formatNeighbor(otherId, other)}${strengthTag}`, + }); + } + + return { projected, ambient }; +} + +function formatNeighbor(id: number, node: GraphNode | undefined): string { + if (!node) return `#${id}`; + return `[${formatGraphNodeCode(node.kind, node.kindOrdinal)}] ${node.plane}/${node.kind}: ${truncate(node.title, 90)}`; +} + +function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; +} diff --git a/src/renderers/graph/overview.ts b/src/renderers/graph/overview.ts deleted file mode 100644 index 8dd433a86..000000000 --- a/src/renderers/graph/overview.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Formats projected graph overview context into model-facing text. - * - * Input: - * - projected output from projections/graph/overview.ts - * - * Output: - * - markdown-framed TOON or equivalent compact text for LLM consumption - * - * Replaces/adapts: - * - .pi/agents/contexts/graph.ts - * - .pi/extensions/graph/command-adapter.ts overview formatting - */ - -export {}; diff --git a/src/renderers/graph/previews.test.ts b/src/renderers/graph/previews.test.ts new file mode 100644 index 000000000..bf1723a25 --- /dev/null +++ b/src/renderers/graph/previews.test.ts @@ -0,0 +1,166 @@ +/** + * Graph renderer previews — the single home for graph context-render coverage. + * + * Each case renders a fixture through a graph renderer and locks the full + * output as a markdown preview under `__previews__/`. The locked file IS the + * assertion: review the diff when output changes, accept with `--update`. + * + * The only inline assertions kept are cross-cutting *contract invariants* that + * a careless snapshot update could silently hide: + * - anchored neighborhood projections never leak raw structural vocabulary + * (category arrows, endpoint role tokens, internal numeric ids) into context; + * - large-graph slices stay bounded regardless of graph size. + * Positive "the output looks like X" checks live in the snapshot, not here. + */ + +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test } from 'vitest'; + +import { readGraphSliceFixture, readNodeNeighborhoodFixture } from './fixture-reads.test-support.js'; +import { formatGraphSlice } from './graph-slice.js'; +import { formatNeighborhood } from './node-neighborhood.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PREVIEWS_DIR = resolve(HERE, '__previews__'); + +async function lockPreview(fileName: string, rendered: string): Promise { + const locked = rendered.endsWith('\n') ? rendered : `${rendered}\n`; + mkdirSync(PREVIEWS_DIR, { recursive: true }); + await expect(locked).toMatchFileSnapshot(resolve(PREVIEWS_DIR, fileName)); +} + +/** + * Anchored projections must read in plain language — never the raw structural + * vocabulary that lives in storage. This guard runs on every neighborhood + * render so a snapshot update can't quietly reintroduce leakage. + */ +function expectNoStructuralLeak(rendered: string): void { + expect(rendered).not.toContain('-['); // raw "A -[category]-> B" arrows + expect(rendered).not.toContain('(dependency)'); // endpoint role tokens + expect(rendered).not.toContain('(dependent)'); + expect(rendered).not.toContain('#'); // internal numeric ids +} + +// ── GraphSlice (anchorless, whole-spec) ────────────────────────────────────── + +test('graph-slice: compact summary for a small graph', async () => { + const rendered = formatGraphSlice( + readGraphSliceFixture({ set: 'workspace-spread', fixture: 'alpha-grounding' }), + { heading: 'Selected-spec graph: workspace-spread/alpha-grounding' }, + ); + await lockPreview('graph-slice-alpha-grounding-compact-summary.md', rendered); +}); + +test('graph-slice: compact summary stays bounded for a large graph', async () => { + const rendered = formatGraphSlice(readGraphSliceFixture({ set: 'bilal-port', fixture: 'code-health' }), { + heading: 'Selected-spec graph: bilal-port/code-health', + }); + await lockPreview('graph-slice-code-health-compact-summary.md', rendered); + expect(rendered.split('\n').length).toBeLessThan(40); +}); + +test('graph-slice: grouped list is capped per kind', async () => { + const rendered = formatGraphSlice(readGraphSliceFixture({ set: 'bilal-port', fixture: 'code-health' }), { + heading: 'Selected-spec graph: bilal-port/code-health', + variant: 'grouped-list', + }); + await lockPreview('graph-slice-code-health-grouped-list.md', rendered); + expect(rendered.split('\n').length).toBeLessThan(60); +}); + +test('graph-slice: full-debug shows raw arrows by design but no role tokens', async () => { + const rendered = formatGraphSlice( + readGraphSliceFixture({ set: 'edge-spread', fixture: 'category-directions' }), + { + heading: 'Selected-spec graph: edge-spread/category-directions', + variant: 'full-debug', + maxEdges: 20, + maxNodes: 40, + }, + ); + await lockPreview('graph-slice-category-directions-full-debug.md', rendered); + // The debug view deliberately keeps the flat arrow form; perspective-relative + // projection applies only to anchored neighborhoods. Role tokens stay out + // even here (guards the reverted inline-role-token regression). + expect(rendered).toContain('-['); + expect(rendered).not.toContain('(dependency)'); + expect(rendered).not.toContain('(dependent)'); +}); + +// ── Neighborhood (anchored, perspective-relative projection) ────────────────── + +test('neighborhood: real-port anchor (code-health REQ1)', async () => { + const rendered = formatNeighborhood( + readNodeNeighborhoodFixture({ set: 'bilal-port', fixture: 'code-health', anchorCode: 'REQ1' }), + ); + await lockPreview('neighborhood-code-health-REQ1.md', rendered); + expectNoStructuralLeak(rendered); +}); + +const HUB = { set: 'edge-spread', fixture: 'hub-neighborhood', anchorCode: 'REQ1' } as const; + +test('neighborhood: every edge category projected from one anchor (hub REQ1)', async () => { + // The hub fixture wires REQ1 to naturally-typed neighbors across every + // relation direction, both proof/support stances, the three realization + // refinements, hard vs soft impact, and a lateral association. The snapshot + // is the full label + directional-grouping matrix; the per-cell mapping is + // proven by the projection unit tests. + const rendered = formatNeighborhood(readNodeNeighborhoodFixture(HUB), { maxEdges: 20 }); + await lockPreview('neighborhood-hub-REQ1.md', rendered); + expectNoStructuralLeak(rendered); +}); + +test('neighborhood: hops=2 collapses ambient edges to a count (hub REQ1)', async () => { + const rendered = formatNeighborhood(readNodeNeighborhoodFixture({ ...HUB, hops: 2 }), { + maxEdges: 30, + }); + await lockPreview('neighborhood-hub-REQ1-hops2.md', rendered); + expectNoStructuralLeak(rendered); +}); + +test('neighborhood: maxEdges bounds output and notes omissions (hub REQ1)', async () => { + const rendered = formatNeighborhood(readNodeNeighborhoodFixture(HUB), { maxEdges: 1 }); + await lockPreview('neighborhood-hub-REQ1-bounded.md', rendered); + expectNoStructuralLeak(rendered); +}); + +// ── Faithful spec graph derived from this repo's own prose ──────────────────── +// `brunch-self/spec-graph` is hand-derived from memory/SPEC.md + memory/PLAN.md. +// Seeding it here also proves structural legality: readGraphSliceFixture commits +// through the real CommandExecutor and throws on any illegal node/edge. + +const SELF = { set: 'brunch-self', fixture: 'spec-graph' } as const; + +test('brunch-self: whole-spec grouped list across all four planes', async () => { + const rendered = formatGraphSlice(readGraphSliceFixture(SELF), { + heading: 'Selected-spec graph: brunch-self (self-described)', + variant: 'grouped-list', + maxNodes: 60, + }); + await lockPreview('graph-slice-brunch-self-grouped-list.md', rendered); +}); + +test('brunch-self: requirement anchor neighborhood (REQ1 one-authority)', async () => { + const rendered = formatNeighborhood(readNodeNeighborhoodFixture({ ...SELF, anchorCode: 'REQ1' }), { + maxEdges: 20, + }); + await lockPreview('neighborhood-brunch-self-REQ1.md', rendered); + expectNoStructuralLeak(rendered); +}); + +test('brunch-self: module anchor neighborhood (MOD1 CommandExecutor)', async () => { + const rendered = formatNeighborhood(readNodeNeighborhoodFixture({ ...SELF, anchorCode: 'MOD1', hops: 2 }), { + maxEdges: 20, + }); + await lockPreview('neighborhood-brunch-self-MOD1-hops2.md', rendered); + expectNoStructuralLeak(rendered); +}); + +test('neighborhood: missing anchor renders a clear miss', () => { + expect(formatNeighborhood({ selector: { id: 404 }, status: 'not_found', related: [], edges: [] })).toBe( + '[Selected-spec node context]\n- node: not found in selected spec', + ); +}); diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 124dced10..443569451 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -400,9 +400,19 @@ describe('JSON-RPC handlers', () => { expect(overview).toBeDefined(); expect(neighborhood).toBeDefined(); expect(JSON.stringify(overview?.paramsSchema)).toContain('specId'); - expect(JSON.stringify(overview?.resultSchema)).toContain('nodeCount'); + const overviewResultSchema = JSON.stringify(overview?.resultSchema); + expect(overviewResultSchema).toContain('nodes'); + expect(overviewResultSchema).toContain('edges'); + expect(overviewResultSchema).toContain('lsn'); + expect(overviewResultSchema).not.toContain('nodeCount'); + expect(overviewResultSchema).not.toContain('edgeCount'); expect(JSON.stringify(neighborhood?.paramsSchema)).toContain('nodeId'); - expect(JSON.stringify(neighborhood?.resultSchema)).toContain('not_found'); + const neighborhoodResultSchema = JSON.stringify(neighborhood?.resultSchema); + expect(neighborhoodResultSchema).toContain('found'); + expect(neighborhoodResultSchema).toContain('not_found'); + expect(neighborhoodResultSchema).not.toContain('success'); + expect(neighborhoodResultSchema).not.toContain('anchor'); + expect(neighborhoodResultSchema).not.toContain('neighbors'); expect(overview?.examples).toContainEqual({ jsonrpc: '2.0', id: expect.any(Number), @@ -1365,7 +1375,7 @@ describe('JSON-RPC handlers', () => { review: { status: 'approved', lsn: expect.any(Number), - createdNodes: { 'requirement-draft': { id: expect.any(Number), code: 'R1' } }, + createdNodes: { 'requirement-draft': { id: expect.any(Number), code: 'REQ1' } }, }, capture: { status: 'no_capture' }, }, @@ -1383,7 +1393,6 @@ describe('JSON-RPC handlers', () => { }); expect(overview).toMatchObject({ result: { - nodeCount: 2, nodes: expect.arrayContaining([ expect.objectContaining({ kind: 'requirement', @@ -1469,7 +1478,7 @@ describe('JSON-RPC handlers', () => { goal: { code: 'G1' }, context: { code: 'CTX1' }, constraint: { code: 'CON1' }, - criterion: { code: 'CR1' }, + criterion: { code: 'AC1' }, }, }, }, @@ -1491,7 +1500,6 @@ describe('JSON-RPC handlers', () => { }); expect(overview).toMatchObject({ result: { - nodeCount: 4, nodes: expect.arrayContaining([ expect.objectContaining({ kind: 'goal', @@ -1517,7 +1525,8 @@ describe('JSON-RPC handlers', () => { }), ).resolves.toMatchObject({ result: { - nodeCount: 0, + nodes: [], + edges: [], }, }); }); @@ -1572,7 +1581,7 @@ describe('JSON-RPC handlers', () => { goal: { code: 'G1' }, context: { code: 'CTX1' }, constraint: { code: 'CON1' }, - criterion: { code: 'CR1' }, + criterion: { code: 'AC1' }, }, }, }, @@ -1590,7 +1599,6 @@ describe('JSON-RPC handlers', () => { }); expect(overview).toMatchObject({ result: { - nodeCount: 4, nodes: expect.arrayContaining([ expect.objectContaining({ kind: 'goal', @@ -1608,7 +1616,7 @@ describe('JSON-RPC handlers', () => { method: 'graph.overview', params: { specId: sibling.specId }, }), - ).resolves.toMatchObject({ result: { nodeCount: 0 } }); + ).resolves.toMatchObject({ result: { nodes: [], edges: [] } }); 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.'); @@ -2346,13 +2354,17 @@ describe('JSON-RPC handlers', () => { jsonrpc: '2.0', id: 50, result: { - nodeCount: 2, - edgeCount: 1, + nodes: expect.arrayContaining([ + expect.objectContaining({ title: 'Spec A requirement', specId: fixture.specAId }), + expect.objectContaining({ title: 'Spec A constraint', specId: fixture.specAId }), + ]), + edges: [expect.objectContaining({ category: 'dependency', specId: fixture.specAId })], lsn: fixture.specALsn, }, }); if (!('result' in overviewA)) throw new Error('expected graph overview'); - expect(JSON.stringify(overviewA.result)).toContain('Spec A requirement'); + expect(overviewA.result).not.toHaveProperty('nodeCount'); + expect(overviewA.result).not.toHaveProperty('edgeCount'); expect(JSON.stringify(overviewA.result)).not.toContain('Spec B goal'); const overviewB = await handlers.handle({ @@ -2364,8 +2376,15 @@ describe('JSON-RPC handlers', () => { expect(overviewB).toMatchObject({ jsonrpc: '2.0', id: 51, - result: { nodeCount: 1, edgeCount: 0, lsn: fixture.specBLsn }, + result: { + nodes: [expect.objectContaining({ title: 'Spec B goal' })], + edges: [], + lsn: fixture.specBLsn, + }, }); + if (!('result' in overviewB)) throw new Error('expected graph overview'); + expect(overviewB.result).not.toHaveProperty('nodeCount'); + expect(overviewB.result).not.toHaveProperty('edgeCount'); const crossSpecNeighborhood = await handlers.handle({ jsonrpc: '2.0', @@ -2376,7 +2395,12 @@ describe('JSON-RPC handlers', () => { expect(crossSpecNeighborhood).toEqual({ jsonrpc: '2.0', id: 52, - result: { status: 'not_found' }, + result: { + selector: { id: fixture.specBNodeId }, + status: 'not_found', + related: [], + edges: [], + }, }); const neighborhood = await handlers.handle({ @@ -2389,9 +2413,10 @@ describe('JSON-RPC handlers', () => { jsonrpc: '2.0', id: 53, result: { - status: 'success', - anchor: { id: fixture.specANodeId, specId: fixture.specAId }, - neighbors: [{ title: 'Spec A constraint', specId: fixture.specAId }], + selector: { id: fixture.specANodeId }, + status: 'found', + node: { id: fixture.specANodeId, specId: fixture.specAId }, + related: [{ title: 'Spec A constraint', specId: fixture.specAId }], edges: [{ category: 'dependency', specId: fixture.specAId }], }, }); @@ -2494,7 +2519,7 @@ describe('JSON-RPC handlers', () => { specId: fixture.specAId, basis: 'explicit', nodes: [{ ref: 'thesis', plane: 'intent', kind: 'thesis', title: 'Dev RPC thesis' }], - edges: [{ category: 'support', source: { existingCode: 'R1' }, target: 'thesis', stance: 'for' }], + edges: [{ category: 'support', source: { existingCode: 'REQ1' }, target: 'thesis', stance: 'for' }], }, }); @@ -2526,8 +2551,6 @@ describe('JSON-RPC handlers', () => { jsonrpc: '2.0', id: 61, result: { - nodeCount: 3, - edgeCount: 2, lsn: commitResult.lsn, nodes: expect.arrayContaining([expect.objectContaining({ title: 'Dev RPC thesis' })]), edges: expect.arrayContaining([expect.objectContaining({ category: 'support', stance: 'for' })]), @@ -2544,8 +2567,6 @@ describe('JSON-RPC handlers', () => { jsonrpc: '2.0', id: 62, result: { - nodeCount: 1, - edgeCount: 0, lsn: fixture.specBLsn, }, }); @@ -2585,7 +2606,7 @@ describe('JSON-RPC handlers', () => { specId: fixture.specAId, basis: 'explicit', nodes: [{ ref: 'thesis', plane: 'intent', kind: 'thesis', title: 'Invalid dev RPC thesis' }], - edges: [{ category: 'proof', source: { existingCode: 'R1' }, target: 'thesis' }], + edges: [{ category: 'proof', source: { existingCode: 'REQ1' }, target: 'thesis' }], }, }); @@ -2610,8 +2631,6 @@ describe('JSON-RPC handlers', () => { jsonrpc: '2.0', id: 64, result: { - nodeCount: 2, - edgeCount: 1, lsn: fixture.specALsn, }, }); @@ -2649,7 +2668,9 @@ describe('JSON-RPC handlers', () => { specId: fixture.specAId, basis: 'explicit', nodes: [{ ref: 'thesis', plane: 'intent', kind: 'thesis', title: 'Line RPC thesis' }], - edges: [{ category: 'support', source: { existingCode: 'R1' }, target: 'thesis', stance: 'for' }], + edges: [ + { category: 'support', source: { existingCode: 'REQ1' }, target: 'thesis', stance: 'for' }, + ], }, }, { jsonrpc: '2.0', id: 66, method: 'graph.overview', params: { specId: fixture.specAId } }, diff --git a/src/rpc/methods/graph.ts b/src/rpc/methods/graph.ts index 9e1bfad71..6235ac9fb 100644 --- a/src/rpc/methods/graph.ts +++ b/src/rpc/methods/graph.ts @@ -3,7 +3,7 @@ import { Value } from 'typebox/value'; import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js'; import type { RpcMethodContext, RpcMethodDefinition } from './registry.js'; -import { NonNegativeIntegerSchema, PositiveIntegerSchema } from './schemas.js'; +import { PositiveIntegerSchema } from './schemas.js'; const GraphOverviewParamsSchema = Type.Object( { @@ -32,9 +32,7 @@ const GraphOverviewResultSchema = Type.Object( { nodes: Type.Array(GraphNodeResultSchema), edges: Type.Array(GraphEdgeResultSchema), - nodeCount: NonNegativeIntegerSchema, - edgeCount: NonNegativeIntegerSchema, - lsn: NonNegativeIntegerSchema, + lsn: Type.Integer({ minimum: 0 }), }, { additionalProperties: false }, ); @@ -42,16 +40,20 @@ const GraphOverviewResultSchema = Type.Object( const GraphNodeNeighborhoodResultSchema = Type.Union([ Type.Object( { - status: Type.Literal('success'), - anchor: GraphNodeResultSchema, - neighbors: Type.Array(GraphNodeResultSchema), + selector: Type.Object({}, { additionalProperties: true }), + status: Type.Literal('found'), + node: GraphNodeResultSchema, + related: Type.Array(GraphNodeResultSchema), edges: Type.Array(GraphEdgeResultSchema), }, { additionalProperties: false }, ), Type.Object( { + selector: Type.Object({}, { additionalProperties: true }), status: Type.Literal('not_found'), + related: Type.Array(GraphNodeResultSchema), + edges: Type.Array(GraphEdgeResultSchema), }, { additionalProperties: false }, ), @@ -61,8 +63,7 @@ export const graphRpcMethods: readonly RpcMethodDefinition[] = { method: 'graph.overview', access: 'read', - description: - 'Return the canonical selected-spec graph overview with nodes, edges, counts, and current graph LSN.', + description: 'Return the canonical selected-spec graph slice with nodes, edges, and current graph LSN.', paramsSchema: GraphOverviewParamsSchema, resultSchema: GraphOverviewResultSchema, examples: [ @@ -80,7 +81,8 @@ export const graphRpcMethods: readonly RpcMethodDefinition[] = return createJsonRpcFailure(requestId, -32602, 'Invalid params'); } const graph = await context.getGraphRuntime(); - return createJsonRpcSuccess(requestId, graph.forSpec(params.value.specId).getGraphOverview()); + const result = graph.forSpec(params.value.specId).queryGraph(); + return createJsonRpcSuccess(requestId, result); }, }, { @@ -105,14 +107,15 @@ export const graphRpcMethods: readonly RpcMethodDefinition[] = return createJsonRpcFailure(requestId, -32602, 'Invalid params'); } const graph = await context.getGraphRuntime(); + const [result] = graph + .forSpec(params.value.specId) + .getNodes( + [{ id: params.value.nodeId }], + params.value.hops === undefined ? undefined : { hops: params.value.hops }, + ); return createJsonRpcSuccess( requestId, - graph - .forSpec(params.value.specId) - .getNodeNeighborhood( - params.value.nodeId, - params.value.hops === undefined ? undefined : { hops: params.value.hops }, - ), + result ?? { selector: { id: params.value.nodeId }, status: 'not_found', related: [], edges: [] }, ); }, }, diff --git a/src/rpc/methods/registry.ts b/src/rpc/methods/registry.ts index 65a583a57..2d1ba3a64 100644 --- a/src/rpc/methods/registry.ts +++ b/src/rpc/methods/registry.ts @@ -6,7 +6,7 @@ import type { import type { ProductUpdatePublisher } from '../product-updates.js'; import type { JsonRpcRequest, JsonRpcResponse } from '../protocol.js'; -export type RpcMethodAccess = 'read' | 'write'; +type RpcMethodAccess = 'read' | 'write'; export interface RpcMethodDefinition { readonly method: string; diff --git a/src/rpc/product-updates.ts b/src/rpc/product-updates.ts index 2bfb06dcf..f2a0870c8 100644 --- a/src/rpc/product-updates.ts +++ b/src/rpc/product-updates.ts @@ -1,6 +1,6 @@ export const BRUNCH_UPDATED_METHOD = 'brunch.updated'; -export type ProductUpdateTopic = +type ProductUpdateTopic = | 'workspace.state' | 'workspace.selectionState' | 'session.pendingExchange' @@ -26,7 +26,7 @@ export interface ProductUpdateNotification { }; } -export type ProductUpdateListener = (updates: readonly ProductUpdate[]) => void; +type ProductUpdateListener = (updates: readonly ProductUpdate[]) => void; export interface ProductUpdatePublisher { publish(update: ProductUpdate | readonly ProductUpdate[]): void; diff --git a/src/scripts/README.md b/src/scripts/README.md index 52906f492..f30e351af 100644 --- a/src/scripts/README.md +++ b/src/scripts/README.md @@ -6,10 +6,7 @@ SPEC decisions: D52-L Local executable utilities and script-facing helpers that are not product domain layers. -Current utilities: - -- `render-preview.ts` — writes reviewable renderer previews from seeded fixtures without changing product runtime code. - +No standing script utilities are currently owned here. 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 deleted file mode 100644 index a8fbed524..000000000 --- a/src/scripts/render-preview.ts +++ /dev/null @@ -1,113 +0,0 @@ -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/exchange-projection.ts b/src/session/exchange-projection.ts index cc067f799..8fef8f8f0 100644 --- a/src/session/exchange-projection.ts +++ b/src/session/exchange-projection.ts @@ -32,19 +32,19 @@ const STRUCTURED_RESPONSE_TYPES = new Set([ 'brunch.choice_response', ]); -export interface EntryRange { +interface EntryRange { start: string; end: string; } -export interface SessionExchange { +interface SessionExchange { promptRange: EntryRange; responseRange: EntryRange; promptEntryIds: string[]; responseEntryIds: string[]; } -export interface OpenPromptProjection { +interface OpenPromptProjection { promptRange: EntryRange; promptEntryIds: string[]; } @@ -55,7 +55,7 @@ export interface SessionExchangeProjection { openPrompt: OpenPromptProjection | null; } -export interface TranscriptDisplayRow { +interface TranscriptDisplayRow { id: string; role: 'prompt' | 'assistant' | 'user'; text: string; diff --git a/src/session/project-identity.ts b/src/session/project-identity.ts index 702ea12e6..342a87e2a 100644 --- a/src/session/project-identity.ts +++ b/src/session/project-identity.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; -export type ProjectIdentitySource = 'package.json' | 'pyproject.toml' | 'cargo.toml' | 'go.mod' | 'directory'; +type ProjectIdentitySource = 'package.json' | 'pyproject.toml' | 'cargo.toml' | 'go.mod' | 'directory'; export interface ProjectIdentity { /** Human-facing project name, as written in the source artifact. */ diff --git a/src/session/runtime-state.ts b/src/session/runtime-state.ts index e76ab5754..e4768d793 100644 --- a/src/session/runtime-state.ts +++ b/src/session/runtime-state.ts @@ -2,7 +2,7 @@ export const BRUNCH_AGENT_RUNTIME_STATE_CUSTOM_TYPE = 'brunch.agent_runtime_stat export type OperationalModeId = 'elicit'; export type AgentRoleId = 'elicitor'; -export type AutoAxisSelection = 'auto'; +type AutoAxisSelection = 'auto'; export type AgentStrategyId = | 'freestyle' | 'step-wise-decision-tree' diff --git a/src/session/structured-exchange-loop.ts b/src/session/structured-exchange-loop.ts index cc57e6d98..35b5bac90 100644 --- a/src/session/structured-exchange-loop.ts +++ b/src/session/structured-exchange-loop.ts @@ -45,25 +45,25 @@ export const PendingStructuredExchangeSchema = z.toJSONSchema(zPendingStructured unrepresentable: 'throw', }); -export interface StructuredExchangeTextResponseInput { +interface StructuredExchangeTextResponseInput { exchangeId: string; answer: { text: string }; note?: string | undefined; } -export interface StructuredExchangeSingleChoiceResponseInput { +interface StructuredExchangeSingleChoiceResponseInput { exchangeId: string; answer: { optionId: string }; note?: string | undefined; } -export interface StructuredExchangeMultiChoiceResponseInput { +interface StructuredExchangeMultiChoiceResponseInput { exchangeId: string; answer: { optionIds: string[] }; note?: string | undefined; } -export interface StructuredExchangeReviewResponseInput { +interface StructuredExchangeReviewResponseInput { exchangeId: string; answer: { review: { decision: 'approve' | 'request_changes' | 'reject'; comment?: string | undefined } }; note?: string | undefined; @@ -75,12 +75,12 @@ export type StructuredExchangeResponseInput = | StructuredExchangeMultiChoiceResponseInput | StructuredExchangeReviewResponseInput; -export interface AcceptedToolTextContent { +interface AcceptedToolTextContent { type: 'text'; text: string; } -export interface AcceptedToolResultMessage { +interface AcceptedToolResultMessage { role: 'toolResult'; toolCallId: string; toolName: string; diff --git a/src/session/workspace-context.ts b/src/session/workspace-context.ts index 8e1d60888..4d6e1b876 100644 --- a/src/session/workspace-context.ts +++ b/src/session/workspace-context.ts @@ -4,19 +4,19 @@ 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 { +interface WorkspaceSessionFileInventory { readonly file: string; readonly lineCount: number; readonly byteCount: number; } -export interface WorkspaceTreeEntryInventory { +interface WorkspaceTreeEntryInventory { readonly name: string; readonly kind: 'file' | 'directory'; readonly fileCount: number; } -export interface WorkspaceMarkdownFileInventory { +interface WorkspaceMarkdownFileInventory { readonly path: string; readonly lineCount: number; readonly byteCount: number; @@ -31,14 +31,14 @@ export interface WorkspaceCwdInventory { readonly markdownFiles: readonly WorkspaceMarkdownFileInventory[]; } -export interface WorkspaceSpecOverview { +interface WorkspaceSpecOverview { readonly id: number; readonly title: string; readonly nodeCount: number; readonly sessionCount: number; } -export interface WorkspaceSessionOverview { +interface WorkspaceSessionOverview { readonly id: string; readonly file: string; readonly specId: number; @@ -90,7 +90,7 @@ export async function inspectWorkspaceOverview(cwd: string): Promise left.title.localeCompare(right.title)); const specsById = new Map(specs.map((spec) => [spec.id, spec])); diff --git a/src/session/workspace-session-coordinator.ts b/src/session/workspace-session-coordinator.ts index c08a9d2e7..cd954a6a4 100644 --- a/src/session/workspace-session-coordinator.ts +++ b/src/session/workspace-session-coordinator.ts @@ -21,7 +21,7 @@ const STATE_FILE = 'workspace.json'; const SESSION_DIR = 'sessions'; const STATE_SCHEMA_VERSION = 1; -export interface WorkspaceSpecState { +interface WorkspaceSpecState { id: number; title: string; } @@ -73,20 +73,20 @@ export interface WorkspaceSessionReadyState { chrome: WorkspaceSessionChromeState; } -export interface WorkspaceSessionSelectSpecState { +interface WorkspaceSessionSelectSpecState { status: 'select_spec'; cwd: string; chrome: WorkspaceSessionChromeState; } -export interface WorkspaceSessionNeedsHumanState { +interface WorkspaceSessionNeedsHumanState { status: 'needs_human'; cwd: string; reason: string; chrome: WorkspaceSessionChromeState; } -export interface WorkspaceSessionCancelledState { +interface WorkspaceSessionCancelledState { status: 'cancelled'; cwd: string; chrome: WorkspaceSessionChromeState; @@ -97,29 +97,29 @@ export type WorkspaceSessionState = | WorkspaceSessionSelectSpecState | WorkspaceSessionNeedsHumanState; -export interface WorkspaceContinueDecision { +interface WorkspaceContinueDecision { action: 'continue'; specId: number; sessionFile: string; } -export interface WorkspaceOpenSessionDecision { +interface WorkspaceOpenSessionDecision { action: 'openSession'; specId: number; sessionFile: string; } -export interface WorkspaceNewSessionDecision { +interface WorkspaceNewSessionDecision { action: 'newSession'; specId: number; } -export interface WorkspaceNewSpecDecision { +interface WorkspaceNewSpecDecision { action: 'newSpec'; title: string; } -export interface WorkspaceCancelDecision { +interface WorkspaceCancelDecision { action: 'cancel'; } @@ -144,12 +144,12 @@ export interface WorkspaceLaunchSession { available: true; } -export interface WorkspaceLaunchSpec { +interface WorkspaceLaunchSpec { spec: WorkspaceSpecState; sessions: WorkspaceLaunchSession[]; } -export type WorkspaceUnavailableSessionReason = +type WorkspaceUnavailableSessionReason = | 'missing_header' | 'missing_binding' | 'incompatible_binding' @@ -180,7 +180,7 @@ export interface DefaultWorkspaceCoordinator { openDefaultWorkspace(): Promise; } -export interface WorkspaceSetupCoordinator { +interface WorkspaceSetupCoordinator { createSetupSession(options?: { specTitle?: string; createNewSpec?: boolean; @@ -192,7 +192,7 @@ export interface WorkspaceSessionBoundaryCoordinator { bindCurrentSpecToReplacementSession(manager: SessionManager): Promise; } -export interface WorkspaceDefaultChromeCoordinator { +interface WorkspaceDefaultChromeCoordinator { deriveDefaultChromeState(): Promise; } @@ -698,7 +698,7 @@ export interface WorkspaceStoreOracleOptions { expectedSessionCount?: number; } -export interface WorkspaceStoreOracleSuccess { +interface WorkspaceStoreOracleSuccess { ok: true; specId: number | null; sessions: Array<{ @@ -709,7 +709,7 @@ export interface WorkspaceStoreOracleSuccess { }>; } -export interface WorkspaceStoreOracleFailure { +interface WorkspaceStoreOracleFailure { ok: false; errors: string[]; } diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 000000000..44afd34ee --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,3 @@ +export function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; +} diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index dd0095839..7020c9e7f 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -1,11 +1,16 @@ // @vitest-environment jsdom +import { QueryClient } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { GraphSlice, NodeNeighborhood } from '../graph/queries.js'; import type { WorkspaceState } from '../projections/workspace/workspace-state.js'; import { BrunchWebApp, createBrunchWebRuntime } from './app.js'; +import { graphNodeNeighborhoodQueryOptions, graphOverviewQueryOptions } from './queries/graph.js'; +import { queryKeys } from './query-keys.js'; import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from './rpc-client.js'; +import { invalidateBrunchUpdate } from './subscriptions/brunch-updates.js'; interface RpcCall { method: string; @@ -45,10 +50,8 @@ const selectedSpecWithoutSessionState: WorkspaceState = { const emptyGraphOverview = { nodes: [], edges: [], - nodeCount: 0, - edgeCount: 0, lsn: 0, -}; +} satisfies GraphSlice; const populatedGraphOverview = { nodes: [ @@ -57,6 +60,7 @@ const populatedGraphOverview = { specId: 1, plane: 'intent', kind: 'requirement', + kindOrdinal: 1, title: 'Spec A requirement', basis: 'explicit', createdAtLsn: 1, @@ -67,6 +71,7 @@ const populatedGraphOverview = { specId: 1, plane: 'intent', kind: 'assumption', + kindOrdinal: 1, title: 'Spec A assumption', basis: 'explicit', createdAtLsn: 1, @@ -80,19 +85,26 @@ const populatedGraphOverview = { category: 'support', sourceId: 11, targetId: 10, - stance: 'supports', + stance: 'for', basis: 'explicit', createdAtLsn: 1, updatedAtLsn: 1, }, ], - nodeCount: 2, - edgeCount: 1, lsn: 1, -}; +} satisfies GraphSlice; +const foundNeighborhood = { + selector: { id: 11 }, + status: 'found', + node: populatedGraphOverview.nodes[1]!, + related: [populatedGraphOverview.nodes[0]!], + edges: populatedGraphOverview.edges, +} satisfies NodeNeighborhood; + function rpcClient(options?: { state?: WorkspaceState; - graphOverview?: typeof emptyGraphOverview | typeof populatedGraphOverview; + graphOverview?: GraphSlice; + nodeNeighborhood?: NodeNeighborhood; calls?: RpcCall[]; listeners?: Set; close?: ReturnType; @@ -112,6 +124,9 @@ function rpcClient(options?: { if (method === 'graph.overview') { return (options?.graphOverview ?? emptyGraphOverview) as T; } + if (method === 'graph.nodeNeighborhood') { + return (options?.nodeNeighborhood ?? foundNeighborhood) as T; + } throw new Error(`unexpected RPC method ${method}`); }, subscribe(listener: WebSocketRpcNotificationListener) { @@ -170,6 +185,85 @@ describe('Brunch React web app', () => { expect(screen.getByText('Focused read pending: graph.nodeNeighborhood(1, 11, 1)')).toBeTruthy(); }); + it('derives graph overview presentation from GraphSlice arrays without count aliases', async () => { + window.history.pushState(null, '', '/spec/1'); + const graphOverview = { + nodes: populatedGraphOverview.nodes, + edges: populatedGraphOverview.edges, + lsn: populatedGraphOverview.lsn, + } satisfies GraphSlice; + const runtime = createBrunchWebRuntime({ + rpcClient: rpcClient({ graphOverview }), + }); + + render(); + + expect(await screen.findByText('Graph overview')).toBeTruthy(); + expect(screen.getByText('2')).toBeTruthy(); + expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('support: 1')).toBeTruthy(); + }); + + it('keeps graph query options typed to graph-owned RPC shapes', async () => { + const calls: RpcCall[] = []; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rpc = rpcClient({ + calls, + graphOverview: populatedGraphOverview, + nodeNeighborhood: foundNeighborhood, + }); + + await expect(client.fetchQuery(graphOverviewQueryOptions(rpc, 1))).resolves.toEqual( + populatedGraphOverview, + ); + await expect(client.fetchQuery(graphNodeNeighborhoodQueryOptions(rpc, 1, 11, 2))).resolves.toEqual( + foundNeighborhood, + ); + expect(calls).toContainEqual({ method: 'graph.overview', params: { specId: 1 } }); + expect(calls).toContainEqual({ + method: 'graph.nodeNeighborhood', + params: { specId: 1, nodeId: 11, hops: 2 }, + }); + }); + + it('invalidates graph overview exactly and graph neighborhoods by selected-node prefix', () => { + const client = new QueryClient(); + const overviewKey = queryKeys.graph.overview(1); + const otherOverviewKey = queryKeys.graph.overview(2); + const matchingNeighborhoodKey = queryKeys.graph.nodeNeighborhood(1, 11, 1); + const otherNeighborhoodKey = queryKeys.graph.nodeNeighborhood(1, 12, 1); + client.setQueryData(overviewKey, populatedGraphOverview); + client.setQueryData(otherOverviewKey, emptyGraphOverview); + client.setQueryData(matchingNeighborhoodKey, foundNeighborhood); + client.setQueryData(otherNeighborhoodKey, foundNeighborhood); + + invalidateBrunchUpdate(client, { + jsonrpc: '2.0', + method: 'brunch.updated', + params: { updates: [{ topic: 'graph.overview', specId: 1 }] }, + }); + invalidateBrunchUpdate(client, { + jsonrpc: '2.0', + method: 'brunch.updated', + params: { updates: [{ topic: 'graph.nodeNeighborhood', specId: 1, nodeId: 11 }] }, + }); + + expect(client.getQueryCache().find({ queryKey: overviewKey, exact: true })?.state.isInvalidated).toBe( + true, + ); + expect( + client.getQueryCache().find({ queryKey: otherOverviewKey, exact: true })?.state.isInvalidated, + ).toBe(false); + expect( + client.getQueryCache().find({ queryKey: matchingNeighborhoodKey, exact: true })?.state.isInvalidated, + ).toBe(true); + expect( + client.getQueryCache().find({ queryKey: otherNeighborhoodKey, exact: true })?.state.isInvalidated, + ).toBe(false); + }); + it('invalidates the exact selected-spec graph overview query on graph notifications', async () => { window.history.pushState(null, '', '/spec/1'); const calls: RpcCall[] = []; diff --git a/src/web/app.tsx b/src/web/app.tsx index 8cd480be9..e3e282927 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -7,7 +7,7 @@ import { indexRoute, rootRoute, type BrunchWebRouterContext } from './routes/roo import { specRoute } from './routes/spec.js'; import type { WebSocketRpcClient } from './rpc-client.js'; -export type BrunchWebRouter = AnyRouter; +type BrunchWebRouter = AnyRouter; export interface BrunchWebRuntime { queryClient: BrunchWebRouterContext['queryClient']; diff --git a/src/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx index 248c8071f..e1a9d3ce1 100644 --- a/src/web/features/graph/GraphOverview.tsx +++ b/src/web/features/graph/GraphOverview.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import type { GraphOverview } from '../../../graph/queries.js'; +import type { GraphSlice } from '../../../graph/queries.js'; -export function GraphOverviewPanel(options: { overview: GraphOverview }) { +export function GraphOverviewPanel(options: { overview: GraphSlice }) { const { overview } = options; const [focusedNodeId, setFocusedNodeId] = useState(null); const nodeGroups = groupNodes(overview.nodes); @@ -23,11 +23,11 @@ export function GraphOverviewPanel(options: { overview: GraphOverview }) {
Nodes
-
{overview.nodeCount}
+
{overview.nodes.length}
Edges
-
{overview.edgeCount}
+
{overview.edges.length}
LSN
@@ -102,11 +102,11 @@ export function GraphOverviewPanel(options: { overview: GraphOverview }) { ); } -function groupNodes(nodes: GraphOverview['nodes']): Array<{ +function groupNodes(nodes: GraphSlice['nodes']): Array<{ label: string; - nodes: GraphOverview['nodes']; + nodes: GraphSlice['nodes']; }> { - const groups = new Map>(); + const groups = new Map>(); for (const node of nodes) { const label = `${node.plane} / ${node.kind}`; const group = groups.get(label); @@ -121,7 +121,7 @@ function groupNodes(nodes: GraphOverview['nodes']): Array<{ .map(([label, groupedNodes]) => ({ label, nodes: groupedNodes })); } -function summarizeEdges(edges: GraphOverview['edges']): Array<[string, number]> { +function summarizeEdges(edges: GraphSlice['edges']): Array<[string, number]> { const counts = new Map(); for (const edge of edges) { counts.set(edge.category, (counts.get(edge.category) ?? 0) + 1); diff --git a/src/web/queries/graph.ts b/src/web/queries/graph.ts index 0705efd80..b8e73a1d9 100644 --- a/src/web/queries/graph.ts +++ b/src/web/queries/graph.ts @@ -1,13 +1,13 @@ import { queryOptions } from '@tanstack/react-query'; -import type { GraphOverview, NeighborhoodResult } from '../../graph/queries.js'; +import type { GraphSlice, NodeNeighborhood } from '../../graph/queries.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; export function graphOverviewQueryOptions(rpcClient: WebSocketRpcClient, specId: number) { return queryOptions({ queryKey: queryKeys.graph.overview(specId), - queryFn: () => rpcClient.request('graph.overview', { specId }), + queryFn: () => rpcClient.request('graph.overview', { specId }), }); } @@ -20,7 +20,7 @@ export function graphNodeNeighborhoodQueryOptions( return queryOptions({ queryKey: queryKeys.graph.nodeNeighborhood(specId, nodeId, hops ?? null), queryFn: () => - rpcClient.request('graph.nodeNeighborhood', { + rpcClient.request('graph.nodeNeighborhood', { specId, nodeId, ...(hops === undefined ? {} : { hops }), diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx index 727aa0cbb..5ec14f2c7 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -7,7 +7,7 @@ import { workspaceStateQueryOptions } from '../queries/workspace.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; -export type SessionProjectionTarget = { +type SessionProjectionTarget = { sessionId: string; specId: number; }; diff --git a/src/web/rpc-client.ts b/src/web/rpc-client.ts index d39bb1d83..043820964 100644 --- a/src/web/rpc-client.ts +++ b/src/web/rpc-client.ts @@ -1,7 +1,5 @@ import type { JsonRpcFailure, JsonRpcId, JsonRpcRequest, JsonRpcResponse } from '../rpc/protocol.js'; -export type { JsonRpcRequest, JsonRpcResponse } from '../rpc/protocol.js'; - type WebSocketEventListener = (event: { data?: unknown }) => void; type WebSocketLike = Pick & {